From 8e113384aaedfd6c29934e3f4c3e55f460fcb8c9 Mon Sep 17 00:00:00 2001 From: Thomas Darimont Date: Mon, 29 Aug 2016 18:20:13 +0200 Subject: [PATCH] KEYCLOAK-3491 Revise Scripting Support Refactored the scripting infrastructure and added documentation. Added tests and an authenticator template in JavaScript for a quickstart. Increased height of ace code editor to 600px to avoid scrolling. --- .../java/org/keycloak/models/ScriptModel.java | 23 ++- .../keycloak/scripting/InvocableScript.java | 64 ------- .../scripting/InvocableScriptAdapter.java | 118 ++++++++++++ .../java/org/keycloak/scripting/Script.java | 18 ++ .../scripting/ScriptBindingsConfigurer.java | 20 +- .../scripting/ScriptExecutionException.java | 20 +- .../keycloak/scripting/ScriptingProvider.java | 35 +++- .../browser/ScriptBasedAuthenticator.java | 144 ++++++++++---- .../ScriptBasedAuthenticatorFactory.java | 42 +++- .../scripting/DefaultScriptingProvider.java | 91 ++++++--- .../DefaultScriptingProviderFactory.java | 28 ++- .../scripts/authenticator-template.js | 37 ++++ .../admin/authentication/ProvidersTest.java | 2 +- .../forms/ScriptAuthenticatorTest.java | 180 ++++++++++++++++++ .../scripts/authenticator-example.js | 10 + .../admin/resources/js/controllers/realm.js | 15 +- .../templates/kc-component-config.html | 2 +- .../keycloak/admin/resources/css/styles.css | 2 +- 18 files changed, 693 insertions(+), 158 deletions(-) delete mode 100644 server-spi/src/main/java/org/keycloak/scripting/InvocableScript.java create mode 100644 server-spi/src/main/java/org/keycloak/scripting/InvocableScriptAdapter.java create mode 100644 services/src/main/resources/scripts/authenticator-template.js create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ScriptAuthenticatorTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/resources/scripts/authenticator-example.js diff --git a/server-spi/src/main/java/org/keycloak/models/ScriptModel.java b/server-spi/src/main/java/org/keycloak/models/ScriptModel.java index 8d6d5fd665..c4b9735030 100644 --- a/server-spi/src/main/java/org/keycloak/models/ScriptModel.java +++ b/server-spi/src/main/java/org/keycloak/models/ScriptModel.java @@ -1,12 +1,33 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.keycloak.models; /** - * Denotes an executable Script with metadata. + * A representation of a Script with some additional meta-data. * * @author Thomas Darimont */ public interface ScriptModel { + /** + * MIME-Type for JavaScript + */ + String TEXT_JAVASCRIPT = "text/javascript"; + /** * Returns the unique id of the script. {@literal null} for ad-hoc created scripts. */ diff --git a/server-spi/src/main/java/org/keycloak/scripting/InvocableScript.java b/server-spi/src/main/java/org/keycloak/scripting/InvocableScript.java deleted file mode 100644 index 342652fa17..0000000000 --- a/server-spi/src/main/java/org/keycloak/scripting/InvocableScript.java +++ /dev/null @@ -1,64 +0,0 @@ -package org.keycloak.scripting; - -import org.keycloak.models.ScriptModel; - -import javax.script.Invocable; -import javax.script.ScriptEngine; -import javax.script.ScriptException; - -/** - * @author Thomas Darimont - */ -public class InvocableScript implements Invocable { - - /** - * Holds the script metadata as well as the actual script. - */ - private final ScriptModel script; - - /** - * Holds the {@link ScriptEngine} instance initialized with the script code. - */ - private final ScriptEngine scriptEngine; - - public InvocableScript(ScriptModel script, ScriptEngine scriptEngine) { - this.script = script; - this.scriptEngine = scriptEngine; - } - - @Override - public Object invokeMethod(Object thiz, String name, Object... args) throws ScriptException, NoSuchMethodException { - return getInvocableEngine().invokeMethod(thiz, name, args); - } - - @Override - public Object invokeFunction(String name, Object... args) throws ScriptException, NoSuchMethodException { - return getInvocableEngine().invokeFunction(name, args); - } - - @Override - public T getInterface(Class clazz) { - return getInvocableEngine().getInterface(clazz); - } - - @Override - public T getInterface(Object thiz, Class clazz) { - return getInvocableEngine().getInterface(thiz, clazz); - } - - private Invocable getInvocableEngine() { - return (Invocable) scriptEngine; - } - - /** - * Returns {@literal true} iif the {@link ScriptEngine} has a function with the given {@code functionName}. - * @param functionName - * @return - */ - public boolean hasFunction(String functionName){ - - Object candidate = scriptEngine.getContext().getAttribute(functionName); - - return candidate != null; - } -} diff --git a/server-spi/src/main/java/org/keycloak/scripting/InvocableScriptAdapter.java b/server-spi/src/main/java/org/keycloak/scripting/InvocableScriptAdapter.java new file mode 100644 index 0000000000..c3859aba35 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/scripting/InvocableScriptAdapter.java @@ -0,0 +1,118 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.scripting; + +import org.keycloak.models.ScriptModel; + +import javax.script.Invocable; +import javax.script.ScriptEngine; +import javax.script.ScriptException; + +/** + * Wraps a {@link ScriptModel} and makes it {@link Invocable}. + * + * @author Thomas Darimont + */ +public class InvocableScriptAdapter implements Invocable { + + /** + * Holds the {@ScriptModel} + */ + private final ScriptModel scriptModel; + + /** + * Holds the {@link ScriptEngine} instance initialized with the script code. + */ + private final ScriptEngine scriptEngine; + + /** + * Creates a new {@link InvocableScriptAdapter} instance. + * + * @param scriptModel must not be {@literal null} + * @param scriptEngine must not be {@literal null} + */ + public InvocableScriptAdapter(ScriptModel scriptModel, ScriptEngine scriptEngine) { + + if (scriptModel == null) { + throw new IllegalArgumentException("scriptModel must not be null"); + } + + if (scriptEngine == null) { + throw new IllegalArgumentException("scriptEngine must not be null"); + } + + this.scriptModel = scriptModel; + this.scriptEngine = loadScriptIntoEngine(scriptModel, scriptEngine); + } + + @Override + public Object invokeMethod(Object thiz, String name, Object... args) throws ScriptExecutionException { + + try { + return getInvocableEngine().invokeMethod(thiz, name, args); + } catch (ScriptException | NoSuchMethodException e) { + throw new ScriptExecutionException(scriptModel, e); + } + } + + @Override + public Object invokeFunction(String name, Object... args) throws ScriptExecutionException { + try { + return getInvocableEngine().invokeFunction(name, args); + } catch (ScriptException | NoSuchMethodException e) { + throw new ScriptExecutionException(scriptModel, e); + } + } + + @Override + public T getInterface(Class clazz) { + return getInvocableEngine().getInterface(clazz); + } + + @Override + public T getInterface(Object thiz, Class clazz) { + return getInvocableEngine().getInterface(thiz, clazz); + } + + /** + * Returns {@literal true} if the {@link ScriptEngine} has a definition with the given {@code name}. + * + * @param name + * @return + */ + public boolean isDefined(String name) { + + Object candidate = scriptEngine.getContext().getAttribute(name); + + return candidate != null; + } + + private ScriptEngine loadScriptIntoEngine(ScriptModel script, ScriptEngine engine) { + + try { + engine.eval(script.getCode()); + } catch (ScriptException se) { + throw new ScriptExecutionException(script, se); + } + + return engine; + } + + private Invocable getInvocableEngine() { + return (Invocable) scriptEngine; + } +} diff --git a/server-spi/src/main/java/org/keycloak/scripting/Script.java b/server-spi/src/main/java/org/keycloak/scripting/Script.java index 2e81372655..ef86902e24 100644 --- a/server-spi/src/main/java/org/keycloak/scripting/Script.java +++ b/server-spi/src/main/java/org/keycloak/scripting/Script.java @@ -1,8 +1,26 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.keycloak.scripting; import org.keycloak.models.ScriptModel; /** + * A {@link ScriptModel} which holds some meta-data. + * * @author Thomas Darimont */ public class Script implements ScriptModel { diff --git a/server-spi/src/main/java/org/keycloak/scripting/ScriptBindingsConfigurer.java b/server-spi/src/main/java/org/keycloak/scripting/ScriptBindingsConfigurer.java index 9d55195977..9613eb64e4 100644 --- a/server-spi/src/main/java/org/keycloak/scripting/ScriptBindingsConfigurer.java +++ b/server-spi/src/main/java/org/keycloak/scripting/ScriptBindingsConfigurer.java @@ -1,17 +1,33 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.keycloak.scripting; import javax.script.Bindings; /** * Callback interface for customization of {@link Bindings} for a {@link javax.script.ScriptEngine}. - * + *

Used by {@link ScriptingProvider}

* @author Thomas Darimont */ @FunctionalInterface public interface ScriptBindingsConfigurer { /** - * A default {@link ScriptBindingsConfigurer} leaves the Bindings empty. + * A default {@link ScriptBindingsConfigurer} that provides no Bindings. */ ScriptBindingsConfigurer EMPTY = new ScriptBindingsConfigurer() { diff --git a/server-spi/src/main/java/org/keycloak/scripting/ScriptExecutionException.java b/server-spi/src/main/java/org/keycloak/scripting/ScriptExecutionException.java index e912ca915e..2063bd21b9 100644 --- a/server-spi/src/main/java/org/keycloak/scripting/ScriptExecutionException.java +++ b/server-spi/src/main/java/org/keycloak/scripting/ScriptExecutionException.java @@ -1,3 +1,19 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.keycloak.scripting; import org.keycloak.models.ScriptModel; @@ -11,7 +27,7 @@ import javax.script.ScriptException; */ public class ScriptExecutionException extends RuntimeException { - public ScriptExecutionException(ScriptModel script, ScriptException se) { - super("Error executing script '" + script.getName() + "'", se); + public ScriptExecutionException(ScriptModel script, Exception ex) { + super("Could not execute script '" + script.getName() + "' problem was: " + ex.getMessage(), ex); } } diff --git a/server-spi/src/main/java/org/keycloak/scripting/ScriptingProvider.java b/server-spi/src/main/java/org/keycloak/scripting/ScriptingProvider.java index 163120bc7c..67bad5a9c9 100644 --- a/server-spi/src/main/java/org/keycloak/scripting/ScriptingProvider.java +++ b/server-spi/src/main/java/org/keycloak/scripting/ScriptingProvider.java @@ -1,3 +1,19 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.keycloak.scripting; import org.keycloak.models.ScriptModel; @@ -13,20 +29,23 @@ import javax.script.ScriptEngine; public interface ScriptingProvider extends Provider { /** - * Returns an {@link InvocableScript} based on the given {@link ScriptModel}. - *

The {@code InvocableScript} wraps a dedicated {@link ScriptEngine} that was populated with the provided {@link ScriptBindingsConfigurer}

+ * Returns an {@link InvocableScriptAdapter} based on the given {@link ScriptModel}. + *

The {@code InvocableScriptAdapter} wraps a dedicated {@link ScriptEngine} that was populated with the provided {@link ScriptBindingsConfigurer}

* - * @param script the script to wrap + * @param scriptModel the scriptModel to wrap * @param bindingsConfigurer populates the {@link javax.script.Bindings} * @return */ - InvocableScript prepareScript(ScriptModel script, ScriptBindingsConfigurer bindingsConfigurer); + InvocableScriptAdapter prepareInvocableScript(ScriptModel scriptModel, ScriptBindingsConfigurer bindingsConfigurer); /** - * Returns an {@link InvocableScript} based on the given {@link ScriptModel} with an {@link ScriptBindingsConfigurer#EMPTY} {@code ScriptBindingsConfigurer}. - * @see #prepareScript(ScriptModel, ScriptBindingsConfigurer) - * @param script + * Creates a new {@link ScriptModel} instance. + * + * @param realmId + * @param scriptName + * @param scriptCode + * @param scriptDescription * @return */ - InvocableScript prepareScript(ScriptModel script); + ScriptModel createScript(String realmId, String mimeType, String scriptName, String scriptCode, String scriptDescription); } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/ScriptBasedAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/ScriptBasedAuthenticator.java index 895f0356f0..85a217ff1e 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/ScriptBasedAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/ScriptBasedAuthenticator.java @@ -1,23 +1,82 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.keycloak.authentication.authenticators.browser; import org.jboss.logging.Logger; import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; import org.keycloak.authentication.Authenticator; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; +import org.keycloak.models.ScriptModel; import org.keycloak.models.UserModel; -import org.keycloak.scripting.InvocableScript; -import org.keycloak.scripting.Script; -import org.keycloak.scripting.ScriptBindingsConfigurer; +import org.keycloak.scripting.InvocableScriptAdapter; +import org.keycloak.scripting.ScriptExecutionException; import org.keycloak.scripting.ScriptingProvider; -import javax.script.Bindings; -import javax.script.ScriptException; import java.util.Map; /** * An {@link Authenticator} that can execute a configured script during authentication flow. - *

scripts must provide

+ *

+ * Scripts must at least provide one of the following functions: + *

    + *
  1. {@code authenticate(..)} which is called from {@link Authenticator#authenticate(AuthenticationFlowContext)}
  2. + *
  3. {@code action(..)} which is called from {@link Authenticator#action(AuthenticationFlowContext)}
  4. + *
+ *

+ *

+ * Custom {@link Authenticator Authenticator's} should at least provide the {@code authenticate(..)} function. + * The following script {@link javax.script.Bindings} are available for convenient use within script code. + *

    + *
  1. {@code script} the {@link ScriptModel} to access script metadata
  2. + *
  3. {@code realm} the {@link RealmModel}
  4. + *
  5. {@code user} the current {@link UserModel}
  6. + *
  7. {@code session} the active {@link KeycloakSession}
  8. + *
  9. {@code httpRequest} the current {@link org.jboss.resteasy.spi.HttpRequest}
  10. + *
  11. {@code LOG} a {@link org.jboss.logging.Logger} scoped to {@link ScriptBasedAuthenticator}/li> + *
+ *

+ *

+ * Additional context information can be extracted from the {@code context} argument passed to the {@code authenticate(context)} + * or {@code action(context)} function. + *

+ * An example {@link ScriptBasedAuthenticator} definition could look as follows: + *

+ * {@code
+ *
+ *   AuthenticationFlowError = Java.type("org.keycloak.authentication.AuthenticationFlowError");
+ *
+ *   function authenticate(context) {
+ *
+ *     LOG.info(script.name + " --> trace auth for: " + user.username);
+ *
+ *     if (   user.username === "tester"
+ *         && user.getAttribute("someAttribute")
+ *         && user.getAttribute("someAttribute").contains("someValue")) {
+ *
+ *         context.failure(AuthenticationFlowError.INVALID_USER);
+ *         return;
+ *     }
+ *
+ *     context.success();
+ *   }
+ * }
+ * 
* * @author Thomas Darimont */ @@ -29,58 +88,51 @@ public class ScriptBasedAuthenticator implements Authenticator { static final String SCRIPT_NAME = "scriptName"; static final String SCRIPT_DESCRIPTION = "scriptDescription"; - static final String ACTION = "action"; - static final String AUTHENTICATE = "authenticate"; - static final String TEXT_JAVASCRIPT = "text/javascript"; + static final String ACTION_FUNCTION_NAME = "action"; + static final String AUTHENTICATE_FUNCTION_NAME = "authenticate"; @Override public void authenticate(AuthenticationFlowContext context) { - tryInvoke(AUTHENTICATE, context); + tryInvoke(AUTHENTICATE_FUNCTION_NAME, context); } @Override public void action(AuthenticationFlowContext context) { - tryInvoke(ACTION, context); + tryInvoke(ACTION_FUNCTION_NAME, context); } private void tryInvoke(String functionName, AuthenticationFlowContext context) { - InvocableScript script = getInvocableScript(context); + if (!hasAuthenticatorConfig(context)) { + // this is an empty not yet configured script authenticator + // we mark this execution as success to not lock out users due to incompletely configured authenticators. + context.success(); + return; + } - if (!script.hasFunction(functionName)) { + InvocableScriptAdapter invocableScriptAdapter = getInvocableScriptAdapter(context); + + if (!invocableScriptAdapter.isDefined(functionName)) { return; } try { - //should context be wrapped in a readonly wrapper? - script.invokeFunction(functionName, context); - } catch (ScriptException | NoSuchMethodException e) { + //should context be wrapped in a read-only wrapper? + invocableScriptAdapter.invokeFunction(functionName, context); + } catch (ScriptExecutionException e) { LOGGER.error(e); + context.failure(AuthenticationFlowError.INTERNAL_ERROR); } } - private InvocableScript getInvocableScript(final AuthenticationFlowContext context) { - - final Script script = createAdhocScriptFromContext(context); - - ScriptBindingsConfigurer bindingsConfigurer = new ScriptBindingsConfigurer() { - - @Override - public void configureBindings(Bindings bindings) { - - bindings.put("script", script); - bindings.put("LOG", LOGGER); - } - }; - - ScriptingProvider scripting = context.getSession().scripting(); - - //how to deal with long running scripts -> timeout? - - return scripting.prepareScript(script, bindingsConfigurer); + private boolean hasAuthenticatorConfig(AuthenticationFlowContext context) { + return context != null + && context.getAuthenticatorConfig() != null + && context.getAuthenticatorConfig().getConfig() != null + && !context.getAuthenticatorConfig().getConfig().isEmpty(); } - private Script createAdhocScriptFromContext(AuthenticationFlowContext context) { + private InvocableScriptAdapter getInvocableScriptAdapter(AuthenticationFlowContext context) { Map config = context.getAuthenticatorConfig().getConfig(); @@ -90,21 +142,35 @@ public class ScriptBasedAuthenticator implements Authenticator { RealmModel realm = context.getRealm(); - return new Script(null /* scriptId */, realm.getId(), scriptName, TEXT_JAVASCRIPT, scriptCode, scriptDescription); + ScriptingProvider scripting = context.getSession().scripting(); + + //TODO lookup script by scriptId instead of creating it every time + ScriptModel script = scripting.createScript(realm.getId(), ScriptModel.TEXT_JAVASCRIPT, scriptName, scriptCode, scriptDescription); + + //how to deal with long running scripts -> timeout? + return scripting.prepareInvocableScript(script, bindings -> { + bindings.put("script", script); + bindings.put("realm", context.getRealm()); + bindings.put("user", context.getUser()); + bindings.put("session", context.getSession()); + bindings.put("httpRequest", context.getHttpRequest()); + bindings.put("LOG", LOGGER); + }); } @Override public boolean requiresUser() { - return false; + return true; } @Override public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { - return false; + return true; } @Override public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + //TODO make RequiredActions configurable in the script //NOOP } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/ScriptBasedAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/ScriptBasedAuthenticatorFactory.java index b25af8f1fd..0528154522 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/ScriptBasedAuthenticatorFactory.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/ScriptBasedAuthenticatorFactory.java @@ -1,5 +1,23 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.keycloak.authentication.authenticators.browser; +import org.apache.commons.io.IOUtils; +import org.jboss.logging.Logger; import org.keycloak.Config; import org.keycloak.authentication.Authenticator; import org.keycloak.authentication.AuthenticatorFactory; @@ -8,6 +26,7 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.provider.ProviderConfigProperty; +import java.io.IOException; import java.util.List; import static java.util.Arrays.asList; @@ -24,7 +43,9 @@ import static org.keycloak.provider.ProviderConfigProperty.STRING_TYPE; */ public class ScriptBasedAuthenticatorFactory implements AuthenticatorFactory { - static final String PROVIDER_ID = "auth-script-based"; + private static final Logger LOGGER = Logger.getLogger(ScriptBasedAuthenticatorFactory.class); + + public static final String PROVIDER_ID = "auth-script-based"; static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { AuthenticationExecutionModel.Requirement.REQUIRED, @@ -77,7 +98,7 @@ public class ScriptBasedAuthenticatorFactory implements AuthenticatorFactory { @Override public boolean isUserSetupAllowed() { - return false; + return true; } @Override @@ -87,12 +108,12 @@ public class ScriptBasedAuthenticatorFactory implements AuthenticatorFactory { @Override public String getDisplayType() { - return "Script-based Authentication"; + return "Script"; } @Override public String getHelpText() { - return "Script based authentication."; + return "Script based authentication. Allows to define custom authentication logic via JavaScript."; } @Override @@ -114,9 +135,16 @@ public class ScriptBasedAuthenticatorFactory implements AuthenticatorFactory { script.setType(SCRIPT_TYPE); script.setName(SCRIPT_CODE); script.setLabel("Script Source"); - script.setDefaultValue("//enter your script here"); - script.setHelpText("The script used to authenticate. Scripts must at least define a function with the name 'authenticate' that accepts a context (AuthenticationFlowContext) parameter." + - "This authenticator exposes the following additional variables: 'script', 'LOG'"); + + String scriptTemplate = "//enter your script code here"; + try { + scriptTemplate = IOUtils.toString(getClass().getResource("/scripts/authenticator-template.js")); + } catch (IOException ioe) { + LOGGER.warn(ioe); + } + script.setDefaultValue(scriptTemplate); + script.setHelpText("The script used to authenticate. Scripts must at least define a function with the name 'authenticate(context)' that accepts a context (AuthenticationFlowContext) parameter.\n" + + "This authenticator exposes the following additional variables: 'script', 'realm', 'user', 'session', 'httpRequest', 'LOG'"); return asList(name, description, script); } diff --git a/services/src/main/java/org/keycloak/scripting/DefaultScriptingProvider.java b/services/src/main/java/org/keycloak/scripting/DefaultScriptingProvider.java index 140ec04fbd..5772f53055 100644 --- a/services/src/main/java/org/keycloak/scripting/DefaultScriptingProvider.java +++ b/services/src/main/java/org/keycloak/scripting/DefaultScriptingProvider.java @@ -1,3 +1,19 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.keycloak.scripting; import org.keycloak.models.ScriptModel; @@ -6,7 +22,6 @@ import javax.script.Bindings; import javax.script.ScriptContext; import javax.script.ScriptEngine; import javax.script.ScriptEngineManager; -import javax.script.ScriptException; /** * A {@link ScriptingProvider} that uses a {@link ScriptEngineManager} to evaluate scripts with a {@link ScriptEngine}. @@ -18,40 +33,70 @@ public class DefaultScriptingProvider implements ScriptingProvider { private final ScriptEngineManager scriptEngineManager; public DefaultScriptingProvider(ScriptEngineManager scriptEngineManager) { + + if (scriptEngineManager == null) { + throw new IllegalStateException("scriptEngineManager must not be null!"); + } + this.scriptEngineManager = scriptEngineManager; } + /** + * Wraps the provided {@link ScriptModel} in a {@link javax.script.Invocable} instance with bindings configured through the {@link ScriptBindingsConfigurer}. + * + * @param scriptModel must not be {@literal null} + * @param bindingsConfigurer must not be {@literal null} + * @return + */ @Override - public InvocableScript prepareScript(ScriptModel script) { - return prepareScript(script, ScriptBindingsConfigurer.EMPTY); - } + public InvocableScriptAdapter prepareInvocableScript(ScriptModel scriptModel, ScriptBindingsConfigurer bindingsConfigurer) { - @Override - public InvocableScript prepareScript(ScriptModel script, ScriptBindingsConfigurer bindingsConfigurer) { - - if (script == null) { - throw new NullPointerException("script must not be null"); + if (scriptModel == null) { + throw new IllegalArgumentException("script must not be null"); } - if (script.getCode() == null || script.getCode().trim().isEmpty()) { + if (scriptModel.getCode() == null || scriptModel.getCode().trim().isEmpty()) { throw new IllegalArgumentException("script must not be null or empty"); } if (bindingsConfigurer == null) { - throw new NullPointerException("bindingsConfigurer must not be null"); + throw new IllegalArgumentException("bindingsConfigurer must not be null"); } - ScriptEngine engine = lookupScriptEngineFor(script); + ScriptEngine engine = createPreparedScriptEngine(scriptModel, bindingsConfigurer); - if (engine == null) { + return new InvocableScriptAdapter(scriptModel, engine); + } + + //TODO allow scripts to be maintained independently of other components, e.g. with dedicated persistence + //TODO allow script lookup by (scriptId) + //TODO allow script lookup by (name, realmName) + + @Override + public ScriptModel createScript(String realmId, String mimeType, String scriptName, String scriptCode, String scriptDescription) { + + ScriptModel script = new Script(null /* scriptId */, realmId, scriptName, mimeType, scriptCode, scriptDescription); + return script; + } + + /** + * Looks-up a {@link ScriptEngine} with prepared {@link Bindings} for the given {@link ScriptModel Script}. + * + * @param script + * @param bindingsConfigurer + * @return + */ + private ScriptEngine createPreparedScriptEngine(ScriptModel script, ScriptBindingsConfigurer bindingsConfigurer) { + + ScriptEngine scriptEngine = lookupScriptEngineFor(script); + + if (scriptEngine == null) { throw new IllegalStateException("Could not find ScriptEngine for script: " + script); } - configureBindings(bindingsConfigurer, engine); + configureBindings(bindingsConfigurer, scriptEngine); - loadScriptIntoEngine(script, engine); - - return new InvocableScript(script, engine); + return scriptEngine; } private void configureBindings(ScriptBindingsConfigurer bindingsConfigurer, ScriptEngine engine) { @@ -61,15 +106,9 @@ public class DefaultScriptingProvider implements ScriptingProvider { engine.setBindings(bindings, ScriptContext.ENGINE_SCOPE); } - private void loadScriptIntoEngine(ScriptModel script, ScriptEngine engine) { - - try { - engine.eval(script.getCode()); - } catch (ScriptException se) { - throw new ScriptExecutionException(script, se); - } - } - + /** + * Looks-up a {@link ScriptEngine} based on the MIME-type provided by the given {@link Script}. + */ private ScriptEngine lookupScriptEngineFor(ScriptModel script) { return scriptEngineManager.getEngineByMimeType(script.getMimeType()); } diff --git a/services/src/main/java/org/keycloak/scripting/DefaultScriptingProviderFactory.java b/services/src/main/java/org/keycloak/scripting/DefaultScriptingProviderFactory.java index 2e8a431aa0..b00a058eee 100644 --- a/services/src/main/java/org/keycloak/scripting/DefaultScriptingProviderFactory.java +++ b/services/src/main/java/org/keycloak/scripting/DefaultScriptingProviderFactory.java @@ -1,3 +1,19 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.keycloak.scripting; import org.keycloak.Config; @@ -13,11 +29,9 @@ public class DefaultScriptingProviderFactory implements ScriptingProviderFactory static final String ID = "script-based-auth"; - private final ScriptEngineManager scriptEngineManager = new ScriptEngineManager(); - @Override public ScriptingProvider create(KeycloakSession session) { - return new DefaultScriptingProvider(scriptEngineManager); + return new DefaultScriptingProvider(ScriptEngineManagerHolder.SCRIPT_ENGINE_MANAGER); } @Override @@ -39,4 +53,12 @@ public class DefaultScriptingProviderFactory implements ScriptingProviderFactory public String getId() { return ID; } + + /** + * Holder class for lazy initialization of {@link ScriptEngineManager}. + */ + private static class ScriptEngineManagerHolder { + + private static final ScriptEngineManager SCRIPT_ENGINE_MANAGER = new ScriptEngineManager(); + } } diff --git a/services/src/main/resources/scripts/authenticator-template.js b/services/src/main/resources/scripts/authenticator-template.js new file mode 100644 index 0000000000..73bb12475f --- /dev/null +++ b/services/src/main/resources/scripts/authenticator-template.js @@ -0,0 +1,37 @@ +/* + * Template for JavaScript based authenticator's. + * See org.keycloak.authentication.authenticators.browser.ScriptBasedAuthenticatorFactory + */ + +// import enum for error lookup +AuthenticationFlowError = Java.type("org.keycloak.authentication.AuthenticationFlowError"); + +/** + * An example authenticate function. + * + * The following variables are available for convenience: + * user - current user {@see org.keycloak.models.UserModel} + * realm - current realm {@see org.keycloak.models.RealmModel} + * session - current KeycloakSession {@see org.keycloak.models.KeycloakSession} + * httpRequest - current HttpRequest {@see org.jboss.resteasy.spi.HttpRequest} + * script - current script {@see org.keycloak.models.ScriptModel} + * LOG - current logger {@see org.jboss.logging.Logger} + * + * You one can extract current http request headers via: + * httpRequest.getHttpHeaders().getHeaderString("Forwarded") + * + * @param context {@see org.keycloak.authentication.AuthenticationFlowContext} + */ +function authenticate(context) { + + LOG.info(script.name + " trace auth for: " + user.username); + + var authShouldFail = false; + if (authShouldFail) { + + context.failure(AuthenticationFlowError.INVALID_USER); + return; + } + + context.success(); +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java index 4e07e6fbcc..0f2133b86c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java @@ -136,7 +136,7 @@ public class ProvidersTest extends AbstractAuthenticationTest { "Validates a OTP on a separate OTP form. Only shown if required based on the configured conditions."); addProviderInfo(result, "auth-cookie", "Cookie", "Validates the SSO cookie set by the auth server."); addProviderInfo(result, "auth-otp-form", "OTP Form", "Validates a OTP on a separate OTP form."); - addProviderInfo(result, "auth-script-based", "Script-based Authentication", "Script based authentication."); + addProviderInfo(result, "auth-script-based", "Script", "Script based authentication. Allows to define custom authentication logic via JavaScript."); addProviderInfo(result, "auth-spnego", "Kerberos", "Initiates the SPNEGO protocol. Most often used with Kerberos."); addProviderInfo(result, "auth-username-password-form", "Username Password Form", "Validates a username and password from login form."); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ScriptAuthenticatorTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ScriptAuthenticatorTest.java new file mode 100644 index 0000000000..667c85ff59 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ScriptAuthenticatorTest.java @@ -0,0 +1,180 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.testsuite.forms; + +import org.apache.commons.io.IOUtils; +import org.jboss.arquillian.graphene.page.Page; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.authentication.AuthenticationFlow; +import org.keycloak.authentication.authenticators.browser.ScriptBasedAuthenticatorFactory; +import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventType; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.representations.idm.AuthenticationExecutionExportRepresentation; +import org.keycloak.representations.idm.AuthenticationExecutionRepresentation; +import org.keycloak.representations.idm.AuthenticationFlowRepresentation; +import org.keycloak.representations.idm.AuthenticatorConfigRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.storage.user.UserCredentialAuthenticationProvider; +import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.pages.LoginPage; +import org.keycloak.testsuite.util.ExecutionBuilder; +import org.keycloak.testsuite.util.FlowBuilder; +import org.keycloak.testsuite.util.RealmBuilder; +import org.keycloak.testsuite.util.UserBuilder; + +import javax.ws.rs.core.Response; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; + +/** + * Tests for {@link org.keycloak.authentication.authenticators.browser.ScriptBasedAuthenticator} + * + * @author Thomas Darimont + */ +public class ScriptAuthenticatorTest extends AbstractFlowTest { + + UserRepresentation failUser; + + UserRepresentation okayUser; + + @Page + protected LoginPage loginPage; + + @Rule + public AssertEvents events = new AssertEvents(this); + + private AuthenticationFlowRepresentation flow; + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + + failUser = UserBuilder.create() + .id("fail") + .username("fail") + .email("fail@test.com") + .enabled(true) + .password("password") + .build(); + + okayUser = UserBuilder.create() + .id("user") + .username("user") + .email("user@test.com") + .enabled(true) + .password("password") + .build(); + + RealmBuilder.edit(testRealm) + .user(failUser) + .user(okayUser); + } + + @Before + public void configureFlows() throws Exception { + + String scriptFlow = "scriptBrowser"; + + AuthenticationFlowRepresentation scriptBrowserFlow = FlowBuilder.create() + .alias(scriptFlow) + .description("dummy pass through registration") + .providerId("basic-flow") + .topLevel(true) + .builtIn(false) + .build(); + + String scriptAuth = "scriptAuth"; + + Response createFlowResponse = testRealm().flows().createFlow(scriptBrowserFlow); + Assert.assertEquals(201, createFlowResponse.getStatus()); + + RealmRepresentation realm = testRealm().toRepresentation(); + realm.setBrowserFlow(scriptFlow); + realm.setDirectGrantFlow(scriptFlow); + testRealm().update(realm); + + this.flow = findFlowByAlias(scriptFlow); + + AuthenticationExecutionRepresentation usernamePasswordFormExecution = ExecutionBuilder.create() + .id("username password form") + .parentFlow(this.flow.getId()) + .requirement(AuthenticationExecutionModel.Requirement.REQUIRED.name()) + .authenticator(UsernamePasswordFormFactory.PROVIDER_ID) + .build(); + + AuthenticationExecutionRepresentation authScriptExecution = ExecutionBuilder.create() + .id(scriptAuth) + .parentFlow(this.flow.getId()) + .requirement(AuthenticationExecutionModel.Requirement.REQUIRED.name()) + .authenticator(ScriptBasedAuthenticatorFactory.PROVIDER_ID) + .build(); + + Response addExecutionResponse = testRealm().flows().addExecution(usernamePasswordFormExecution); + Assert.assertEquals(201, addExecutionResponse.getStatus()); + + addExecutionResponse = testRealm().flows().addExecution(authScriptExecution); + Assert.assertEquals(201, addExecutionResponse.getStatus()); + + Response newExecutionConfigResponse = testRealm().flows().newExecutionConfig(scriptAuth, createScriptAuthConfig(scriptAuth, "authenticator-example.js", "/scripts/authenticator-example.js", "simple script based authenticator")); + Assert.assertEquals(201, newExecutionConfigResponse.getStatus()); + } + + /** + * KEYCLOAK-3491 + */ + @Test + public void loginShouldWorkWithScriptAuthenticator() { + + loginPage.open(); + + loginPage.login(okayUser.getUsername(), "password"); + + events.expectLogin().user(okayUser.getId()).detail(Details.USERNAME, okayUser.getUsername()).assertEvent(); + } + + /** + * KEYCLOAK-3491 + */ + @Test + public void loginShouldFailWithScriptAuthenticator() { + + loginPage.open(); + + loginPage.login(failUser.getUsername(), "password"); + + events.expect(EventType.LOGIN_ERROR).user((String)null).error(Errors.USER_NOT_FOUND).assertEvent(); + } + + private AuthenticatorConfigRepresentation createScriptAuthConfig(String alias, String scriptName, String scriptCodePath, String scriptDescription) throws IOException { + + AuthenticatorConfigRepresentation configRep = new AuthenticatorConfigRepresentation(); + + configRep.setAlias(alias); + configRep.getConfig().put("scriptCode", IOUtils.toString(getClass().getResourceAsStream(scriptCodePath))); + configRep.getConfig().put("scriptName", scriptName); + configRep.getConfig().put("scriptDescription", scriptDescription); + + return configRep; + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/scripts/authenticator-example.js b/testsuite/integration-arquillian/tests/base/src/test/resources/scripts/authenticator-example.js new file mode 100644 index 0000000000..0fc10a426a --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/scripts/authenticator-example.js @@ -0,0 +1,10 @@ +AuthenticationFlowError = Java.type("org.keycloak.authentication.AuthenticationFlowError"); + +function authenticate(context) { + LOG.info(script.name + " --> trace auth for: " + user.username); + if (user.username === "fail") { + context.failure(AuthenticationFlowError.INVALID_USER); + return; + } + context.success(); +} \ No newline at end of file diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js index f42ce55b22..8d631109d5 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js @@ -2118,9 +2118,20 @@ module.controller('AuthenticationConfigCreateCtrl', function($scope, realm, flow $scope.realm = realm; $scope.flow = flow; $scope.create = true; - $scope.config = { config: {}}; $scope.configType = configType; + var defaultConfig = {}; + if (configType && Array.isArray(configType.properties)) { + for(var i = 0; i < configType.properties.length; i++) { + var property = configType.properties[i]; + if (property && property.name) { + defaultConfig[property.name] = property.defaultValue; + } + } + } + + $scope.config = { config: defaultConfig}; + $scope.$watch(function() { return $location.path(); }, function() { @@ -2145,8 +2156,6 @@ module.controller('AuthenticationConfigCreateCtrl', function($scope, realm, flow //$location.url("/realms"); window.history.back(); }; - - }); module.controller('ClientInitialAccessCtrl', function($scope, realm, clientInitialAccess, clientRegTrustedHosts, ClientInitialAccess, ClientRegistrationTrustedHost, Dialog, Notifications, $route, $location) { diff --git a/themes/src/main/resources/theme/base/admin/resources/templates/kc-component-config.html b/themes/src/main/resources/theme/base/admin/resources/templates/kc-component-config.html index 97d88762ee..57bbc06de4 100755 --- a/themes/src/main/resources/theme/base/admin/resources/templates/kc-component-config.html +++ b/themes/src/main/resources/theme/base/admin/resources/templates/kc-component-config.html @@ -33,7 +33,7 @@
-
+
{{config[option.name]}}
diff --git a/themes/src/main/resources/theme/keycloak/admin/resources/css/styles.css b/themes/src/main/resources/theme/keycloak/admin/resources/css/styles.css index c3a9a64eab..9253c8c839 100755 --- a/themes/src/main/resources/theme/keycloak/admin/resources/css/styles.css +++ b/themes/src/main/resources/theme/keycloak/admin/resources/css/styles.css @@ -377,6 +377,6 @@ h1 i { } .ace_editor { - height: 400px; + height: 600px; width: 100%; } \ No newline at end of file