Merge pull request #1532 from patriot1burke/master

refactor requiredactions, start doco
This commit is contained in:
Bill Burke 2015-08-11 13:52:07 -04:00
commit 58e37b6a3f
36 changed files with 1030 additions and 264 deletions

View file

@ -0,0 +1,80 @@
<chapter id="auth_spi">
<title>Custom Authentication, Registration, and Required Actions</title>
<para>
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.
</para>
<para>
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.
</para>
<para>
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.
</para>
<section>
<title>Terms</title>
<para>
To first learn about the Authentication SPI, let's go over some of the terms used to describe it.
<variablelist>
<varlistentry>
<term>Authentication Flow</term>
<listitem>
<para>
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.
</para>
</listitem>
</varlistentry>
<varlistentry>
<term>Authenticator</term>
<listitem>
<para>
An authenticator is a pluggable component that hold the logic for performing the authentication
or action within a flow. It is usually a singleton.
</para>
</listitem>
</varlistentry>
<varlistentry>
<term>Execution</term>
<listitem>
<para>
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.
</para>
</listitem>
</varlistentry>
<varlistentry>
<term>Execution Requirement</term>
<listitem>
<para>
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.
</para>
</listitem>
</varlistentry>
<varlistentry>
<term>Authenticator Config</term>
<listitem>
<para>
This object defines the configuration for the Authenticator for a specific execution within
an authentication flow. Each execution can have a different config.
</para>
</listitem>
</varlistentry>
</variablelist>
</para>
</section>
</chapter>

View file

@ -4,7 +4,7 @@ package org.keycloak.events;
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
public interface Details {
String CUSTOM_REQUIRED_ACTION="custom_required_action";
String EMAIL = "email";
String PREVIOUS_EMAIL = "previous_email";
String UPDATED_EMAIL = "updated_email";

View file

@ -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;

View file

@ -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.

View file

@ -0,0 +1,47 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<artifactId>keycloak-examples-providers-parent</artifactId>
<groupId>org.keycloak</groupId>
<version>1.5.0.Final-SNAPSHOT</version>
</parent>
<name>Authenticator Example</name>
<description/>
<modelVersion>4.0.0</modelVersion>
<artifactId>authenticator-example</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-model-api</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jboss.logging</groupId>
<artifactId>jboss-logging</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-login-api</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>federation-properties-example</finalName>
</build>
</project>

View file

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @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<String, String> 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");
}
}

View file

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @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<ProviderConfigProperty> configProperties = new ArrayList<ProviderConfigProperty>();
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<ProviderConfigProperty> 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;
}
}

View file

@ -0,0 +1 @@
org.keycloak.examples.authenticator.SecretQuestionAuthenticatorFactory

View file

@ -17,5 +17,6 @@
<module>event-listener-sysout</module>
<module>event-store-mem</module>
<module>federation-provider</module>
<module>authenticator</module>
</modules>
</project>

View file

@ -8,9 +8,9 @@
<div id="kc-terms-text">
${msg("termsText")}
</div>
<form class="form-actions" action="${requiredActionUrl("terms_and_conditions", "")}" method="POST">
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}" name="accept" id="kc-login" type="submit" value="${msg("doAccept")}"/>
<input class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonLargeClass!}" name="cancel" id="kc-cancel" type="submit" value="${msg("doDecline")}"/>
<form class="form-actions" action="${url.loginAction}" method="POST">
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}" name="accept" id="kc-accept" type="submit" value="${msg("doAccept")}"/>
<input class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonLargeClass!}" name="cancel" id="kc-decline" type="submit" value="${msg("doDecline")}"/>
</form>
</#if>
</@layout.registrationLayout>

View file

@ -25,7 +25,7 @@ public interface LoginFormsProvider extends Provider {
public Response createResponse(UserModel.RequiredAction action);
Response createForm(String form, Map<String, Object> attributes);
Response createForm(String form);
public Response createLogin();

View file

@ -278,7 +278,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
}
@Override
public Response createForm(String form, Map<String, Object> extraAttributes) {
public Response createForm(String form) {
RealmModel realm = session.getContext().getRealm();
ClientModel client = session.getContext().getClient();

View file

@ -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) {

View file

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @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());
}
}

View file

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @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() {
}
}

View file

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @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();
}

View file

@ -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<AuthenticationExecutionModel> 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));

View file

@ -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);

View file

@ -0,0 +1,47 @@
package org.keycloak.authentication;
/**
* Status of an execution/authenticator in a Authentication Flow
*
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @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
}

View file

@ -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();
}

View file

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @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;
}
}

View file

@ -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().

View file

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @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 {

View file

@ -22,7 +22,7 @@ import java.util.List;
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @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

View file

@ -25,7 +25,7 @@ import java.util.Map;
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @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);

View file

@ -20,7 +20,7 @@ import javax.ws.rs.core.Response;
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public class UsernamePasswordForm extends AbstractFormAuthenticator implements Authenticator {
public class UsernamePasswordForm extends AbstractUsernameFormAuthenticator implements Authenticator {
@Override
public void action(AuthenticationFlowContext context) {

View file

@ -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 {

View file

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @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<String, String> 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<String, Object>());
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() {
}
}

View file

@ -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() {

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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;
}

View file

@ -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,53 +880,10 @@ 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;
}
RequiredActionContextResult context = new RequiredActionContextResult(clientSession.getUserSession(), clientSession, realm, event, session, request, clientSession.getUserSession().getUser()) {
@Override
public String generateAccessCode(String action) {
String clientSessionAction = clientSession.getAction();
@ -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");
}
}

View file

@ -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 <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
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();
}
}

View file

@ -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 <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
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();
}
}