Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,11 @@ public interface AuthenticationFlowCallback extends Authenticator {
*/
void onParentFlowSuccess(AuthenticationFlowContext context);


/**
* Triggered after the top authentication flow is successfully finished.
* It is really suitable for last verification of successful authentication
*/
default void onTopFlowSuccess() {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,5 @@ public final class Constants {
public static final String FORCE_LEVEL_OF_AUTHENTICATION = "force-level-of-authentication";
public static final String ACR_LOA_MAP = "acr.loa.map";
public static final int MINIMUM_LOA = 0;
public static final int MAXIMUM_LOA = Integer.MAX_VALUE;
public static final int NO_LOA = -1;
}
Original file line number Diff line number Diff line change
Expand Up @@ -710,18 +710,6 @@ public boolean isSuccessful(AuthenticationExecutionModel model) {
return status == AuthenticationSessionModel.ExecutionStatus.SUCCESS;
}

public boolean isEvaluatedTrue(AuthenticationExecutionModel model) {
AuthenticationSessionModel.ExecutionStatus status = authenticationSession.getExecutionStatus().get(model.getId());
if (status == null) return false;
return status == AuthenticationSessionModel.ExecutionStatus.EVALUATED_TRUE;
}

public boolean isEvaluatedFalse(AuthenticationExecutionModel model) {
AuthenticationSessionModel.ExecutionStatus status = authenticationSession.getExecutionStatus().get(model.getId());
if (status == null) return false;
return status == AuthenticationSessionModel.ExecutionStatus.EVALUATED_FALSE;
}

public Response handleBrowserExceptionList(AuthenticationFlowException e) {
LoginFormsProvider forms = session.getProvider(LoginFormsProvider.class).setAuthenticationSession(authenticationSession);
ServicesLogger.LOGGER.failedAuthentication(e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ private static boolean addAllExecutionsFromSubflow(AuthenticationProcessor proce

// For conditional execution, we must check if condition is true. Otherwise return false, which means trying next
// requiredExecution in the list
return !flow.isConditionalSubflowDisabled(ex, false);
return !flow.isConditionalSubflowDisabled(ex);

}).findFirst().orElse(null);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,27 @@

package org.keycloak.authentication;

import com.google.common.collect.Sets;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.Constants;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.sessions.CommonClientSessionModel;
import org.keycloak.utils.StringUtil;

import java.util.Collections;
import java.util.Set;

import static org.keycloak.services.managers.AuthenticationManager.SSO_AUTH;

public class AuthenticatorUtil {

// It is used for identification of note included in authentication session for storing callback provider factories
public static String CALLBACKS_FACTORY_IDS_NOTE = "callbacksFactoryProviderIds";


public static boolean isSSOAuthentication(AuthenticationSessionModel authSession) {
return "true".equals(authSession.getAuthNote(SSO_AUTH));
}

public static boolean isLevelOfAuthenticationForced(AuthenticationSessionModel authSession) {
return Boolean.parseBoolean(authSession.getClientNote(Constants.FORCE_LEVEL_OF_AUTHENTICATION));
}
Expand All @@ -47,4 +61,46 @@ public static int getCurrentLevelOfAuthentication(AuthenticatedClientSessionMode
String clientSessionLoaNote = clientSession.getNote(Constants.LEVEL_OF_AUTHENTICATION);
return clientSessionLoaNote == null ? Constants.NO_LOA : Integer.parseInt(clientSessionLoaNote);
}

/**
* Set authentication session note for callbacks defined for {@link AuthenticationFlowCallbackFactory) factories
*
* @param authSession authentication session
* @param authFactoryId authentication factory ID which should be added to the authentication session note
*/
public static void setAuthCallbacksFactoryIds(AuthenticationSessionModel authSession, String authFactoryId) {
if (authSession == null || StringUtil.isBlank(authFactoryId)) return;

final String callbacksFactories = authSession.getAuthNote(CALLBACKS_FACTORY_IDS_NOTE);

if (StringUtil.isNotBlank(callbacksFactories)) {
boolean containsProviderId = callbacksFactories.equals(authFactoryId) ||
callbacksFactories.contains(Constants.CFG_DELIMITER + authFactoryId) ||
callbacksFactories.contains(authFactoryId + Constants.CFG_DELIMITER);

if (!containsProviderId) {
authSession.setAuthNote(CALLBACKS_FACTORY_IDS_NOTE, callbacksFactories + Constants.CFG_DELIMITER + authFactoryId);
}
} else {
authSession.setAuthNote(CALLBACKS_FACTORY_IDS_NOTE, authFactoryId);
}
}

/**
* Get set of Authentication factories IDs defined in authentication session as CALLBACKS_FACTORY_IDS_NOTE
*
* @param authSession authentication session
* @return set of factories IDs
*/
public static Set<String> getAuthCallbacksFactoryIds(AuthenticationSessionModel authSession) {
if (authSession == null) return Collections.emptySet();

final String callbacksFactories = authSession.getAuthNote(CALLBACKS_FACTORY_IDS_NOTE);

if (StringUtil.isNotBlank(callbacksFactories)) {
return Sets.newHashSet(callbacksFactories.split(Constants.CFG_DELIMITER));
} else {
return Collections.emptySet();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import org.keycloak.services.ServicesLogger;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.sessions.CommonClientSessionModel;
import org.keycloak.utils.StringUtil;

import javax.ws.rs.HttpMethod;
import javax.ws.rs.core.MultivaluedMap;
Expand All @@ -35,6 +36,8 @@
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
Expand All @@ -48,7 +51,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
private final List<AuthenticationExecutionModel> executions;
private final AuthenticationProcessor processor;
private final AuthenticationFlowModel flow;
private boolean successful;
private boolean successful = false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May I suggest Boolean.FALSE?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm... I am not sure if it is better to use Boolean.FALSE ? This would mean one more necessity for auto-boxing from Boolean object (Boolean.FALSE) to primitive value false. And the successful is a primitive variable, which is not sent anywhere and not parsed from/to String etc and hence is completely fine with stick only to primitive value (true/false) IMO without need of any boxing/unboxing from/to Java object of type Boolean? IMO best for performance and also readability.

Or do you have some article or resource, which recommends to not use primitive for such use-case?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems fine to me, just following some guides to avoid constants. But not any problem or technical need.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarification. I consider constants makes sense especially for strings and numbers. Also for example when I want to specify for example default value or when I want to search for the usage. Pretty much most of the use-cases like used for example in class org.keycloak.models.Constants . For this particular use-case, I don't see much value of using Boolean.FALSE instead of false . But thanks for bringing this.

private List<AuthenticationFlowException> afeList = new ArrayList<>();

public DefaultAuthenticationFlow(AuthenticationProcessor processor, AuthenticationFlowModel flow) {
Expand Down Expand Up @@ -158,6 +161,10 @@ public Response processAction(String actionExecution) {
AuthenticationProcessor.Result result = processor.createAuthenticatorContext(model, authenticator, executions);
result.setAuthenticationSelections(createAuthenticationSelectionList(model));

if (factory instanceof AuthenticationFlowCallbackFactory) {
AuthenticatorUtil.setAuthCallbacksFactoryIds(processor.getAuthenticationSession(), factory.getId());
}

logger.debugv("action: {0}", model.getAuthenticator());
authenticator.action(result);
Response response = processResult(result, true);
Expand Down Expand Up @@ -250,7 +257,7 @@ public Response processFlow() {
AuthenticationExecutionModel required = requiredIListIterator.next();
//Conditional flows must be considered disabled (non-existent) if their condition evaluates to false.
//If the flow has been processed before it will not be removed to consider its execution status.
if (required.isConditional() && !isProcessed(required) && isConditionalSubflowDisabled(required, true)) {
if (required.isConditional() && !isProcessed(required) && isConditionalSubflowDisabled(required)) {
requiredIListIterator.remove();
continue;
}
Expand All @@ -270,8 +277,7 @@ public Response processFlow() {
if (requiredList.isEmpty()) {
//check if an alternative is already successful, in case we are returning in the flow after an action
if (alternativeList.stream().anyMatch(alternative -> processor.isSuccessful(alternative) || isSetupRequired(alternative))) {
successful = true;
return null;
return onFlowExecutionsSuccessful();
}

//handle alternative elements: the first alternative element to be satisfied is enough
Expand All @@ -282,8 +288,7 @@ public Response processFlow() {
return response;
}
if (processor.isSuccessful(alternative) || isSetupRequired(alternative)) {
successful = true;
return null;
return onFlowExecutionsSuccessful();
}
} catch (AuthenticationFlowException afe) {
//consuming the error is not good here from an administrative point of view, but the user, since he has alternatives, should be able to go to another alternative and continue
Expand All @@ -292,7 +297,9 @@ public Response processFlow() {
}
}
} else {
successful = requiredElementsSuccessful;
if (requiredElementsSuccessful) {
return onFlowExecutionsSuccessful();
}
}
return null;
}
Expand Down Expand Up @@ -326,10 +333,9 @@ void fillListsOfExecutions(Stream<AuthenticationExecutionModel> executionsToProc
/**
* Checks if the conditional subflow passed in parameter is disabled.
* @param model
* @param storeResult whether to store the result of the conditional evaluations
* @return
*/
boolean isConditionalSubflowDisabled(AuthenticationExecutionModel model, boolean storeResult) {
boolean isConditionalSubflowDisabled(AuthenticationExecutionModel model) {
if (model == null || !model.isAuthenticatorFlow() || !model.isConditional()) {
return false;
};
Expand All @@ -339,8 +345,10 @@ boolean isConditionalSubflowDisabled(AuthenticationExecutionModel model, boolean
.filter(this::isConditionalAuthenticator)
.filter(s -> s.isEnabled())
.collect(Collectors.toList());
return conditionalAuthenticatorList.isEmpty() || conditionalAuthenticatorList.stream()
.anyMatch(m -> conditionalNotMatched(m, modelList, storeResult));
boolean conditionalSubflowDisabled = conditionalAuthenticatorList.isEmpty() || conditionalAuthenticatorList.stream()
.anyMatch(m -> conditionalNotMatched(m, modelList));
logger.tracef("Conditional subflow '%s' is %s", logExecutionAlias(model), conditionalSubflowDisabled ? "disabled" : "enabled");
return conditionalSubflowDisabled;
}

private boolean isConditionalAuthenticator(AuthenticationExecutionModel model) {
Expand All @@ -355,25 +363,16 @@ private AuthenticatorFactory getAuthenticatorFactory(AuthenticationExecutionMode
return factory;
}

private boolean conditionalNotMatched(AuthenticationExecutionModel model, List<AuthenticationExecutionModel> executionList, boolean storeResult) {
private boolean conditionalNotMatched(AuthenticationExecutionModel model, List<AuthenticationExecutionModel> executionList) {
AuthenticatorFactory factory = getAuthenticatorFactory(model);
ConditionalAuthenticator authenticator = (ConditionalAuthenticator) createAuthenticator(factory);
AuthenticationProcessor.Result context = processor.createAuthenticatorContext(model, authenticator, executionList);

boolean matchCondition;

// Retrieve previous evaluation result if any, else evaluate and store result for future re-evaluation
if (processor.isEvaluatedTrue(model)) {
matchCondition = true;
} else if (processor.isEvaluatedFalse(model)) {
matchCondition = false;
} else {
matchCondition = authenticator.matchCondition(context);
if (storeResult) {
setExecutionStatus(model,
matchCondition ? AuthenticationSessionModel.ExecutionStatus.EVALUATED_TRUE : AuthenticationSessionModel.ExecutionStatus.EVALUATED_FALSE);
}
}
// Always store result for future re-evaluation. It is a chance that some condition is evaluated multiple times during the flow,
// but this is expected as "conditions of condition" can be changed during the flow (EG. when acr level is reached or when user is added to the context)
boolean matchCondition = authenticator.matchCondition(context);
setExecutionStatus(model,
matchCondition ? AuthenticationSessionModel.ExecutionStatus.EVALUATED_TRUE : AuthenticationSessionModel.ExecutionStatus.EVALUATED_FALSE);

return !matchCondition;
}
Expand Down Expand Up @@ -547,6 +546,8 @@ public List<AuthenticationFlowException> getFlowExceptions(){
private void setExecutionStatus(AuthenticationExecutionModel authExecutionModel, CommonClientSessionModel.ExecutionStatus status) {
this.processor.getAuthenticationSession().setExecutionStatus(authExecutionModel.getId(), status);

logger.tracef("Set execution status: Execution: %s, status: %s", logExecutionAlias(authExecutionModel), status);

if (authExecutionModel.isAuthenticatorFlow() && status == CommonClientSessionModel.ExecutionStatus.SUCCESS) {
// Trigger callbacks after flow was successfully finished
processor.getRealm().getAuthenticationExecutionsStream(authExecutionModel.getFlowId()).forEach(this::checkAuthCallback);
Expand All @@ -563,8 +564,37 @@ private void checkAuthCallback(AuthenticationExecutionModel execution) {
AuthenticationFlowCallback authCallback = (AuthenticationFlowCallback) createAuthenticator(authFactory);
logger.tracef("Will trigger callback '%s' after successful finish of the flow '%s'", authFactory.getId(), execution.getParentFlow());
authCallback.onParentFlowSuccess(processor.createAuthenticatorContext(execution, authCallback, null)); // no need to have executions filled
AuthenticatorUtil.setAuthCallbacksFactoryIds(processor.getAuthenticationSession(), authFactory.getId());
}
}
}
}

// This is triggered when current flow is successful due the fact that it's executions passed.
// It is opportunity to do some last "generic" checks before considering whole authentication as successful
private Response onFlowExecutionsSuccessful() {
if (flow.isTopLevel()) {
logger.debugf("Authentication successful of the top flow '%s'", flow.getAlias());
executeTopFlowSuccessCallbacks();
}

successful = true;
return null;
}

/**
* Execute callbacks defined for each {@see AuthenticationFlowCallbackFactory} class in top authentication flow if success
*/
private void executeTopFlowSuccessCallbacks() {
final AuthenticationSessionModel authSession = processor.getAuthenticationSession();
final Set<String> factoryProviderIDs = AuthenticatorUtil.getAuthCallbacksFactoryIds(authSession);

factoryProviderIDs.stream()
.filter(StringUtil::isNotBlank)
.map(id -> processor.getSession().getProvider(Authenticator.class, id))
.filter(Objects::nonNull)
.filter(AuthenticationFlowCallback.class::isInstance)
.map(AuthenticationFlowCallback.class::cast)
.forEach(AuthenticationFlowCallback::onTopFlowSuccess);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ default Authenticator create(KeycloakSession session) {

@Override
default Authenticator createDisplay(KeycloakSession session, String displayType) {
if (displayType == null) return getSingleton();
if (displayType == null) return create(session);
if (!OAuth2Constants.DISPLAY_CONSOLE.equalsIgnoreCase(displayType)) return null;
return getSingleton();
return create(session);
}

ConditionalAuthenticator getSingleton();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,28 +20,41 @@
import org.jboss.logging.Logger;
import org.keycloak.authentication.AuthenticationFlowCallback;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.AuthenticationFlowException;
import org.keycloak.authentication.AuthenticatorUtil;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.services.messages.Messages;
import org.keycloak.sessions.AuthenticationSessionModel;

public class ConditionalLoaAuthenticator implements ConditionalAuthenticator, AuthenticationFlowCallback {

public static final String LEVEL = "loa-condition-level";
public static final String STORE_IN_USER_SESSION = "loa-store-in-user-session";

private static final Logger logger = Logger.getLogger(ConditionalLoaAuthenticator.class);

private final KeycloakSession session;

public ConditionalLoaAuthenticator(KeycloakSession session) {
this.session = session;
}

@Override
public boolean matchCondition(AuthenticationFlowContext context) {
AuthenticationSessionModel authSession = context.getAuthenticationSession();
int currentLoa = AuthenticatorUtil.getCurrentLevelOfAuthentication(authSession);
int requestedLoa = AuthenticatorUtil.getRequestedLevelOfAuthentication(authSession);
Integer configuredLoa = getConfiguredLoa(context);
return (currentLoa < Constants.MINIMUM_LOA && requestedLoa < Constants.MINIMUM_LOA)
boolean result = (currentLoa < Constants.MINIMUM_LOA && requestedLoa < Constants.MINIMUM_LOA)
|| ((configuredLoa == null || currentLoa < configuredLoa) && currentLoa < requestedLoa);

logger.tracef("Checking condition '%s' : currentLoa: %d, requestedLoa: %d, configuredLoa: %d, evaluation result: %b",
context.getAuthenticatorConfig().getAlias(), currentLoa, requestedLoa, configuredLoa, result);

return result;
}

@Override
Expand All @@ -58,6 +71,17 @@ public void onParentFlowSuccess(AuthenticationFlowContext context) {
}
}

@Override
public void onTopFlowSuccess() {
AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession();

if (AuthenticatorUtil.isLevelOfAuthenticationForced(authSession) && !AuthenticatorUtil.isLevelOfAuthenticationSatisfied(authSession) && !AuthenticatorUtil.isSSOAuthentication(authSession)) {
String details = String.format("Forced level of authentication did not meet the requirements. Requested level: %d, Fulfilled level: %d",
AuthenticatorUtil.getRequestedLevelOfAuthentication(authSession), AuthenticatorUtil.getCurrentLevelOfAuthentication(authSession));
throw new AuthenticationFlowException(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, details, Messages.ACR_NOT_FULFILLED);
}
}

private Integer getConfiguredLoa(AuthenticationFlowContext context) {
try {
return Integer.parseInt(context.getAuthenticatorConfig().getConfig().get(LEVEL));
Expand Down
Loading