From bcc2c893ef0845e299e436910e12aafbebff0ea1 Mon Sep 17 00:00:00 2001 From: Bill Burke Date: Tue, 11 Aug 2015 13:04:40 -0400 Subject: [PATCH] refactor requiredactions, start doco --- .../reference/en/en-US/modules/auth-spi.xml | 80 +++++++++++ .../java/org/keycloak/events/Details.java | 2 +- .../java/org/keycloak/events/EventType.java | 3 +- examples/providers/authenticator/README.md | 27 ++++ examples/providers/authenticator/pom.xml | 47 +++++++ .../SecretQuestionAuthenticator.java | 80 +++++++++++ .../SecretQuestionAuthenticatorFactory.java | 105 +++++++++++++++ ...ycloak.authentication.AuthenticatorFactory | 1 + examples/providers/pom.xml | 1 + .../main/resources/theme/base/login/terms.ftl | 6 +- .../keycloak/login/LoginFormsProvider.java | 2 +- .../FreeMarkerLoginFormsProvider.java | 2 +- .../models/utils/CredentialValidation.java | 21 +-- .../AbstractFormAuthenticator.java | 67 +++++++++ .../AbstractFormRequiredAction.java | 67 +++++++++ .../AuthenticationFlowContext.java | 19 +++ .../AuthenticationProcessor.java | 36 ++--- .../DefaultAuthenticationFlow.java | 14 +- .../keycloak/authentication/FlowStatus.java | 47 +++++++ .../authentication/RequiredActionContext.java | 16 +++ .../RequiredActionContextResult.java | 127 ++++++++++++++++++ .../RequiredActionProvider.java | 10 +- ...=> AbstractUsernameFormAuthenticator.java} | 38 +----- .../browser/OTPFormAuthenticator.java | 2 +- .../browser/SpnegoAuthenticator.java | 2 +- .../browser/UsernamePasswordForm.java | 2 +- .../directgrant/ValidateUsername.java | 4 +- .../requiredactions/TermsAndConditions.java | 62 ++------- .../requiredactions/UpdatePassword.java | 11 +- .../requiredactions/UpdateProfile.java | 11 +- .../requiredactions/UpdateTotp.java | 11 +- .../requiredactions/VerifyEmail.java | 13 +- .../managers/AuthenticationManager.java | 81 +++++------ .../resources/LoginActionsService.java | 104 +++++++------- .../actions/TermsAndConditionsTest.java | 119 ++++++++++++++++ .../pages/TermsAndConditionsPage.java | 54 ++++++++ 36 files changed, 1030 insertions(+), 264 deletions(-) create mode 100755 docbook/reference/en/en-US/modules/auth-spi.xml create mode 100755 examples/providers/authenticator/README.md create mode 100755 examples/providers/authenticator/pom.xml create mode 100755 examples/providers/authenticator/src/main/java/org/keycloak/examples/authenticator/SecretQuestionAuthenticator.java create mode 100755 examples/providers/authenticator/src/main/java/org/keycloak/examples/authenticator/SecretQuestionAuthenticatorFactory.java create mode 100755 examples/providers/authenticator/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory create mode 100755 services/src/main/java/org/keycloak/authentication/AbstractFormAuthenticator.java create mode 100755 services/src/main/java/org/keycloak/authentication/AbstractFormRequiredAction.java create mode 100755 services/src/main/java/org/keycloak/authentication/FlowStatus.java create mode 100755 services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java rename services/src/main/java/org/keycloak/authentication/authenticators/browser/{AbstractFormAuthenticator.java => AbstractUsernameFormAuthenticator.java} (82%) create mode 100755 testsuite/integration/src/test/java/org/keycloak/testsuite/actions/TermsAndConditionsTest.java create mode 100755 testsuite/integration/src/test/java/org/keycloak/testsuite/pages/TermsAndConditionsPage.java diff --git a/docbook/reference/en/en-US/modules/auth-spi.xml b/docbook/reference/en/en-US/modules/auth-spi.xml new file mode 100755 index 0000000000..54bd36d179 --- /dev/null +++ b/docbook/reference/en/en-US/modules/auth-spi.xml @@ -0,0 +1,80 @@ + + Custom Authentication, Registration, and Required Actions + + Keycloak comes out of the box with a bunch of different authentication mechanisms: kerberos, password, and otp. + These mechanisms may not meet all of your requirements and you may want to plug in your own custom ones. Keycloak + provides an authentication SPI that you can use to write new plugins. The admin console supports applying, ordering, + and configuring these new mechanisms. + + + Keycloak also supports a simple registration form. Different aspects of this form can be enabled and disabled i.e. + Recaptcha support can be turned off and on. The same authentication SPI can be used to add another page to the + registration flow or reimplement it entirely. There's also an additional fine-grain SPI you can use to add + specific validations and user extensions to the built in registration form. + + + A required action in Keycloak is an action that a user has to perform after he authenticates. After the action + is performed successfully, the user doesn't have to perform the action again. Keycloak comes with some built in + required actions like "reset password". This action forces the user to change their password after they have logged in. + You can write and plug in your own required actions. + +
+ Terms + + To first learn about the Authentication SPI, let's go over some of the terms used to describe it. + + + Authentication Flow + + + A flow is a container for all authentications that must happen during login or registration. If you + go to the admin console authentication page, you can view all the defined flows in the system and + what authenticators they are made up of. Flows can contain other flows. You can also bind a new + different flow for browser login, direct granta access, and registration. + + + + + Authenticator + + + An authenticator is a pluggable component that hold the logic for performing the authentication + or action within a flow. It is usually a singleton. + + + + + Execution + + + An execution is an object that binds the authenticator to the flow and the authenticator + to the configuration of the authenticator. Flows contain execution entries. + + + + + Execution Requirement + + + Each execution defines how an authenticator behaves in a flow. The requirement defines + whether the authenticator is enabled, disabled, optional, required, or an alternative. An + alternative requirement means that the authentiactor is optional unless no other alternative + authenticator is successful in the flow. For example, cookie authentication, kerberos, + and the set of all login forms are all alternative. If one of those is successful, none of + the others are executed. + + + + + Authenticator Config + + + This object defines the configuration for the Authenticator for a specific execution within + an authentication flow. Each execution can have a different config. + + + + + +
+
\ No newline at end of file diff --git a/events/api/src/main/java/org/keycloak/events/Details.java b/events/api/src/main/java/org/keycloak/events/Details.java index 679b67010a..468d8528ad 100755 --- a/events/api/src/main/java/org/keycloak/events/Details.java +++ b/events/api/src/main/java/org/keycloak/events/Details.java @@ -4,7 +4,7 @@ package org.keycloak.events; * @author Stian Thorgersen */ public interface Details { - + String CUSTOM_REQUIRED_ACTION="custom_required_action"; String EMAIL = "email"; String PREVIOUS_EMAIL = "previous_email"; String UPDATED_EMAIL = "updated_email"; diff --git a/events/api/src/main/java/org/keycloak/events/EventType.java b/events/api/src/main/java/org/keycloak/events/EventType.java index d8c0d19319..b049b2a038 100755 --- a/events/api/src/main/java/org/keycloak/events/EventType.java +++ b/events/api/src/main/java/org/keycloak/events/EventType.java @@ -66,7 +66,8 @@ public enum EventType { IDENTITY_PROVIDER_RETRIEVE_TOKEN_ERROR(false), IDENTITY_PROVIDER_ACCCOUNT_LINKING(false), IDENTITY_PROVIDER_ACCCOUNT_LINKING_ERROR(false), - IMPERSONATE(true); + IMPERSONATE(true), + CUSTOM_REQUIRED_ACTION(true); private boolean saveByDefault; diff --git a/examples/providers/authenticator/README.md b/examples/providers/authenticator/README.md new file mode 100755 index 0000000000..e65a7789de --- /dev/null +++ b/examples/providers/authenticator/README.md @@ -0,0 +1,27 @@ +Example User Federation Provider +=================================================== + +This is an example of user federation backed by a simple properties file. This properties file only contains username/password +key pairs. To deploy, build this directory then take the jar and copy it to standalone/configuration/providers. Alternatively you can deploy as a module by running: + + KEYCLOAK_HOME/bin/jboss-cli.sh --command="module add --name=org.keycloak.examples.userprops --resources=target/federation-properties-example.jar --dependencies=org.keycloak.keycloak-core,org.keycloak.keycloak-model-api" + +Then registering the provider by editing keycloak-server.json and adding the module to the providers field: + + "providers": [ + .... + "module:org.keycloak.examples.userprops" + ], + + +You will then have to restart the authentication server. + +The ClasspathPropertiesFederationProvider is an example of a readonly provider. If you go to the Users/Federation + page of the admin console you will see this provider listed under "classpath-properties. To configure this provider you +specify a classpath to a properties file in the "path" field of the admin page for this plugin. This example includes +a "test-users.properties" within the JAR that you can use as the variable. + +The FilePropertiesFederationProvider is an example of a writable provider. It synchronizes changes made to +username and password with the properties file. If you go to the Users/Federation page of the admin console you will +see this provider listed under "file-properties". To configure this provider you specify a fully qualified file path to +a properties file in the "path" field of the admin page for this plugin. diff --git a/examples/providers/authenticator/pom.xml b/examples/providers/authenticator/pom.xml new file mode 100755 index 0000000000..1e177f2f6c --- /dev/null +++ b/examples/providers/authenticator/pom.xml @@ -0,0 +1,47 @@ + + + keycloak-examples-providers-parent + org.keycloak + 1.5.0.Final-SNAPSHOT + + + Authenticator Example + + 4.0.0 + + authenticator-example + jar + + + + org.keycloak + keycloak-core + provided + + + org.keycloak + keycloak-model-api + provided + + + org.jboss.logging + jboss-logging + provided + + + org.keycloak + keycloak-services + provided + + + org.keycloak + keycloak-login-api + provided + + + + + federation-properties-example + + diff --git a/examples/providers/authenticator/src/main/java/org/keycloak/examples/authenticator/SecretQuestionAuthenticator.java b/examples/providers/authenticator/src/main/java/org/keycloak/examples/authenticator/SecretQuestionAuthenticator.java new file mode 100755 index 0000000000..8e60d9b99c --- /dev/null +++ b/examples/providers/authenticator/src/main/java/org/keycloak/examples/authenticator/SecretQuestionAuthenticator.java @@ -0,0 +1,80 @@ +package org.keycloak.examples.authenticator; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.authentication.AbstractFormAuthenticator; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserCredentialValueModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.CredentialValidation; + +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class SecretQuestionAuthenticator extends AbstractFormAuthenticator { + + public static final String CREDENTIAL_TYPE = "secret_question"; + + @Override + public void authenticate(AuthenticationFlowContext context) { + Response challenge = loginForm(context).createForm("secret_question.ftl"); + context.challenge(challenge); + } + + @Override + public void action(AuthenticationFlowContext context) { + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + String secret = formData.getFirst("secret"); + if (secret == null || secret.trim().equals("")) { + badSecret(context); + return; + } + + UserCredentialValueModel cred = null; + for (UserCredentialValueModel model : context.getUser().getCredentialsDirectly()) { + if (model.getType().equals(CREDENTIAL_TYPE)) { + cred = model; + break; + } + } + if (cred == null) { + badSecret(context); + return; + } + + boolean validated = CredentialValidation.validateHashedCredential(context.getRealm(), context.getUser(), secret, cred); + if (!validated) { + badSecret(context); + return; + } + + context.success(); + } + + private void badSecret(AuthenticationFlowContext context) { + Response challenge = loginForm(context) + .setError("badSecret") + .createForm("secret_question.ftl"); + context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, challenge); + } + + @Override + public boolean requiresUser() { + return true; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return session.users().configuredForCredentialType(CREDENTIAL_TYPE, realm, user); + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + user.addRequiredAction(CREDENTIAL_TYPE + "_CONFIG"); + } +} diff --git a/examples/providers/authenticator/src/main/java/org/keycloak/examples/authenticator/SecretQuestionAuthenticatorFactory.java b/examples/providers/authenticator/src/main/java/org/keycloak/examples/authenticator/SecretQuestionAuthenticatorFactory.java new file mode 100755 index 0000000000..09a83da758 --- /dev/null +++ b/examples/providers/authenticator/src/main/java/org/keycloak/examples/authenticator/SecretQuestionAuthenticatorFactory.java @@ -0,0 +1,105 @@ +package org.keycloak.examples.authenticator; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.authentication.ConfigurableAuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class SecretQuestionAuthenticatorFactory implements AuthenticatorFactory, ConfigurableAuthenticatorFactory { + + public static final String PROVIDER_ID = "secret-question-authenticator"; + private static final SecretQuestionAuthenticator SINGLETON = new SecretQuestionAuthenticator(); + + @Override + public Authenticator create() { + return SINGLETON; + } + + @Override + public Authenticator create(KeycloakSession session) { + return SINGLETON; + } + + @Override + public String getDisplayType() { + return "Secret Question"; + } + + @Override + public String getReferenceCategory() { + return "Secret Question"; + } + + @Override + public boolean isConfigurable() { + return true; + } + + private static AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.REQUIRED, + AuthenticationExecutionModel.Requirement.DISABLED + }; + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return true; + } + + @Override + public String getHelpText() { + return "A secret question that a user has to answer. i.e. What is your mother's maiden name."; + } + + private static final List configProperties = new ArrayList(); + + static { + ProviderConfigProperty property; + property = new ProviderConfigProperty(); + property.setName("remember_machine"); + property.setLabel("Remember machine"); + property.setType(ProviderConfigProperty.BOOLEAN_TYPE); + property.setHelpText("If set to true, a checkbox will appear when entering in secret on whether the user wants keycloak to remember the machine. If the user wants to remember, then a persistent cookie is set, and the user will not have to enter in their secret again."); + configProperties.add(property); + } + + + @Override + public List getConfigProperties() { + return configProperties; + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} diff --git a/examples/providers/authenticator/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/examples/providers/authenticator/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory new file mode 100755 index 0000000000..a62922e858 --- /dev/null +++ b/examples/providers/authenticator/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -0,0 +1 @@ +org.keycloak.examples.authenticator.SecretQuestionAuthenticatorFactory \ No newline at end of file diff --git a/examples/providers/pom.xml b/examples/providers/pom.xml index fc13539129..731151492b 100755 --- a/examples/providers/pom.xml +++ b/examples/providers/pom.xml @@ -17,5 +17,6 @@ event-listener-sysout event-store-mem federation-provider + authenticator diff --git a/forms/common-themes/src/main/resources/theme/base/login/terms.ftl b/forms/common-themes/src/main/resources/theme/base/login/terms.ftl index 991e5ed0d5..58f4445851 100755 --- a/forms/common-themes/src/main/resources/theme/base/login/terms.ftl +++ b/forms/common-themes/src/main/resources/theme/base/login/terms.ftl @@ -8,9 +8,9 @@
${msg("termsText")}
-
- - + + +
\ No newline at end of file diff --git a/forms/login-api/src/main/java/org/keycloak/login/LoginFormsProvider.java b/forms/login-api/src/main/java/org/keycloak/login/LoginFormsProvider.java index cad0c1ae8b..745e395da5 100755 --- a/forms/login-api/src/main/java/org/keycloak/login/LoginFormsProvider.java +++ b/forms/login-api/src/main/java/org/keycloak/login/LoginFormsProvider.java @@ -25,7 +25,7 @@ public interface LoginFormsProvider extends Provider { public Response createResponse(UserModel.RequiredAction action); - Response createForm(String form, Map attributes); + Response createForm(String form); public Response createLogin(); diff --git a/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/FreeMarkerLoginFormsProvider.java b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/FreeMarkerLoginFormsProvider.java index 245e625bfd..12c27d43a0 100755 --- a/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/FreeMarkerLoginFormsProvider.java +++ b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/FreeMarkerLoginFormsProvider.java @@ -278,7 +278,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { } @Override - public Response createForm(String form, Map extraAttributes) { + public Response createForm(String form) { RealmModel realm = session.getContext().getRealm(); ClientModel client = session.getContext().getClient(); diff --git a/model/api/src/main/java/org/keycloak/models/utils/CredentialValidation.java b/model/api/src/main/java/org/keycloak/models/utils/CredentialValidation.java index d1eef51dbc..3411f59c89 100755 --- a/model/api/src/main/java/org/keycloak/models/utils/CredentialValidation.java +++ b/model/api/src/main/java/org/keycloak/models/utils/CredentialValidation.java @@ -38,29 +38,34 @@ public class CredentialValidation { * @return */ public static boolean validPassword(RealmModel realm, UserModel user, String password) { - boolean validated = false; UserCredentialValueModel passwordCred = null; for (UserCredentialValueModel cred : user.getCredentialsDirectly()) { if (cred.getType().equals(UserCredentialModel.PASSWORD)) { - validated = new Pbkdf2PasswordEncoder(cred.getSalt()).verify(password, cred.getValue(), cred.getHashIterations()); passwordCred = cred; } } + if (passwordCred == null) return false; + + return validateHashedCredential(realm, user, password, passwordCred); + + } + + public static boolean validateHashedCredential(RealmModel realm, UserModel user, String unhashedCredValue, UserCredentialValueModel credential) { + boolean validated = new Pbkdf2PasswordEncoder(credential.getSalt()).verify(unhashedCredValue, credential.getValue(), credential.getHashIterations()); if (validated) { int iterations = hashIterations(realm); - if (iterations > -1 && iterations != passwordCred.getHashIterations()) { + if (iterations > -1 && iterations != credential.getHashIterations()) { UserCredentialValueModel newCred = new UserCredentialValueModel(); - newCred.setType(passwordCred.getType()); - newCred.setDevice(passwordCred.getDevice()); - newCred.setSalt(passwordCred.getSalt()); + newCred.setType(credential.getType()); + newCred.setDevice(credential.getDevice()); + newCred.setSalt(credential.getSalt()); newCred.setHashIterations(iterations); - newCred.setValue(new Pbkdf2PasswordEncoder(newCred.getSalt()).encode(password, iterations)); + newCred.setValue(new Pbkdf2PasswordEncoder(newCred.getSalt()).encode(unhashedCredValue, iterations)); user.updateCredentialDirectly(newCred); } } return validated; - } public static boolean validPasswordToken(RealmModel realm, UserModel user, String encodedPasswordToken) { diff --git a/services/src/main/java/org/keycloak/authentication/AbstractFormAuthenticator.java b/services/src/main/java/org/keycloak/authentication/AbstractFormAuthenticator.java new file mode 100755 index 0000000000..a3180295fe --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/AbstractFormAuthenticator.java @@ -0,0 +1,67 @@ +package org.keycloak.authentication; + +import org.keycloak.OAuth2Constants; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.Authenticator; +import org.keycloak.login.LoginFormsProvider; +import org.keycloak.services.resources.LoginActionsService; + +import java.net.URI; + +/** + * Abstract helper class that Authenticator implementations can leverage + * + * @author Bill Burke + * @version $Revision: 1 $ + */ +public abstract class AbstractFormAuthenticator implements Authenticator { + public static final String EXECUTION = "execution"; + + @Override + public void close() { + + } + + /** + * Create a form builder that presets the user, action URI, and a generated access code + * + * @param context + * @return + */ + protected LoginFormsProvider loginForm(AuthenticationFlowContext context) { + String accessCode = context.generateAccessCode(); + URI action = getActionUrl(context, accessCode); + LoginFormsProvider provider = context.getSession().getProvider(LoginFormsProvider.class) + .setUser(context.getUser()) + .setActionUri(action) + .setClientSessionCode(accessCode); + if (context.getForwardedErrorMessage() != null) { + provider.setError(context.getForwardedErrorMessage()); + } + return provider; + } + + /** + * Get the action URL for the required action. + * + * @param context + * @param code client sessino access code + * @return + */ + public URI getActionUrl(AuthenticationFlowContext context, String code) { + return LoginActionsService.authenticationFormProcessor(context.getUriInfo()) + .queryParam(OAuth2Constants.CODE, code) + .queryParam(EXECUTION, context.getExecution().getId()) + .build(context.getRealm().getName()); + } + + /** + * Get the action URL for the required action. This auto-generates the access code. + * + * @param context + * @return + */ + public URI getActionUrl(AuthenticationFlowContext context) { + return getActionUrl(context, context.generateAccessCode()); + } +} diff --git a/services/src/main/java/org/keycloak/authentication/AbstractFormRequiredAction.java b/services/src/main/java/org/keycloak/authentication/AbstractFormRequiredAction.java new file mode 100755 index 0000000000..ea1443d948 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/AbstractFormRequiredAction.java @@ -0,0 +1,67 @@ +package org.keycloak.authentication; + +import org.keycloak.OAuth2Constants; +import org.keycloak.authentication.RequiredActionContext; +import org.keycloak.authentication.RequiredActionProvider; +import org.keycloak.login.LoginFormsProvider; +import org.keycloak.services.resources.LoginActionsService; + +import java.net.URI; + +/** + * Abstract helper class that Authenticator implementations can leverage + * + * @author Bill Burke + * @version $Revision: 1 $ + */ +public abstract class AbstractFormRequiredAction implements RequiredActionProvider { + + /** + * Get the action URL for the required action. + * + * @param context + * @param code client sessino access code + * @return + */ + public URI getActionUrl(RequiredActionContext context, String code) { + return LoginActionsService.requiredActionProcessor(context.getUriInfo()) + .queryParam(OAuth2Constants.CODE, code) + .queryParam("action", getProviderId()) + .build(context.getRealm().getName()); + } + + /** + * Get the action URL for the required action. This auto-generates the access code. + * + * @param context + * @return + */ + public URI getActionUrl(RequiredActionContext context) { + String accessCode = context.generateAccessCode(getProviderId()); + return getActionUrl(context, accessCode); + + } + + /** + * Create a form builder that presets the user, action URI, and a generated access code + * + * @param context + * @return + */ + public LoginFormsProvider form(RequiredActionContext context) { + String accessCode = context.generateAccessCode(getProviderId()); + URI action = getActionUrl(context, accessCode); + LoginFormsProvider provider = context.getSession().getProvider(LoginFormsProvider.class) + .setUser(context.getUser()) + .setActionUri(action) + .setClientSessionCode(accessCode); + return provider; + } + + @Override + public void close() { + + } + + +} diff --git a/services/src/main/java/org/keycloak/authentication/AuthenticationFlowContext.java b/services/src/main/java/org/keycloak/authentication/AuthenticationFlowContext.java index 6a683f3b71..de694934ac 100755 --- a/services/src/main/java/org/keycloak/authentication/AuthenticationFlowContext.java +++ b/services/src/main/java/org/keycloak/authentication/AuthenticationFlowContext.java @@ -16,6 +16,10 @@ import javax.ws.rs.core.Response; import javax.ws.rs.core.UriInfo; /** + * This interface encapsulates information about an execution in an AuthenticationFlow. It is also used to set + * the status of the execution being performed. + * + * * @author Bill Burke * @version $Revision: 1 $ */ @@ -121,6 +125,7 @@ public interface AuthenticationFlowContext { AuthenticationExecutionModel.Requirement getCategoryRequirementFromCurrentFlow(String authenticatorCategory); + /** * Mark the current execution as successful. The flow will then continue * @@ -174,4 +179,18 @@ public interface AuthenticationFlowContext { * */ void attempted(); + + /** + * Get the current status of the current execution. + * + * @return may return null if not set yet. + */ + FlowStatus getStatus(); + + /** + * Get the error condition of a failed execution. + * + * @return may return null if there was no error + */ + AuthenticationFlowError getError(); } diff --git a/services/src/main/java/org/keycloak/authentication/AuthenticationProcessor.java b/services/src/main/java/org/keycloak/authentication/AuthenticationProcessor.java index 3c16552bbd..552eec8697 100755 --- a/services/src/main/java/org/keycloak/authentication/AuthenticationProcessor.java +++ b/services/src/main/java/org/keycloak/authentication/AuthenticationProcessor.java @@ -3,7 +3,7 @@ package org.keycloak.authentication; import org.jboss.logging.Logger; import org.jboss.resteasy.spi.HttpRequest; import org.keycloak.ClientConnection; -import org.keycloak.authentication.authenticators.browser.AbstractFormAuthenticator; +import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; @@ -53,16 +53,6 @@ public class AuthenticationProcessor { protected boolean userSessionCreated; - public static enum Status { - SUCCESS, - CHALLENGE, - FORCE_CHALLENGE, - FAILURE_CHALLENGE, - FAILED, - ATTEMPTED - - } - public RealmModel getRealm() { return realm; } @@ -173,7 +163,7 @@ public class AuthenticationProcessor { AuthenticatorConfigModel authenticatorConfig; AuthenticationExecutionModel execution; Authenticator authenticator; - Status status; + FlowStatus status; Response challenge; AuthenticationFlowError error; List currentExecutions; @@ -219,32 +209,33 @@ public class AuthenticationProcessor { return authenticator; } - public Status getStatus() { + @Override + public FlowStatus getStatus() { return status; } @Override public void success() { - this.status = Status.SUCCESS; + this.status = FlowStatus.SUCCESS; } @Override public void failure(AuthenticationFlowError error) { - status = Status.FAILED; + status = FlowStatus.FAILED; this.error = error; } @Override public void challenge(Response challenge) { - this.status = Status.CHALLENGE; + this.status = FlowStatus.CHALLENGE; this.challenge = challenge; } @Override public void forceChallenge(Response challenge) { - this.status = Status.FORCE_CHALLENGE; + this.status = FlowStatus.FORCE_CHALLENGE; this.challenge = challenge; } @@ -252,7 +243,7 @@ public class AuthenticationProcessor { @Override public void failureChallenge(AuthenticationFlowError error, Response challenge) { this.error = error; - this.status = Status.FAILURE_CHALLENGE; + this.status = FlowStatus.FAILURE_CHALLENGE; this.challenge = challenge; } @@ -260,14 +251,14 @@ public class AuthenticationProcessor { @Override public void failure(AuthenticationFlowError error, Response challenge) { this.error = error; - this.status = Status.FAILED; + this.status = FlowStatus.FAILED; this.challenge = challenge; } @Override public void attempted() { - this.status = Status.ATTEMPTED; + this.status = FlowStatus.ATTEMPTED; } @@ -341,6 +332,7 @@ public class AuthenticationProcessor { return challenge; } + @Override public AuthenticationFlowError getError() { return error; } @@ -348,7 +340,7 @@ public class AuthenticationProcessor { public void logFailure() { if (realm.isBruteForceProtected()) { - String username = clientSession.getNote(AbstractFormAuthenticator.ATTEMPTED_USERNAME); + String username = clientSession.getNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME); // todo need to handle non form failures if (username == null) { @@ -513,7 +505,7 @@ public class AuthenticationProcessor { public void attachSession() { String username = clientSession.getAuthenticatedUser().getUsername(); - String attemptedUsername = clientSession.getNote(AbstractFormAuthenticator.ATTEMPTED_USERNAME); + String attemptedUsername = clientSession.getNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME); if (attemptedUsername != null) username = attemptedUsername; if (userSession == null) { // if no authenticator attached a usersession boolean remember = "true".equals(clientSession.getNote(Details.REMEMBER_ME)); diff --git a/services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java b/services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java index f03f7080df..1df2545f38 100755 --- a/services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java +++ b/services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java @@ -149,13 +149,13 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow { public Response processResult(AuthenticationProcessor.Result result) { AuthenticationExecutionModel execution = result.getExecution(); - AuthenticationProcessor.Status status = result.getStatus(); - if (status == AuthenticationProcessor.Status.SUCCESS) { + FlowStatus status = result.getStatus(); + if (status == FlowStatus.SUCCESS) { AuthenticationProcessor.logger.debugv("authenticator SUCCESS: {0}", execution.getAuthenticator()); processor.getClientSession().setExecutionStatus(execution.getId(), ClientSessionModel.ExecutionStatus.SUCCESS); if (execution.isAlternative()) alternativeSuccessful = true; return null; - } else if (status == AuthenticationProcessor.Status.FAILED) { + } else if (status == FlowStatus.FAILED) { AuthenticationProcessor.logger.debugv("authenticator FAILED: {0}", execution.getAuthenticator()); processor.logFailure(); processor.getClientSession().setExecutionStatus(execution.getId(), ClientSessionModel.ExecutionStatus.FAILED); @@ -163,10 +163,10 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow { return sendChallenge(result, execution); } throw new AuthenticationFlowException(result.getError()); - } else if (status == AuthenticationProcessor.Status.FORCE_CHALLENGE) { + } else if (status == FlowStatus.FORCE_CHALLENGE) { processor.getClientSession().setExecutionStatus(execution.getId(), ClientSessionModel.ExecutionStatus.CHALLENGED); return sendChallenge(result, execution); - } else if (status == AuthenticationProcessor.Status.CHALLENGE) { + } else if (status == FlowStatus.CHALLENGE) { AuthenticationProcessor.logger.debugv("authenticator CHALLENGE: {0}", execution.getAuthenticator()); if (execution.isRequired()) { processor.getClientSession().setExecutionStatus(execution.getId(), ClientSessionModel.ExecutionStatus.CHALLENGED); @@ -184,12 +184,12 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow { processor.getClientSession().setExecutionStatus(execution.getId(), ClientSessionModel.ExecutionStatus.SKIPPED); } return null; - } else if (status == AuthenticationProcessor.Status.FAILURE_CHALLENGE) { + } else if (status == FlowStatus.FAILURE_CHALLENGE) { AuthenticationProcessor.logger.debugv("authenticator FAILURE_CHALLENGE: {0}", execution.getAuthenticator()); processor.logFailure(); processor.getClientSession().setExecutionStatus(execution.getId(), ClientSessionModel.ExecutionStatus.CHALLENGED); return sendChallenge(result, execution); - } else if (status == AuthenticationProcessor.Status.ATTEMPTED) { + } else if (status == FlowStatus.ATTEMPTED) { AuthenticationProcessor.logger.debugv("authenticator ATTEMPTED: {0}", execution.getAuthenticator()); if (execution.getRequirement() == AuthenticationExecutionModel.Requirement.REQUIRED) { throw new AuthenticationFlowException(AuthenticationFlowError.INVALID_CREDENTIALS); diff --git a/services/src/main/java/org/keycloak/authentication/FlowStatus.java b/services/src/main/java/org/keycloak/authentication/FlowStatus.java new file mode 100755 index 0000000000..0acd8755a2 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/FlowStatus.java @@ -0,0 +1,47 @@ +package org.keycloak.authentication; + +/** + * Status of an execution/authenticator in a Authentication Flow + * + * @author Bill Burke + * @version $Revision: 1 $ + */ +public enum FlowStatus { + /** + * Successful execution + */ + SUCCESS, + + /** + * Execution offered a challenge. Optional executions will ignore this challenge. Alternative executions may + * ignore the challenge depending on the status of other executions in the flow. + * + */ + CHALLENGE, + + /** + * Irregardless of the execution's requirement, this challenge will be sent to the user. + * + */ + FORCE_CHALLENGE, + + /** + * Flow will be aborted and a Response provided by the execution will be sent. + * + */ + FAILURE_CHALLENGE, + + /** + * Flow will be aborted. + * + */ + FAILED, + + /** + * This is not an error condition. Execution was attempted, but the authenticator is unable to process the request. An example of this is if + * a Kerberos authenticator did not see a negotiate header. There was no error, but the execution was attempted. + * + */ + ATTEMPTED + +} diff --git a/services/src/main/java/org/keycloak/authentication/RequiredActionContext.java b/services/src/main/java/org/keycloak/authentication/RequiredActionContext.java index 67404e5b29..9735ed1c42 100755 --- a/services/src/main/java/org/keycloak/authentication/RequiredActionContext.java +++ b/services/src/main/java/org/keycloak/authentication/RequiredActionContext.java @@ -9,6 +9,7 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; +import javax.ws.rs.core.Response; import javax.ws.rs.core.UriInfo; /** @@ -18,6 +19,15 @@ import javax.ws.rs.core.UriInfo; * @version $Revision: 1 $ */ public interface RequiredActionContext { + void ignore(); + + public static enum Status { + CHALLENGE, + SUCCESS, + IGNORE, + FAILURE + } + /** * Current event builder being used * @@ -46,4 +56,10 @@ public interface RequiredActionContext { * @return */ String generateAccessCode(String action); + + Status getStatus(); + + void challenge(Response response); + void failure(); + void success(); } diff --git a/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java b/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java new file mode 100755 index 0000000000..10a8a0d92a --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java @@ -0,0 +1,127 @@ +package org.keycloak.authentication; + +import org.jboss.resteasy.spi.HttpRequest; +import org.keycloak.ClientConnection; +import org.keycloak.events.EventBuilder; +import org.keycloak.models.ClientSessionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.services.managers.ClientSessionCode; + +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class RequiredActionContextResult implements RequiredActionContext { + protected UserSessionModel userSession; + protected ClientSessionModel clientSession; + protected RealmModel realm; + protected EventBuilder eventBuilder; + protected KeycloakSession session; + protected Status status; + protected Response challenge; + protected HttpRequest httpRequest; + protected UserModel user; + + public RequiredActionContextResult(UserSessionModel userSession, ClientSessionModel clientSession, + RealmModel realm, EventBuilder eventBuilder, KeycloakSession session, + HttpRequest httpRequest, + UserModel user) { + this.userSession = userSession; + this.clientSession = clientSession; + this.realm = realm; + this.eventBuilder = eventBuilder; + this.session = session; + this.httpRequest = httpRequest; + this.user = user; + } + + @Override + public EventBuilder getEvent() { + return eventBuilder; + } + + @Override + public UserModel getUser() { + return user; + } + + @Override + public RealmModel getRealm() { + return realm; + } + + @Override + public ClientSessionModel getClientSession() { + return clientSession; + } + + @Override + public UserSessionModel getUserSession() { + return userSession; + } + + @Override + public ClientConnection getConnection() { + return session.getContext().getConnection(); + } + + @Override + public UriInfo getUriInfo() { + return session.getContext().getUri(); + } + + @Override + public KeycloakSession getSession() { + return session; + } + + @Override + public HttpRequest getHttpRequest() { + return httpRequest; + } + + @Override + public String generateAccessCode(String action) { + ClientSessionCode code = new ClientSessionCode(getRealm(), getClientSession()); + code.setAction(action); + return code.getCode(); + } + + @Override + public Status getStatus() { + return status; + } + + @Override + public void challenge(Response response) { + status = Status.CHALLENGE; + challenge = response; + + } + + @Override + public void failure() { + status = Status.FAILURE; + } + + @Override + public void success() { + status = Status.SUCCESS; + + } + + @Override + public void ignore() { + status = Status.IGNORE; + } + + public Response getChallenge() { + return challenge; + } +} diff --git a/services/src/main/java/org/keycloak/authentication/RequiredActionProvider.java b/services/src/main/java/org/keycloak/authentication/RequiredActionProvider.java index 4d441cdcbb..f6b1e62c2f 100755 --- a/services/src/main/java/org/keycloak/authentication/RequiredActionProvider.java +++ b/services/src/main/java/org/keycloak/authentication/RequiredActionProvider.java @@ -28,18 +28,14 @@ public interface RequiredActionProvider extends Provider { * @param context * @return */ - Response requiredActionChallenge(RequiredActionContext context); + void requiredActionChallenge(RequiredActionContext context); /** - * This is an optional method. If the required action has a more complex interaction, you can encapsulate it within - * a REST service. This method returns a JAX-RS sub locator object that can be referenced at: - * - * /realms/{realm}/login-actions/required-actions/{provider-id} + * Called when a required action has form input you want to process. * * @param context - * @return */ - Object jaxrsService(RequiredActionContext context); + void processAction(RequiredActionContext context); /** * Provider id of this required action. Must match ProviderFactory.getId(). diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractFormAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java similarity index 82% rename from services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractFormAuthenticator.java rename to services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java index 46e71d4f10..5a96553e7b 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractFormAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java @@ -1,13 +1,11 @@ package org.keycloak.authentication.authenticators.browser; import org.jboss.logging.Logger; -import org.keycloak.OAuth2Constants; +import org.keycloak.authentication.AbstractFormAuthenticator; import org.keycloak.authentication.AuthenticationFlowError; import org.keycloak.authentication.AuthenticationFlowContext; -import org.keycloak.authentication.Authenticator; import org.keycloak.events.Details; import org.keycloak.events.Errors; -import org.keycloak.login.LoginFormsProvider; import org.keycloak.models.ModelDuplicateException; import org.keycloak.models.UserCredentialModel; import org.keycloak.models.UserModel; @@ -15,11 +13,9 @@ import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.messages.Messages; -import org.keycloak.services.resources.LoginActionsService; import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; -import java.net.URI; import java.util.LinkedList; import java.util.List; @@ -27,12 +23,11 @@ import java.util.List; * @author Bill Burke * @version $Revision: 1 $ */ -public abstract class AbstractFormAuthenticator implements Authenticator { +public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuthenticator { - private static final Logger logger = Logger.getLogger(AbstractFormAuthenticator.class); + private static final Logger logger = Logger.getLogger(AbstractUsernameFormAuthenticator.class); public static final String REGISTRATION_FORM_ACTION = "registration_form"; - public static final String EXECUTION = "execution"; public static final String ATTEMPTED_USERNAME = "ATTEMPTED_USERNAME"; @Override @@ -40,31 +35,6 @@ public abstract class AbstractFormAuthenticator implements Authenticator { } - @Override - public void close() { - - } - - protected LoginFormsProvider loginForm(AuthenticationFlowContext context) { - String accessCode = context.generateAccessCode(); - URI action = getActionUrl(context, accessCode); - LoginFormsProvider provider = context.getSession().getProvider(LoginFormsProvider.class) - .setUser(context.getUser()) - .setActionUri(action) - .setClientSessionCode(accessCode); - if (context.getForwardedErrorMessage() != null) { - provider.setError(context.getForwardedErrorMessage()); - } - return provider; - } - - public URI getActionUrl(AuthenticationFlowContext context, String code) { - return LoginActionsService.authenticationFormProcessor(context.getUriInfo()) - .queryParam(OAuth2Constants.CODE, code) - .queryParam(EXECUTION, context.getExecution().getId()) - .build(context.getRealm().getName()); - } - protected Response invalidUser(AuthenticationFlowContext context) { return loginForm(context) .setError(Messages.INVALID_USER) @@ -129,7 +99,7 @@ public abstract class AbstractFormAuthenticator implements Authenticator { return false; } context.getEvent().detail(Details.USERNAME, username); - context.getClientSession().setNote(AbstractFormAuthenticator.ATTEMPTED_USERNAME, username); + context.getClientSession().setNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, username); UserModel user = null; try { diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/OTPFormAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/OTPFormAuthenticator.java index 8a61476a01..6f910e98ba 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/OTPFormAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/OTPFormAuthenticator.java @@ -22,7 +22,7 @@ import java.util.List; * @author Bill Burke * @version $Revision: 1 $ */ -public class OTPFormAuthenticator extends AbstractFormAuthenticator implements Authenticator { +public class OTPFormAuthenticator extends AbstractUsernameFormAuthenticator implements Authenticator { public static final String TOTP_FORM_ACTION = "totp"; @Override diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/SpnegoAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/SpnegoAuthenticator.java index 3e341c937e..bad2fa2ee9 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/SpnegoAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/SpnegoAuthenticator.java @@ -25,7 +25,7 @@ import java.util.Map; * @author Bill Burke * @version $Revision: 1 $ */ -public class SpnegoAuthenticator extends AbstractFormAuthenticator implements Authenticator{ +public class SpnegoAuthenticator extends AbstractUsernameFormAuthenticator implements Authenticator{ public static final String KERBEROS_DISABLED = "kerberos_disabled"; protected static Logger logger = Logger.getLogger(SpnegoAuthenticator.class); diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernamePasswordForm.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernamePasswordForm.java index d1922d38a8..47197804d4 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernamePasswordForm.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernamePasswordForm.java @@ -20,7 +20,7 @@ import javax.ws.rs.core.Response; * @author Bill Burke * @version $Revision: 1 $ */ -public class UsernamePasswordForm extends AbstractFormAuthenticator implements Authenticator { +public class UsernamePasswordForm extends AbstractUsernameFormAuthenticator implements Authenticator { @Override public void action(AuthenticationFlowContext context) { diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/ValidateUsername.java b/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/ValidateUsername.java index 1c39fff154..d7fe1fec19 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/ValidateUsername.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/ValidateUsername.java @@ -3,7 +3,7 @@ package org.keycloak.authentication.authenticators.directgrant; import org.jboss.logging.Logger; import org.keycloak.authentication.AuthenticationFlowError; import org.keycloak.authentication.AuthenticationFlowContext; -import org.keycloak.authentication.authenticators.browser.AbstractFormAuthenticator; +import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.models.AuthenticationExecutionModel; @@ -40,7 +40,7 @@ public class ValidateUsername extends AbstractDirectGrantAuthenticator { return; } context.getEvent().detail(Details.USERNAME, username); - context.getClientSession().setNote(AbstractFormAuthenticator.ATTEMPTED_USERNAME, username); + context.getClientSession().setNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, username); UserModel user = null; try { diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/TermsAndConditions.java b/services/src/main/java/org/keycloak/authentication/requiredactions/TermsAndConditions.java index 6f094f7152..627c80f0ed 100755 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/TermsAndConditions.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/TermsAndConditions.java @@ -4,56 +4,20 @@ import org.keycloak.Config; import org.keycloak.authentication.RequiredActionContext; import org.keycloak.authentication.RequiredActionFactory; import org.keycloak.authentication.RequiredActionProvider; -import org.keycloak.events.Errors; -import org.keycloak.freemarker.FreeMarkerException; -import org.keycloak.login.LoginFormsProvider; +import org.keycloak.authentication.AbstractFormRequiredAction; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; -import org.keycloak.protocol.LoginProtocol; -import org.keycloak.services.managers.AuthenticationManager; -import javax.ws.rs.Consumes; -import javax.ws.rs.POST; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; -import java.io.IOException; -import java.net.URISyntaxException; -import java.util.HashMap; /** * @author Bill Burke * @version $Revision: 1 $ */ -public class TermsAndConditions implements RequiredActionProvider, RequiredActionFactory { +public class TermsAndConditions extends AbstractFormRequiredAction implements RequiredActionFactory { public static final String PROVIDER_ID = "terms_and_conditions"; - public static class Resource { - - public Resource(RequiredActionContext context) { - this.context = context; - } - - protected RequiredActionContext context; - - @POST - @Consumes(MediaType.APPLICATION_FORM_URLENCODED) - public Response agree(final MultivaluedMap formData) throws URISyntaxException, IOException, FreeMarkerException { - if (formData.containsKey("cancel")) { - LoginProtocol protocol = context.getSession().getProvider(LoginProtocol.class, context.getClientSession().getAuthMethod()); - protocol.setRealm(context.getRealm()) - .setHttpHeaders(context.getHttpRequest().getHttpHeaders()) - .setUriInfo(context.getUriInfo()); - context.getEvent().error(Errors.REJECTED_BY_USER); - return protocol.consentDenied(context.getClientSession()); - } - context.getUser().removeRequiredAction(PROVIDER_ID); - return AuthenticationManager.nextActionAfterAuthentication(context.getSession(), context.getUserSession(), context.getClientSession(), context.getConnection(), context.getHttpRequest(), context.getUriInfo(), context.getEvent()); - } - - } - @Override public RequiredActionProvider create(KeycloakSession session) { return this; @@ -87,17 +51,21 @@ public class TermsAndConditions implements RequiredActionProvider, RequiredActio } + @Override - public Response requiredActionChallenge(RequiredActionContext context) { - return context.getSession().getProvider(LoginFormsProvider.class) - .setClientSessionCode(context.generateAccessCode(getProviderId())) - .setUser(context.getUser()) - .createForm("terms.ftl", new HashMap()); + public void requiredActionChallenge(RequiredActionContext context) { + Response challenge = form(context).createForm("terms.ftl"); + context.challenge(challenge); } @Override - public Object jaxrsService(RequiredActionContext context) { - return new Resource(context); + public void processAction(RequiredActionContext context) { + if (context.getHttpRequest().getDecodedFormParameters().containsKey("cancel")) { + context.failure(); + return; + } + context.success(); + } @Override @@ -105,8 +73,4 @@ public class TermsAndConditions implements RequiredActionProvider, RequiredActio return "Terms and Conditions"; } - @Override - public void close() { - - } } diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java index b00510cb5e..4ef413d895 100755 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java @@ -48,21 +48,20 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac } @Override - public Response requiredActionChallenge(RequiredActionContext context) { + public void requiredActionChallenge(RequiredActionContext context) { LoginFormsProvider loginFormsProvider = context.getSession() .getProvider(LoginFormsProvider.class) .setClientSessionCode(context.generateAccessCode(getProviderId())) .setUser(context.getUser()); - return loginFormsProvider.createResponse(UserModel.RequiredAction.UPDATE_PASSWORD); + Response challenge = loginFormsProvider.createResponse(UserModel.RequiredAction.UPDATE_PASSWORD); + context.challenge(challenge); } @Override - public Object jaxrsService(RequiredActionContext context) { - // this is handled by LoginActionsService at the moment - return null; + public void processAction(RequiredActionContext context) { + context.failure(); } - @Override public void close() { diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java index 5860928c50..4cb84932fa 100755 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java @@ -23,18 +23,17 @@ public class UpdateProfile implements RequiredActionProvider, RequiredActionFact } @Override - public Response requiredActionChallenge(RequiredActionContext context) { + public void requiredActionChallenge(RequiredActionContext context) { LoginFormsProvider loginFormsProvider = context.getSession().getProvider(LoginFormsProvider.class) .setClientSessionCode(context.generateAccessCode(getProviderId())) .setUser(context.getUser()); - return loginFormsProvider.createResponse(UserModel.RequiredAction.UPDATE_PROFILE); + Response challenge = loginFormsProvider.createResponse(UserModel.RequiredAction.UPDATE_PROFILE); + context.challenge(challenge); } @Override - public Object jaxrsService(RequiredActionContext context) { - // this is handled by LoginActionsService at the moment - // todo should be refactored to contain it here - return null; + public void processAction(RequiredActionContext context) { + context.failure(); } diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java index ac4e187992..c958357236 100755 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java @@ -25,18 +25,17 @@ public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory } @Override - public Response requiredActionChallenge(RequiredActionContext context) { + public void requiredActionChallenge(RequiredActionContext context) { LoginFormsProvider loginFormsProvider = context.getSession().getProvider(LoginFormsProvider.class) .setClientSessionCode(context.generateAccessCode(getProviderId())) .setUser(context.getUser()); - return loginFormsProvider.createResponse(UserModel.RequiredAction.CONFIGURE_TOTP); + Response challenge = loginFormsProvider.createResponse(UserModel.RequiredAction.CONFIGURE_TOTP); + context.challenge(challenge); } @Override - public Object jaxrsService(RequiredActionContext context) { - // this is handled by LoginActionsService at the moment - // todo should be refactored to contain it here - return null; + public void processAction(RequiredActionContext context) { + context.failure(); } diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java b/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java index f8a6d964b2..ba0ee065ea 100755 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java @@ -34,9 +34,10 @@ public class VerifyEmail implements RequiredActionProvider, RequiredActionFactor } } @Override - public Response requiredActionChallenge(RequiredActionContext context) { + public void requiredActionChallenge(RequiredActionContext context) { if (Validation.isBlank(context.getUser().getEmail())) { - return null; + context.ignore(); + return; } context.getEvent().clone().event(EventType.SEND_VERIFY_EMAIL).detail(Details.EMAIL, context.getUser().getEmail()).success(); @@ -45,13 +46,13 @@ public class VerifyEmail implements RequiredActionProvider, RequiredActionFactor LoginFormsProvider loginFormsProvider = context.getSession().getProvider(LoginFormsProvider.class) .setClientSessionCode(context.generateAccessCode(getProviderId())) .setUser(context.getUser()); - return loginFormsProvider.createResponse(UserModel.RequiredAction.VERIFY_EMAIL); + Response challenge = loginFormsProvider.createResponse(UserModel.RequiredAction.VERIFY_EMAIL); + context.challenge(challenge); } @Override - public Object jaxrsService(RequiredActionContext context) { - // this is handled by LoginActionsService at the moment - return null; + public void processAction(RequiredActionContext context) { + context.failure(); } diff --git a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java index d3ab117670..9e5cd97fb4 100755 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java @@ -7,10 +7,13 @@ import org.keycloak.ClientConnection; import org.keycloak.RSATokenVerifier; import org.keycloak.VerificationException; import org.keycloak.authentication.RequiredActionContext; +import org.keycloak.authentication.RequiredActionContextResult; import org.keycloak.authentication.RequiredActionProvider; import org.keycloak.broker.provider.IdentityProvider; import org.keycloak.events.Details; +import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; import org.keycloak.jose.jws.JWSBuilder; import org.keycloak.login.LoginFormsProvider; import org.keycloak.models.ClientModel; @@ -418,8 +421,9 @@ public class AuthenticationManager { final UserModel user = userSession.getUser(); final ClientModel client = clientSession.getClient(); - RequiredActionContext context = evaluateRequiredActionTriggers(session, userSession, clientSession, clientConnection, request, uriInfo, event, realm, user); + evaluateRequiredActionTriggers(session, userSession, clientSession, clientConnection, request, uriInfo, event, realm, user); + RequiredActionContextResult context = new RequiredActionContextResult(userSession, clientSession, realm, event, session, request, user); logger.debugv("processAccessCode: go to oauth page?: {0}", client.isConsentRequired()); @@ -429,11 +433,23 @@ public class AuthenticationManager { for (String action : requiredActions) { RequiredActionProviderModel model = realm.getRequiredActionProviderByAlias(action); RequiredActionProvider actionProvider = session.getProvider(RequiredActionProvider.class, model.getProviderId()); - Response challenge = actionProvider.requiredActionChallenge(context); - if (challenge != null) { - return challenge; - } + actionProvider.requiredActionChallenge(context); + if (context.getStatus() == RequiredActionContext.Status.FAILURE) { + LoginProtocol protocol = context.getSession().getProvider(LoginProtocol.class, context.getClientSession().getAuthMethod()); + protocol.setRealm(context.getRealm()) + .setHttpHeaders(context.getHttpRequest().getHttpHeaders()) + .setUriInfo(context.getUriInfo()); + event.error(Errors.REJECTED_BY_USER); + return protocol.consentDenied(context.getClientSession()); + } + else if (context.getStatus() == RequiredActionContext.Status.CHALLENGE) { + return context.getChallenge(); + } + else if (context.getStatus() == RequiredActionContext.Status.SUCCESS) { + event.clone().event(EventType.CUSTOM_REQUIRED_ACTION).detail(Details.CUSTOM_REQUIRED_ACTION, actionProvider.getProviderId()).success(); + clientSession.getUserSession().getUser().removeRequiredAction(actionProvider.getProviderId()); + } } if (client.isConsentRequired()) { @@ -484,58 +500,26 @@ public class AuthenticationManager { } - public static RequiredActionContext evaluateRequiredActionTriggers(final KeycloakSession session, final UserSessionModel userSession, final ClientSessionModel clientSession, final ClientConnection clientConnection, final HttpRequest request, final UriInfo uriInfo, final EventBuilder event, final RealmModel realm, final UserModel user) { - RequiredActionContext context = new RequiredActionContext() { + public static void evaluateRequiredActionTriggers(final KeycloakSession session, final UserSessionModel userSession, final ClientSessionModel clientSession, final ClientConnection clientConnection, final HttpRequest request, final UriInfo uriInfo, final EventBuilder event, final RealmModel realm, final UserModel user) { + RequiredActionContextResult result = new RequiredActionContextResult(userSession, clientSession, realm, event, session, request, user) { @Override - public EventBuilder getEvent() { - return event; + public void challenge(Response response) { + throw new RuntimeException("Not allowed to call challenge() within evaluateTriggers()"); } @Override - public UserModel getUser() { - return user; + public void failure() { + throw new RuntimeException("Not allowed to call failure() within evaluateTriggers()"); } @Override - public RealmModel getRealm() { - return realm; + public void success() { + throw new RuntimeException("Not allowed to call success() within evaluateTriggers()"); } @Override - public ClientSessionModel getClientSession() { - return clientSession; - } - - @Override - public UserSessionModel getUserSession() { - return userSession; - } - - @Override - public ClientConnection getConnection() { - return clientConnection; - } - - @Override - public UriInfo getUriInfo() { - return uriInfo; - } - - @Override - public KeycloakSession getSession() { - return session; - } - - @Override - public HttpRequest getHttpRequest() { - return request; - } - - @Override - public String generateAccessCode(String action) { - ClientSessionCode code = new ClientSessionCode(getRealm(), getClientSession()); - code.setAction(action); - return code.getCode(); + public void ignore() { + throw new RuntimeException("Not allowed to call ignore() within evaluateTriggers()"); } }; @@ -543,9 +527,8 @@ public class AuthenticationManager { for (RequiredActionProviderModel model : realm.getRequiredActionProviders()) { if (!model.isEnabled()) continue; RequiredActionProvider provider = session.getProvider(RequiredActionProvider.class, model.getProviderId()); - provider.evaluateTriggers(context); + provider.evaluateTriggers(result); } - return context; } diff --git a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java index dc83039c93..e1789393cf 100755 --- a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java +++ b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java @@ -26,6 +26,7 @@ import org.jboss.resteasy.spi.HttpRequest; import org.keycloak.ClientConnection; import org.keycloak.authentication.AuthenticationProcessor; import org.keycloak.authentication.RequiredActionContext; +import org.keycloak.authentication.RequiredActionContextResult; import org.keycloak.authentication.RequiredActionProvider; import org.keycloak.email.EmailException; import org.keycloak.email.EmailProvider; @@ -125,6 +126,10 @@ public class LoginActionsService { return loginActionsBaseUrl(uriInfo).path(LoginActionsService.class, "authenticateForm"); } + public static UriBuilder requiredActionProcessor(UriInfo uriInfo) { + return loginActionsBaseUrl(uriInfo).path(LoginActionsService.class, "requiredActionPOST"); + } + public static UriBuilder registrationFormProcessor(UriInfo uriInfo) { return loginActionsBaseUrl(uriInfo).path(LoginActionsService.class, "processRegister"); } @@ -829,10 +834,26 @@ public class LoginActionsService { } } - @Path("required-actions/{action}") - public Object requiredAction(@QueryParam("code") final String code, - @PathParam("action") String action) { - event.event(EventType.LOGIN); + @Path("required-action") + @POST + public Response requiredActionPOST(@QueryParam("code") final String code, + @QueryParam("action") String action) { + return processRequireAction(code, action); + + + + } + + @Path("required-action") + @GET + public Response requiredActionGET(@QueryParam("code") final String code, + @QueryParam("action") String action) { + return processRequireAction(code, action); + } + + public Response processRequireAction(final String code, String action) { + event.event(EventType.CUSTOM_REQUIRED_ACTION); + event.detail(Details.CUSTOM_REQUIRED_ACTION, action); if (action == null) { logger.error("required action query param was null"); event.error(Errors.INVALID_CODE); @@ -859,54 +880,11 @@ public class LoginActionsService { throw new WebApplicationException(ErrorPage.error(session, Messages.SESSION_NOT_ACTIVE)); } + initEvent(clientSession); - RequiredActionContext context = new RequiredActionContext() { - @Override - public EventBuilder getEvent() { - return event; - } - @Override - public UserModel getUser() { - return getUserSession().getUser(); - } - - @Override - public RealmModel getRealm() { - return realm; - } - - @Override - public ClientSessionModel getClientSession() { - return clientSession; - } - - @Override - public UserSessionModel getUserSession() { - return clientSession.getUserSession(); - } - - @Override - public ClientConnection getConnection() { - return clientConnection; - } - - @Override - public UriInfo getUriInfo() { - return uriInfo; - } - - @Override - public KeycloakSession getSession() { - return session; - } - - @Override - public HttpRequest getHttpRequest() { - return request; - } - - @Override + RequiredActionContextResult context = new RequiredActionContextResult(clientSession.getUserSession(), clientSession, realm, event, session, request, clientSession.getUserSession().getUser()) { + @Override public String generateAccessCode(String action) { String clientSessionAction = clientSession.getAction(); if (action.equals(clientSessionAction)) { @@ -917,10 +895,32 @@ public class LoginActionsService { code.setAction(action); return code.getCode(); } + + @Override + public void ignore() { + throw new RuntimeException("Cannot call ignore within processAction()"); + } }; - return provider.jaxrsService(context); - + provider.processAction(context); + if (context.getStatus() == RequiredActionContext.Status.SUCCESS) { + event.clone().event(EventType.CUSTOM_REQUIRED_ACTION) + .detail(Details.CUSTOM_REQUIRED_ACTION, action).success(); + clientSession.getUserSession().getUser().removeRequiredAction(provider.getProviderId()); + return AuthenticationManager.nextActionAfterAuthentication(session, clientSession.getUserSession(), clientSession, clientConnection, request, uriInfo, event); + } + if (context.getStatus() == RequiredActionContext.Status.CHALLENGE) { + return context.getChallenge(); + } + if (context.getStatus() == RequiredActionContext.Status.FAILURE) { + LoginProtocol protocol = context.getSession().getProvider(LoginProtocol.class, context.getClientSession().getAuthMethod()); + protocol.setRealm(context.getRealm()) + .setHttpHeaders(context.getHttpRequest().getHttpHeaders()) + .setUriInfo(context.getUriInfo()); + event.detail(Details.CUSTOM_REQUIRED_ACTION, action).error(Errors.REJECTED_BY_USER); + return protocol.consentDenied(context.getClientSession()); + } + throw new RuntimeException("Unreachable"); } } diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/actions/TermsAndConditionsTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/actions/TermsAndConditionsTest.java new file mode 100755 index 0000000000..a1636c8686 --- /dev/null +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/actions/TermsAndConditionsTest.java @@ -0,0 +1,119 @@ +/* + * JBoss, Home of Professional Open Source. + * Copyright 2012, Red Hat, Inc., and individual contributors + * as indicated by the @author tags. See the copyright.txt file in the + * distribution for a full listing of individual contributors. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.keycloak.testsuite.actions; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.authentication.requiredactions.TermsAndConditions; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventType; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.services.managers.RealmManager; +import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.pages.AppPage; +import org.keycloak.testsuite.pages.AppPage.RequestType; +import org.keycloak.testsuite.pages.LoginPage; +import org.keycloak.testsuite.pages.LoginUpdateProfilePage; +import org.keycloak.testsuite.pages.TermsAndConditionsPage; +import org.keycloak.testsuite.rule.KeycloakRule; +import org.keycloak.testsuite.rule.WebResource; +import org.keycloak.testsuite.rule.WebRule; +import org.openqa.selenium.WebDriver; + +/** + * @author Stian Thorgersen + */ +public class TermsAndConditionsTest { + + @ClassRule + public static KeycloakRule keycloakRule = new KeycloakRule(); + + @Rule + public WebRule webRule = new WebRule(this); + + @Rule + public AssertEvents events = new AssertEvents(keycloakRule); + + @WebResource + protected WebDriver driver; + + @WebResource + protected AppPage appPage; + + @WebResource + protected LoginPage loginPage; + + @WebResource + protected TermsAndConditionsPage termsPage; + + @Before + public void before() { + keycloakRule.configure(new KeycloakRule.KeycloakSetup() { + @Override + public void config(RealmManager manager, RealmModel defaultRealm, RealmModel appRealm) { + UserModel user = manager.getSession().users().getUserByUsername("test-user@localhost", appRealm); + user.addRequiredAction(TermsAndConditions.PROVIDER_ID); + } + }); + } + + @Test + public void termsAccepted() { + loginPage.open(); + + loginPage.login("test-user@localhost", "password"); + + termsPage.assertCurrent(); + + termsPage.acceptTerms(); + + String sessionId = events.expectRequiredAction(EventType.CUSTOM_REQUIRED_ACTION).removeDetail(Details.REDIRECT_URI).detail(Details.CUSTOM_REQUIRED_ACTION, TermsAndConditions.PROVIDER_ID).assertEvent().getSessionId(); + + Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + + events.expectLogin().session(sessionId).assertEvent(); + } + + @Test + public void termsDeclined() { + loginPage.open(); + + loginPage.login("test-user@localhost", "password"); + + termsPage.assertCurrent(); + + termsPage.declineTerms(); + + events.expectLogin().detail(Details.CUSTOM_REQUIRED_ACTION, TermsAndConditions.PROVIDER_ID) + .error(Errors.REJECTED_BY_USER) + .removeDetail(Details.CONSENT) + .assertEvent(); + + } + + +} diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/pages/TermsAndConditionsPage.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/pages/TermsAndConditionsPage.java new file mode 100755 index 0000000000..478716fa61 --- /dev/null +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/pages/TermsAndConditionsPage.java @@ -0,0 +1,54 @@ +/* + * JBoss, Home of Professional Open Source. + * Copyright 2012, Red Hat, Inc., and individual contributors + * as indicated by the @author tags. See the copyright.txt file in the + * distribution for a full listing of individual contributors. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.keycloak.testsuite.pages; + +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; + +/** + * @author Stian Thorgersen + */ +public class TermsAndConditionsPage extends AbstractPage { + + @FindBy(id = "kc-accept") + private WebElement submitButton; + + @FindBy(id = "kc-decline") + private WebElement cancelButton; + + public boolean isCurrent() { + return driver.getTitle().equals("Terms and Conditions"); + } + + public void acceptTerms() { + submitButton.click(); + } + public void declineTerms() { + cancelButton.click(); + } + + @Override + public void open() { + throw new UnsupportedOperationException(); + } + +}