From 93bba8e338d7c785a4e2968fa54ce312be35ddb4 Mon Sep 17 00:00:00 2001 From: mposolda Date: Wed, 23 Feb 2022 17:25:27 +0100 Subject: [PATCH] Replace 'Store LoA in User Session' with 'Max Age'. Refactoring of step-up authentications related to that. Closes #10205 --- .../java/org/keycloak/models/Constants.java | 6 + .../authentication/AuthenticatorUtil.java | 61 +--- .../browser/CookieAuthenticator.java | 30 +- .../ConditionalLoaAuthenticator.java | 79 +++-- .../ConditionalLoaAuthenticatorFactory.java | 10 +- .../authenticators/util/AcrStore.java | 201 +++++++++++ .../authenticators/util/LoAUtil.java | 122 +++++++ .../protocol/oidc/OIDCWellKnownProvider.java | 6 +- .../keycloak/protocol/oidc/TokenManager.java | 9 +- .../protocol/oidc/utils/AcrUtils.java | 2 +- .../managers/AuthenticationManager.java | 3 + .../DefaultClientValidationProvider.java | 3 +- .../keycloak/testsuite/util/OAuthClient.java | 14 + ...uthenticationFlowCallbackProviderTest.java | 2 +- .../forms/LevelOfAssuranceFlowTest.java | 316 +++++++++++++++--- .../messages/admin-messages_en.properties | 6 +- 16 files changed, 702 insertions(+), 168 deletions(-) create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/util/AcrStore.java create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/util/LoAUtil.java diff --git a/server-spi-private/src/main/java/org/keycloak/models/Constants.java b/server-spi-private/src/main/java/org/keycloak/models/Constants.java index 76b7745f6f..a86e84f7cf 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/Constants.java +++ b/server-spi-private/src/main/java/org/keycloak/models/Constants.java @@ -131,7 +131,13 @@ public final class Constants { public static final String CLIENT_POLICIES = "client-policies.policies"; + // Authentication session note, which contains loa of current authentication in progress public static final String LEVEL_OF_AUTHENTICATION = "level-of-authentication"; + + // Authentication session (and user session) note, which contains map with authenticated levels and the times of their authentications, + // so it is possible to check when particular level expires and needs to be re-authenticated + public static final String LOA_MAP = "loa-map"; + public static final String REQUESTED_LEVEL_OF_AUTHENTICATION = "requested-level-of-authentication"; public static final String FORCE_LEVEL_OF_AUTHENTICATION = "force-level-of-authentication"; public static final String ACR_LOA_MAP = "acr.loa.map"; diff --git a/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java b/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java index 595d533a7a..bba9982529 100755 --- a/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java +++ b/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java @@ -19,11 +19,7 @@ package org.keycloak.authentication; import com.google.common.collect.Sets; import org.jboss.logging.Logger; -import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticator; -import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory; -import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.AuthenticationExecutionModel; -import org.keycloak.models.AuthenticatorConfigModel; import org.keycloak.models.Constants; import org.keycloak.models.RealmModel; import org.keycloak.sessions.AuthenticationSessionModel; @@ -32,10 +28,9 @@ import org.keycloak.utils.StringUtil; import java.util.Collections; import java.util.LinkedList; import java.util.List; -import java.util.Objects; import java.util.Set; -import java.util.stream.Stream; +import static org.keycloak.services.managers.AuthenticationManager.FORCED_REAUTHENTICATION; import static org.keycloak.services.managers.AuthenticationManager.SSO_AUTH; public class AuthenticatorUtil { @@ -50,28 +45,8 @@ public class AuthenticatorUtil { return "true".equals(authSession.getAuthNote(SSO_AUTH)); } - public static boolean isLevelOfAuthenticationForced(AuthenticationSessionModel authSession) { - return Boolean.parseBoolean(authSession.getClientNote(Constants.FORCE_LEVEL_OF_AUTHENTICATION)); - } - - public static int getRequestedLevelOfAuthentication(AuthenticationSessionModel authSession) { - String requiredLoa = authSession.getClientNote(Constants.REQUESTED_LEVEL_OF_AUTHENTICATION); - return requiredLoa == null ? Constants.NO_LOA : Integer.parseInt(requiredLoa); - } - - public static int getCurrentLevelOfAuthentication(AuthenticationSessionModel authSession) { - String authSessionLoaNote = authSession.getAuthNote(Constants.LEVEL_OF_AUTHENTICATION); - return authSessionLoaNote == null ? Constants.NO_LOA : Integer.parseInt(authSessionLoaNote); - } - - public static boolean isLevelOfAuthenticationSatisfied(AuthenticationSessionModel authSession) { - return AuthenticatorUtil.getRequestedLevelOfAuthentication(authSession) - <= AuthenticatorUtil.getCurrentLevelOfAuthentication(authSession); - } - - public static int getCurrentLevelOfAuthentication(AuthenticatedClientSessionModel clientSession) { - String clientSessionLoaNote = clientSession.getNote(Constants.LEVEL_OF_AUTHENTICATION); - return clientSessionLoaNote == null ? Constants.NO_LOA : Integer.parseInt(clientSessionLoaNote); + public static boolean isForcedReauthentication(AuthenticationSessionModel authSession) { + return "true".equals(authSession.getAuthNote(FORCED_REAUTHENTICATION)); } /** @@ -135,34 +110,4 @@ public class AuthenticatorUtil { return executions; } - /** - * @param realm - * @return All LoA numbers configured in the conditions in the realm browser flow - */ - public static Stream getLoAConfiguredInRealmBrowserFlow(RealmModel realm) { - List loaConditions = getExecutionsByType(realm, realm.getBrowserFlow().getId(), ConditionalLoaAuthenticatorFactory.PROVIDER_ID); - if (loaConditions.isEmpty()) { - // Default values used when step-up conditions not used in the browser authentication flow. - // This is used for backwards compatibility and in case when step-up is not configured in the authentication flow (returning 1 in case of "normal" authentication, 0 for SSO authentication) - return Stream.of(Constants.MINIMUM_LOA, 1); - } else { - Stream configuredLoas = loaConditions.stream() - .map(authExecution -> realm.getAuthenticatorConfigById(authExecution.getAuthenticatorConfig())) - .filter(Objects::nonNull) - .map(authConfig -> { - String levelAsStr = authConfig.getConfig().get(ConditionalLoaAuthenticator.LEVEL); - try { - // Check it can be cast to number - return Integer.parseInt(levelAsStr); - } catch (NullPointerException | NumberFormatException e) { - logger.warnf("Invalid level '%s' configured for the configuration of LoA condition with alias '%s'. Level should be number.", levelAsStr, authConfig.getAlias()); - return null; - } - }) - .filter(Objects::nonNull); - - // Add 0 as a level used for SSO cookie - return Stream.concat(Stream.of(Constants.MINIMUM_LOA), configuredLoas); - } - } } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/CookieAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/CookieAuthenticator.java index 9c58f6420e..87b3e9a296 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/CookieAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/CookieAuthenticator.java @@ -19,7 +19,7 @@ package org.keycloak.authentication.authenticators.browser; import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.authentication.Authenticator; -import org.keycloak.authentication.AuthenticatorUtil; +import org.keycloak.authentication.authenticators.util.AcrStore; import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; @@ -49,25 +49,31 @@ public class CookieAuthenticator implements Authenticator { } else { AuthenticationSessionModel authSession = context.getAuthenticationSession(); LoginProtocol protocol = context.getSession().getProvider(LoginProtocol.class, authSession.getProtocol()); - authSession.setAuthNote(Constants.LEVEL_OF_AUTHENTICATION, authResult.getSession().getNote(Constants.LEVEL_OF_AUTHENTICATION)); + authSession.setAuthNote(Constants.LOA_MAP, authResult.getSession().getNote(Constants.LOA_MAP)); context.setUser(authResult.getUser()); + AcrStore acrStore = new AcrStore(authSession); // Cookie re-authentication is skipped if re-authentication is required if (protocol.requireReauthentication(authResult.getSession(), authSession)) { // Full re-authentication, so we start with no loa - authSession.setAuthNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(Constants.NO_LOA)); + acrStore.setLevelAuthenticatedToCurrentRequest(Constants.NO_LOA); + authSession.setAuthNote(AuthenticationManager.FORCED_REAUTHENTICATION, "true"); context.setForwardedInfoMessage(Messages.REAUTHENTICATE); context.attempted(); - } else if (!AuthenticatorUtil.isLevelOfAuthenticationSatisfied(authSession)) { - // Step-up authentication, we keep the loa from the existing user session. - // The cookie alone is not enough and other authentications must follow. - context.attempted(); } else { - // Cookie only authentication, no loa is returned - authSession.setAuthNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(Constants.NO_LOA)); - authSession.setAuthNote(AuthenticationManager.SSO_AUTH, "true"); - context.attachUserSession(authResult.getSession()); - context.success(); + int previouslyAuthenticatedLevel = acrStore.getHighestAuthenticatedLevelFromPreviousAuthentication(); + if (acrStore.getRequestedLevelOfAuthentication() > previouslyAuthenticatedLevel) { + // Step-up authentication, we keep the loa from the existing user session. + // The cookie alone is not enough and other authentications must follow. + acrStore.setLevelAuthenticatedToCurrentRequest(previouslyAuthenticatedLevel); + context.attempted(); + } else { + // Cookie only authentication + acrStore.setLevelAuthenticatedToCurrentRequest(previouslyAuthenticatedLevel); + authSession.setAuthNote(AuthenticationManager.SSO_AUTH, "true"); + context.attachUserSession(authResult.getSession()); + context.success(); + } } } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalLoaAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalLoaAuthenticator.java index 430955648d..f5eb81a7e0 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalLoaAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalLoaAuthenticator.java @@ -23,6 +23,8 @@ import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.authentication.AuthenticationFlowError; import org.keycloak.authentication.AuthenticationFlowException; import org.keycloak.authentication.AuthenticatorUtil; +import org.keycloak.authentication.authenticators.util.AcrStore; +import org.keycloak.authentication.authenticators.util.LoAUtil; import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; @@ -32,6 +34,11 @@ import org.keycloak.sessions.AuthenticationSessionModel; public class ConditionalLoaAuthenticator implements ConditionalAuthenticator, AuthenticationFlowCallback { public static final String LEVEL = "loa-condition-level"; + public static final String MAX_AGE = "loa-max-age"; + public static final int DEFAULT_MAX_AGE = 36000; // 10 days + + // Only for backwards compatibility with Keycloak 17 + @Deprecated public static final String STORE_IN_USER_SESSION = "loa-store-in-user-session"; private static final Logger logger = Logger.getLogger(ConditionalLoaAuthenticator.class); @@ -45,59 +52,77 @@ public class ConditionalLoaAuthenticator implements ConditionalAuthenticator, Au @Override public boolean matchCondition(AuthenticationFlowContext context) { AuthenticationSessionModel authSession = context.getAuthenticationSession(); - int currentLoa = AuthenticatorUtil.getCurrentLevelOfAuthentication(authSession); - int requestedLoa = AuthenticatorUtil.getRequestedLevelOfAuthentication(authSession); + AcrStore acrStore = new AcrStore(authSession); + int currentAuthenticationLoa = acrStore.getLevelOfAuthenticationFromCurrentAuthentication(); Integer configuredLoa = getConfiguredLoa(context); - boolean result = (currentLoa < Constants.MINIMUM_LOA && requestedLoa < Constants.MINIMUM_LOA) - || ((configuredLoa == null || currentLoa < configuredLoa) && currentLoa < requestedLoa); + if (configuredLoa == null) configuredLoa = Constants.MINIMUM_LOA; + int requestedLoa = acrStore.getRequestedLevelOfAuthentication(); + if (currentAuthenticationLoa < Constants.MINIMUM_LOA) { + logger.tracef("Condition '%s' evaluated to true due the user not yet reached any authentication level in this session, configuredLoa: %d, requestedLoa: %d", + context.getAuthenticatorConfig().getAlias(), configuredLoa, requestedLoa); + return true; + } else { + if (requestedLoa < configuredLoa) { + logger.tracef("Condition '%s' evaluated to false due the requestedLoa '%d' smaller than configuredLoa '%d'. CurrentAuthenticationLoa: %d", + context.getAuthenticatorConfig().getAlias(), requestedLoa, configuredLoa, currentAuthenticationLoa); + return false; + } + int maxAge = getMaxAge(context); + boolean previouslyAuthenticated = (acrStore.isLevelAuthenticatedInPreviousAuth(configuredLoa, maxAge)); + if (previouslyAuthenticated) { + if (currentAuthenticationLoa < configuredLoa) { + acrStore.setLevelAuthenticatedToCurrentRequest(configuredLoa); + } + } + logger.tracef("Checking condition '%s' : currentAuthenticationLoa: %d, requestedLoa: %d, configuredLoa: %d, evaluation result: %b", + context.getAuthenticatorConfig().getAlias(), currentAuthenticationLoa, requestedLoa, configuredLoa, !previouslyAuthenticated); - logger.tracef("Checking condition '%s' : currentLoa: %d, requestedLoa: %d, configuredLoa: %d, evaluation result: %b", - context.getAuthenticatorConfig().getAlias(), currentLoa, requestedLoa, configuredLoa, result); - - return result; + return !previouslyAuthenticated; + } } @Override public void onParentFlowSuccess(AuthenticationFlowContext context) { AuthenticationSessionModel authSession = context.getAuthenticationSession(); + AcrStore acrStore = new AcrStore(authSession); + Integer newLoa = getConfiguredLoa(context); if (newLoa == null) { return; } - logger.tracef("Updating LoA to '%d' when authenticating session '%s'", newLoa, authSession.getParentSession().getId()); - authSession.setAuthNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(newLoa)); - if (isStoreInUserSession(context)) { - authSession.setUserSessionNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(newLoa)); + int maxAge = getMaxAge(context); + if (maxAge == 0) { + logger.tracef("Skip updating authenticated level '%d' in condition '%s' for future authentications due max-age set to 0", newLoa, context.getAuthenticatorConfig().getAlias()); + acrStore.setLevelAuthenticatedToCurrentRequest(newLoa); + } else { + logger.tracef("Updating LoA to '%d' in the condition '%s' when authenticating session '%s'. Max age is %d.", + newLoa, context.getAuthenticatorConfig().getAlias(), authSession.getParentSession().getId(), maxAge); + acrStore.setLevelAuthenticated(newLoa); } } @Override public void onTopFlowSuccess() { AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession(); + AcrStore acrStore = new AcrStore(authSession); - if (AuthenticatorUtil.isLevelOfAuthenticationForced(authSession) && !AuthenticatorUtil.isLevelOfAuthenticationSatisfied(authSession) && !AuthenticatorUtil.isSSOAuthentication(authSession)) { + logger.tracef("Finished authentication at level %d when authenticating authSession '%s'.", acrStore.getLevelOfAuthenticationFromCurrentAuthentication(), authSession.getParentSession().getId()); + if (acrStore.isLevelOfAuthenticationForced() && !acrStore.isLevelOfAuthenticationSatisfiedFromCurrentAuthentication()) { 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)); + acrStore.getRequestedLevelOfAuthentication(), acrStore.getLevelOfAuthenticationFromCurrentAuthentication()); throw new AuthenticationFlowException(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, details, Messages.ACR_NOT_FULFILLED); } + + logger.tracef("Updating authenticated levels in authSession '%s' to user session note for future authentications: %s", authSession.getParentSession().getId(), authSession.getAuthNote(Constants.LOA_MAP)); + authSession.setUserSessionNote(Constants.LOA_MAP, authSession.getAuthNote(Constants.LOA_MAP)); } private Integer getConfiguredLoa(AuthenticationFlowContext context) { - try { - return Integer.parseInt(context.getAuthenticatorConfig().getConfig().get(LEVEL)); - } catch (NullPointerException | NumberFormatException e) { - logger.errorv("Invalid configuration: {0}", LEVEL); - return null; - } + return LoAUtil.getLevelFromLoaConditionConfiguration(context.getAuthenticatorConfig()); } - private boolean isStoreInUserSession(AuthenticationFlowContext context) { - try { - return Boolean.parseBoolean(context.getAuthenticatorConfig().getConfig().get(STORE_IN_USER_SESSION)); - } catch (NullPointerException | NumberFormatException e) { - logger.errorv("Invalid configuration: {0}", STORE_IN_USER_SESSION); - return false; - } + private int getMaxAge(AuthenticationFlowContext context) { + return LoAUtil.getMaxAgeFromLoaConditionConfiguration(context.getAuthenticatorConfig()); } @Override diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalLoaAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalLoaAuthenticatorFactory.java index 18197b6f96..41f55469eb 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalLoaAuthenticatorFactory.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalLoaAuthenticatorFactory.java @@ -43,11 +43,11 @@ public class ConditionalLoaAuthenticatorFactory implements ConditionalAuthentica .type(ProviderConfigProperty.STRING_TYPE) .add() .property() - .name(ConditionalLoaAuthenticator.STORE_IN_USER_SESSION) - .label(ConditionalLoaAuthenticator.STORE_IN_USER_SESSION) - .helpText(ConditionalLoaAuthenticator.STORE_IN_USER_SESSION + ".tooltip") - .type(ProviderConfigProperty.BOOLEAN_TYPE) - .defaultValue("true") + .name(ConditionalLoaAuthenticator.MAX_AGE) + .label(ConditionalLoaAuthenticator.MAX_AGE) + .helpText(ConditionalLoaAuthenticator.MAX_AGE + ".tooltip") + .type(ProviderConfigProperty.STRING_TYPE) + .defaultValue(ConditionalLoaAuthenticator.DEFAULT_MAX_AGE) // 10 hours .add() .build(); diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/util/AcrStore.java b/services/src/main/java/org/keycloak/authentication/authenticators/util/AcrStore.java new file mode 100644 index 0000000000..7737ed9744 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/util/AcrStore.java @@ -0,0 +1,201 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.keycloak.authentication.authenticators.util; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.TreeMap; + +import com.fasterxml.jackson.core.type.TypeReference; +import org.jboss.logging.Logger; +import org.keycloak.authentication.AuthenticatorUtil; +import org.keycloak.common.util.Time; +import org.keycloak.models.AuthenticatedClientSessionModel; +import org.keycloak.models.Constants; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.util.JsonSerialization; + +/** + * CRUD data in the authentication session, which are related to step-up authentication + * + * @author Marek Posolda + */ +public class AcrStore { + + private static final Logger logger = Logger.getLogger(AcrStore.class); + + private final AuthenticationSessionModel authSession; + + public AcrStore(AuthenticationSessionModel authSession) { + this.authSession = authSession; + } + + + public boolean isLevelOfAuthenticationForced() { + return Boolean.parseBoolean(authSession.getClientNote(Constants.FORCE_LEVEL_OF_AUTHENTICATION)); + } + + + public int getRequestedLevelOfAuthentication() { + String requiredLoa = authSession.getClientNote(Constants.REQUESTED_LEVEL_OF_AUTHENTICATION); + return requiredLoa == null ? Constants.NO_LOA : Integer.parseInt(requiredLoa); + } + + + public boolean isLevelOfAuthenticationSatisfiedFromCurrentAuthentication() { + return getRequestedLevelOfAuthentication() + <= getAuthenticatedLevelCurrentAuthentication(); + } + + + public static int getCurrentLevelOfAuthentication(AuthenticatedClientSessionModel clientSession) { + String clientSessionLoaNote = clientSession.getNote(Constants.LEVEL_OF_AUTHENTICATION); + return clientSessionLoaNote == null ? Constants.NO_LOA : Integer.parseInt(clientSessionLoaNote); + } + + + /** + * @param level level of authentication + * @param maxAge maxAge for which this level is considered valid + * @return True if the particular level was already authenticated before in this userSession and is still valid + */ + public boolean isLevelAuthenticatedInPreviousAuth(int level, int maxAge) { + // In case of re-authentication requested from client (EG. by "prompt=login" or "max_age=0", the LoA from previous authentications are not + // considered. User needs to re-authenticate all requested levels again. + if (AuthenticatorUtil.isForcedReauthentication(authSession)) return false; + + Map levels = getCurrentAuthenticatedLevelsMap(); + if (levels == null) return false; + + Integer levelAuthTime = levels.get(level); + if (levelAuthTime == null) return false; + + int currentTime = Time.currentTime(); + return levelAuthTime + maxAge >= currentTime; + } + + + /** + * return level, which was either: + * - directly authenticated in current authentication + * - or was already verified that can be re-used from previous authentication + * + * @return see above + */ + public int getLevelOfAuthenticationFromCurrentAuthentication() { + String authSessionLoaNote = authSession.getAuthNote(Constants.LEVEL_OF_AUTHENTICATION); + return authSessionLoaNote == null ? Constants.NO_LOA : Integer.parseInt(authSessionLoaNote); + } + + + /** + * Save authenticated level to authenticationSession (for current authentication) and loa map (for future authentications) + * + * @param level level to save + */ + public void setLevelAuthenticated(int level) { + setLevelAuthenticatedToCurrentRequest(level); + setLevelAuthenticatedToMap(level); + } + + /** + * Set level to the current authentication session + * + * @param level, which was authenticated by user + */ + public void setLevelAuthenticatedToCurrentRequest(int level) { + authSession.setAuthNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(level)); + } + + + private void setLevelAuthenticatedToMap(int level) { + Map levels = getCurrentAuthenticatedLevelsMap(); + if (levels == null) levels = new HashMap<>(); + + levels.put(level, Time.currentTime()); + + saveCurrentAuthenticatedLevelsMap(levels); + } + + + private int getAuthenticatedLevelCurrentAuthentication() { + String authSessionLoaNote = authSession.getAuthNote(Constants.LEVEL_OF_AUTHENTICATION); + return authSessionLoaNote == null ? Constants.NO_LOA : Integer.parseInt(authSessionLoaNote); + } + + /** + * @return highest authenticated level from previous authentication, which is still valid (not yet expired) + */ + public int getHighestAuthenticatedLevelFromPreviousAuthentication() { + // No map found. User was not yet authenticated in this session + Map levels = getCurrentAuthenticatedLevelsMap(); + if (levels == null || levels.isEmpty()) return Constants.NO_LOA; + + // Map was already saved, so it is SSO authentication at minimum. Using "0" level as the minimum level in this case + int maxLevel = Constants.MINIMUM_LOA; + int currentTime = Time.currentTime(); + + Map configuredMaxAges = LoAUtil.getLoaMaxAgesConfiguredInRealmBrowserFlow(authSession.getRealm()); + levels = new TreeMap<>(levels); + + for (Map.Entry entry : levels.entrySet()) { + Integer levelMaxAge = configuredMaxAges.get(entry.getKey()); + if (levelMaxAge == null) { + logger.warnf("No condition found for level '%d' in the authentication flow", entry.getKey()); + levelMaxAge = 0; + } + int levelAuthTime = entry.getValue(); + int levelExpiration = levelAuthTime + levelMaxAge; + if (currentTime <= levelExpiration) { + maxLevel = entry.getKey(); + } else { + break; + } + } + + logger.tracef("Highest authenticated level from previous authentication of client '%s' in authentication '%s' was: %d", + authSession.getClient().getClientId(), authSession.getParentSession().getId(), maxLevel); + return maxLevel; + } + + // Key is level number. Value is level authTime + private Map getCurrentAuthenticatedLevelsMap() { + String loaMap = authSession.getAuthNote(Constants.LOA_MAP); + if (loaMap == null) { + return null; + } + try { + return JsonSerialization.readValue(loaMap, new TypeReference>() {}); + } catch (IOException e) { + logger.warnf("Invalid format of the LoA map. Saved value was: %s", loaMap); + throw new IllegalStateException(e); + } + } + + private void saveCurrentAuthenticatedLevelsMap(Map levelInfoMap) { + try { + String note = JsonSerialization.writeValueAsString(levelInfoMap); + authSession.setAuthNote(Constants.LOA_MAP, note); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/util/LoAUtil.java b/services/src/main/java/org/keycloak/authentication/authenticators/util/LoAUtil.java new file mode 100644 index 0000000000..08329d6793 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/util/LoAUtil.java @@ -0,0 +1,122 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.keycloak.authentication.authenticators.util; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.jboss.logging.Logger; +import org.keycloak.authentication.AuthenticatorUtil; +import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticator; +import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory; +import org.keycloak.models.AuthenticatedClientSessionModel; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.Constants; +import org.keycloak.models.RealmModel; + +/** + * @author Marek Posolda + */ +public class LoAUtil { + + private static final Logger logger = Logger.getLogger(LoAUtil.class); + + /** + * @param clientSession + * @return current level from client session + */ + public static int getCurrentLevelOfAuthentication(AuthenticatedClientSessionModel clientSession) { + String clientSessionLoaNote = clientSession.getNote(Constants.LEVEL_OF_AUTHENTICATION); + return clientSessionLoaNote == null ? Constants.NO_LOA : Integer.parseInt(clientSessionLoaNote); + } + + + /** + * @param realm + * @return All LoA numbers configured in the conditions in the realm browser flow + */ + public static Stream getLoAConfiguredInRealmBrowserFlow(RealmModel realm) { + Map loaMaxAges = getLoaMaxAgesConfiguredInRealmBrowserFlow(realm); + if (loaMaxAges.isEmpty()) { + // Default values used when step-up conditions not used in the browser authentication flow. + // This is used for backwards compatibility and in case when step-up is not configured in the authentication flow (returning 1 in case of "normal" authentication, 0 for SSO authentication) + return Stream.of(Constants.MINIMUM_LOA, 1); + } else { + // Add 0 as a level used for SSO cookie + return Stream.concat(Stream.of(Constants.MINIMUM_LOA), loaMaxAges.keySet().stream()); + } + } + + + /** + * @param realm + * @return All LoA numbers configured in the conditions in the realm browser flow. Key is level, Vaue is maxAge for particular level + */ + public static Map getLoaMaxAgesConfiguredInRealmBrowserFlow(RealmModel realm) { + List loaConditions = AuthenticatorUtil.getExecutionsByType(realm, realm.getBrowserFlow().getId(), ConditionalLoaAuthenticatorFactory.PROVIDER_ID); + if (loaConditions.isEmpty()) { + // Default values used when step-up conditions not used in the browser authentication flow. + // This is used for backwards compatibility and in case when step-up is not configured in the authentication flow (returning 1 in case of "normal" authentication, 0 for SSO authentication) + return Collections.emptyMap(); + } else { + Map loas = loaConditions.stream() + .map(authExecution -> realm.getAuthenticatorConfigById(authExecution.getAuthenticatorConfig())) + .filter(Objects::nonNull) + .filter(authConfig -> getLevelFromLoaConditionConfiguration(authConfig) != null) + .collect(Collectors.toMap(LoAUtil::getLevelFromLoaConditionConfiguration, LoAUtil::getMaxAgeFromLoaConditionConfiguration)); + return loas; + } + } + + + public static Integer getLevelFromLoaConditionConfiguration(AuthenticatorConfigModel loaConditionConfig) { + String levelAsStr = loaConditionConfig.getConfig().get(ConditionalLoaAuthenticator.LEVEL); + try { + // Check it can be cast to number + return Integer.parseInt(levelAsStr); + } catch (NullPointerException | NumberFormatException e) { + logger.warnf("Invalid level '%s' configured for the configuration of LoA condition with alias '%s'. Level should be number.", levelAsStr, loaConditionConfig.getAlias()); + return null; + } + } + + + public static int getMaxAgeFromLoaConditionConfiguration(AuthenticatorConfigModel loaConditionConfig) { + try { + return Integer.parseInt(loaConditionConfig.getConfig().get(ConditionalLoaAuthenticator.MAX_AGE)); + } catch (NullPointerException | NumberFormatException e) { + // Backwards compatibility with Keycloak 17 + String storeLoaInUserSession = loaConditionConfig.getConfig().get(ConditionalLoaAuthenticator.STORE_IN_USER_SESSION); + if (storeLoaInUserSession != null) { + int maxAge = Boolean.parseBoolean(storeLoaInUserSession) ? ConditionalLoaAuthenticator.DEFAULT_MAX_AGE : 0; + logger.warnf("Max age not configured for condition '%s' in the authentication flow. Fallback to %d based on the configuration option %s from previous version", + loaConditionConfig.getAlias(), maxAge, ConditionalLoaAuthenticator.STORE_IN_USER_SESSION); + return maxAge; + } + + logger.errorf("Invalid max age configured for condition '%s'. Fallback to 0", loaConditionConfig.getAlias()); + return 0; + } + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java index 73ee936ead..7fd982b294 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java @@ -19,9 +19,9 @@ package org.keycloak.protocol.oidc; import com.google.common.collect.Streams; import org.keycloak.OAuth2Constants; -import org.keycloak.authentication.AuthenticatorUtil; import org.keycloak.authentication.ClientAuthenticator; import org.keycloak.authentication.ClientAuthenticatorFactory; +import org.keycloak.authentication.authenticators.util.LoAUtil; import org.keycloak.crypto.CekManagementProvider; import org.keycloak.crypto.ClientSignatureVerifierProvider; import org.keycloak.crypto.ContentEncryptionProvider; @@ -29,7 +29,6 @@ import org.keycloak.crypto.SignatureProvider; import org.keycloak.jose.jws.Algorithm; import org.keycloak.models.CibaConfig; import org.keycloak.models.ClientScopeModel; -import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint; @@ -63,7 +62,6 @@ import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -264,7 +262,7 @@ public class OIDCWellKnownProvider implements WellKnownProvider { List result = new ArrayList<>(realmAcrLoaMap.keySet()); // Add LoA levels configured in authentication flow in addition to the realm values - result.addAll(AuthenticatorUtil.getLoAConfiguredInRealmBrowserFlow(realm) + result.addAll(LoAUtil.getLoAConfiguredInRealmBrowserFlow(realm) .map(String::valueOf) .collect(Collectors.toList())); return result; diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java index 8fb54569af..09c8d79928 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -24,7 +24,8 @@ import org.keycloak.OAuth2Constants; import org.keycloak.OAuthErrorException; import org.keycloak.TokenCategory; import org.keycloak.TokenVerifier; -import org.keycloak.authentication.AuthenticatorUtil; +import org.keycloak.authentication.authenticators.util.AcrStore; +import org.keycloak.authentication.authenticators.util.LoAUtil; import org.keycloak.broker.oidc.OIDCIdentityProvider; import org.keycloak.broker.provider.IdentityBrokerException; import org.keycloak.cluster.ClusterProvider; @@ -565,7 +566,7 @@ public class TokenManager { userSession.setNote(entry.getKey(), entry.getValue()); } - clientSession.setNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(AuthenticatorUtil.getCurrentLevelOfAuthentication(authSession))); + clientSession.setNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(new AcrStore(authSession).getLevelOfAuthenticationFromCurrentAuthentication())); clientSession.setTimestamp(Time.currentTime()); // Remove authentication session now @@ -901,7 +902,7 @@ public class TokenManager { } private String getAcr(AuthenticatedClientSessionModel clientSession) { - int loa = AuthenticatorUtil.getCurrentLevelOfAuthentication(clientSession); + int loa = LoAUtil.getCurrentLevelOfAuthentication(clientSession); if (loa < Constants.MINIMUM_LOA) { loa = AuthenticationManager.isSSOAuthentication(clientSession) ? 0 : 1; } @@ -920,6 +921,8 @@ public class TokenManager { } } } + + logger.tracef("Level sent in the token to client %s: %s. Original loa from the authentication: %d", clientSession.getClient().getClientId(), acr, loa); return acr; } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/utils/AcrUtils.java b/services/src/main/java/org/keycloak/protocol/oidc/utils/AcrUtils.java index f0ac43034a..cafebb9ff1 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/utils/AcrUtils.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/utils/AcrUtils.java @@ -127,7 +127,7 @@ public class AcrUtils { public static String mapLoaToAcr(int loa, Map acrLoaMap, Collection acrValues) { String acr = null; if (!acrLoaMap.isEmpty() && !acrValues.isEmpty()) { - int maxLoa = 0; + int maxLoa = -1; for (String acrValue : acrValues) { Integer mappedLoa = acrLoaMap.get(acrValue); // if there is no mapping for the acrValue, it may be an integer itself 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 c555e0934d..f2878a4dab 100755 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java @@ -143,6 +143,9 @@ public class AuthenticationManager { // clientSession note with flag that clientSession was authenticated through SSO cookie public static final String SSO_AUTH = "SSO_AUTH"; + // authSession note with flag that is true if user is forced to re-authenticate by client (EG. in case of OIDC client by sending "prompt=login") + public static final String FORCED_REAUTHENTICATION = "FORCED_REAUTHENTICATION"; + protected static final Logger logger = Logger.getLogger(AuthenticationManager.class); public static final String FORM_USERNAME = "username"; diff --git a/services/src/main/java/org/keycloak/validation/DefaultClientValidationProvider.java b/services/src/main/java/org/keycloak/validation/DefaultClientValidationProvider.java index 0ee9b11385..e975e4c5d7 100644 --- a/services/src/main/java/org/keycloak/validation/DefaultClientValidationProvider.java +++ b/services/src/main/java/org/keycloak/validation/DefaultClientValidationProvider.java @@ -17,6 +17,7 @@ package org.keycloak.validation; import org.keycloak.authentication.AuthenticatorUtil; +import org.keycloak.authentication.authenticators.util.LoAUtil; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; import org.keycloak.protocol.ProtocolMapperConfigException; @@ -279,7 +280,7 @@ public class DefaultClientValidationProvider implements ClientValidationProvider } for (String configuredAcr : defaultAcrValues) { if (acrToLoaMap.containsKey(configuredAcr)) continue; - if (!AuthenticatorUtil.getLoAConfiguredInRealmBrowserFlow(client.getRealm()) + if (!LoAUtil.getLoAConfiguredInRealmBrowserFlow(client.getRealm()) .anyMatch(level -> configuredAcr.equals(String.valueOf(level)))) { context.addError("defaultAcrValues", "Default ACR values need to contain values specified in the ACR-To-Loa mapping or number levels from set realm browser flow"); } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java index 9e08d85173..3adf68caac 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java @@ -166,6 +166,8 @@ public class OAuthClient { private String maxAge; + private String prompt; + private String responseType; private String responseMode; @@ -255,6 +257,7 @@ public class OAuthClient { clientSessionState = null; clientSessionHost = null; maxAge = null; + prompt = null; responseType = OAuth2Constants.CODE; responseMode = null; nonce = null; @@ -1133,6 +1136,9 @@ public class OAuthClient { if (maxAge != null) { parameters.add(new BasicNameValuePair(OIDCLoginProtocol.MAX_AGE_PARAM, maxAge)); } + if (prompt != null) { + parameters.add(new BasicNameValuePair(OIDCLoginProtocol.PROMPT_PARAM, prompt)); + } if (request != null) { parameters.add(new BasicNameValuePair(OIDCLoginProtocol.REQUEST_PARAM, request)); } @@ -1418,6 +1424,9 @@ public class OAuthClient { if (maxAge != null) { b.queryParam(OIDCLoginProtocol.MAX_AGE_PARAM, maxAge); } + if (prompt != null) { + b.queryParam(OIDCLoginProtocol.PROMPT_PARAM, prompt); + } if (request != null) { b.queryParam(OIDCLoginProtocol.REQUEST_PARAM, request); } @@ -1622,6 +1631,11 @@ public class OAuthClient { return this; } + public OAuthClient prompt(String prompt) { + this.prompt = prompt; + return this; + } + public OAuthClient responseType(String responseType) { this.responseType = responseType; return this; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/AuthenticationFlowCallbackProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/AuthenticationFlowCallbackProviderTest.java index f3fed5c3cf..f023661b0d 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/AuthenticationFlowCallbackProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/AuthenticationFlowCallbackProviderTest.java @@ -93,7 +93,7 @@ public class AuthenticationFlowCallbackProviderTest extends AbstractTestRealmKey .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID, config -> { config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "1"); - config.getConfig().put(ConditionalLoaAuthenticator.STORE_IN_USER_SESSION, "true"); + config.getConfig().put(ConditionalLoaAuthenticator.MAX_AGE, String.valueOf(ConditionalLoaAuthenticator.DEFAULT_MAX_AGE)); }) .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, AllowAccessAuthenticatorFactory.PROVIDER_ID) ) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LevelOfAssuranceFlowTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LevelOfAssuranceFlowTest.java index 5158397965..8968cb14df 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LevelOfAssuranceFlowTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LevelOfAssuranceFlowTest.java @@ -32,19 +32,22 @@ import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.keycloak.admin.client.resource.ClientResource; -import org.keycloak.authentication.authenticators.browser.PasswordFormFactory; -import org.keycloak.authentication.authenticators.browser.UsernameFormFactory; +import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticatorFactory; +import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory; import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticator; import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory; import org.keycloak.events.Details; import org.keycloak.models.AuthenticationExecutionModel.Requirement; import org.keycloak.models.Constants; +import org.keycloak.models.utils.TimeBasedOTP; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.representations.ClaimsRepresentation; import org.keycloak.representations.IDToken; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.admin.ApiUtil; @@ -52,11 +55,14 @@ import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; import org.keycloak.testsuite.authentication.PushButtonAuthenticatorFactory; import org.keycloak.testsuite.client.KeycloakTestingClient; import org.keycloak.testsuite.pages.ErrorPage; -import org.keycloak.testsuite.pages.LoginUsernameOnlyPage; -import org.keycloak.testsuite.pages.PasswordPage; +import org.keycloak.testsuite.pages.LoginPage; +import org.keycloak.testsuite.pages.LoginTotpPage; import org.keycloak.testsuite.pages.PushTheButtonPage; import org.keycloak.testsuite.util.FlowUtil; import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.testsuite.util.RealmRepUtil; +import org.keycloak.testsuite.util.UserBuilder; +import org.keycloak.testsuite.util.WaitUtils; import org.keycloak.util.JsonSerialization; import static org.hamcrest.CoreMatchers.is; @@ -74,10 +80,12 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { public AssertEvents events = new AssertEvents(this); @Page - protected LoginUsernameOnlyPage loginUsernameOnlyPage; + protected LoginPage loginPage; @Page - protected PasswordPage passwordPage; + protected LoginTotpPage loginTotpPage; + + private TimeBasedOTP totp = new TimeBasedOTP(); @Page protected PushTheButtonPage pushTheButtonPage; @@ -89,6 +97,10 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { public void configureTestRealm(RealmRepresentation testRealm) { try { findTestApp(testRealm).setAttributes(Collections.singletonMap(Constants.ACR_LOA_MAP, getAcrToLoaMappingForClient())); + UserRepresentation user = RealmRepUtil.findUser(testRealm, "test-user@localhost"); + UserBuilder.edit(user) + .totpSecret("totpSecret") + .otpEnabled(); } catch (IOException e) { throw new RuntimeException(e); } @@ -108,6 +120,10 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { } public static void configureStepUpFlow(KeycloakTestingClient testingClient) { + configureStepUpFlow(testingClient, ConditionalLoaAuthenticator.DEFAULT_MAX_AGE, 0, 0); + } + + private static void configureStepUpFlow(KeycloakTestingClient testingClient, int maxAge1, int maxAge2, int maxAge3) { final String newFlowAlias = "browser - Level of Authentication FLow"; testingClient.server(TEST_REALM_NAME).run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(newFlowAlias)); testingClient.server(TEST_REALM_NAME) @@ -117,26 +133,32 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID, config -> { config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "1"); - config.getConfig().put(ConditionalLoaAuthenticator.STORE_IN_USER_SESSION, "true"); + config.getConfig().put(ConditionalLoaAuthenticator.MAX_AGE, String.valueOf(maxAge1)); }); // username input for level 1 - subFlow.addAuthenticatorExecution(Requirement.REQUIRED, UsernameFormFactory.PROVIDER_ID); + subFlow.addAuthenticatorExecution(Requirement.REQUIRED, UsernamePasswordFormFactory.PROVIDER_ID); }) // level 2 authentication .addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> { subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID, - config -> config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "2")); + config -> { + config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "2"); + config.getConfig().put(ConditionalLoaAuthenticator.MAX_AGE, String.valueOf(maxAge2)); + }); // password required for level 2 - subFlow.addAuthenticatorExecution(Requirement.REQUIRED, PasswordFormFactory.PROVIDER_ID); + subFlow.addAuthenticatorExecution(Requirement.REQUIRED, OTPFormAuthenticatorFactory.PROVIDER_ID); }) // level 3 authentication .addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> { subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID, - config -> config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "3")); + config -> { + config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "3"); + config.getConfig().put(ConditionalLoaAuthenticator.MAX_AGE, String.valueOf(maxAge3)); + }); // simply push button for level 3 subFlow.addAuthenticatorExecution(Requirement.REQUIRED, PushButtonAuthenticatorFactory.PROVIDER_ID); @@ -145,6 +167,11 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { ).defineAsBrowserFlow()); } + private void reconfigureStepUpFlow(int maxAge1, int maxAge2, int maxAge3) { + BrowserFlowTest.revertFlows(testRealm(), "browser - Level of Authentication FLow"); + configureStepUpFlow(testingClient, maxAge1, maxAge2, maxAge3); + } + @After public void after() { BrowserFlowTest.revertFlows(testRealm(), "browser - Level of Authentication FLow"); @@ -154,7 +181,7 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { public void loginWithoutAcr() { oauth.openLoginForm(); // Authentication without specific LOA results in level 1 authentication - authenticateWithUsername(); + authenticateWithUsernamePassword(); assertLoggedInWithAcr("silver"); } @@ -162,7 +189,7 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { public void loginWithAcr1() { // username input for level 1 openLoginFormWithAcrClaim(true, "silver"); - authenticateWithUsername(); + authenticateWithUsernamePassword(); assertLoggedInWithAcr("silver"); } @@ -171,8 +198,8 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { public void loginWithAcr2() { // username and password input for level 2 openLoginFormWithAcrClaim(true, "gold"); - authenticateWithUsername(); - authenticateWithPassword(); + authenticateWithUsernamePassword(); + authenticateWithTotp(); assertLoggedInWithAcr("gold"); } @@ -180,8 +207,8 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { public void loginWithAcr3() { // username, password input and finally push button for level 3 openLoginFormWithAcrClaim(true, "3"); - authenticateWithUsername(); - authenticateWithPassword(); + authenticateWithUsernamePassword(); + authenticateWithTotp(); authenticateWithButton(); // ACR 3 is returned because it was requested, although there is no mapping for it assertLoggedInWithAcr("3"); @@ -191,15 +218,15 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { public void stepupAuthentication() { // logging in to level 1 openLoginFormWithAcrClaim(true, "silver"); - authenticateWithUsername(); + authenticateWithUsernamePassword(); assertLoggedInWithAcr("silver"); // doing step-up authentication to level 2 openLoginFormWithAcrClaim(true, "gold"); - authenticateWithPassword(); + authenticateWithTotp(); assertLoggedInWithAcr("gold"); // step-up to level 3 needs password authentication because level 2 is not stored in user session openLoginFormWithAcrClaim(true, "3"); - authenticateWithPassword(); + authenticateWithTotp(); authenticateWithButton(); assertLoggedInWithAcr("3"); } @@ -207,7 +234,7 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { @Test public void stepupToUnknownEssentialAcrFails() { openLoginFormWithAcrClaim(true, "silver"); - authenticateWithUsername(); + authenticateWithUsernamePassword(); assertLoggedInWithAcr("silver"); // step-up to unknown acr openLoginFormWithAcrClaim(true, "uranium"); @@ -217,36 +244,36 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { @Test public void reauthenticationWithNoAcr() { openLoginFormWithAcrClaim(true, "silver"); - authenticateWithUsername(); + authenticateWithUsernamePassword(); assertLoggedInWithAcr("silver"); oauth.claims(null); oauth.openLoginForm(); - assertLoggedInWithAcr("0"); + assertLoggedInWithAcr("silver"); // Return silver without need to re-authenticate due maxAge for "silver" condition did not timed-out yet } @Test public void reauthenticationWithReachedAcr() { openLoginFormWithAcrClaim(true, "silver"); - authenticateWithUsername(); + authenticateWithUsernamePassword(); assertLoggedInWithAcr("silver"); openLoginFormWithAcrClaim(true, "silver"); - assertLoggedInWithAcr("0"); + assertLoggedInWithAcr("silver"); // Return previous level due maxAge for "silver" condition did not timed-out yet } @Test public void reauthenticationWithOptionalUnknownAcr() { openLoginFormWithAcrClaim(true, "silver"); - authenticateWithUsername(); + authenticateWithUsernamePassword(); assertLoggedInWithAcr("silver"); openLoginFormWithAcrClaim(false, "iron"); - assertLoggedInWithAcr("0"); + assertLoggedInWithAcr("silver"); // Return silver without need to re-authenticate due maxAge for "silver" condition did not timed-out yet } @Test public void essentialClaimNotReachedFails() { openLoginFormWithAcrClaim(true, "4"); - authenticateWithUsername(); - authenticateWithPassword(); + authenticateWithUsernamePassword(); + authenticateWithTotp(); authenticateWithButton(); assertErrorPage("Authentication requirements not fulfilled"); } @@ -254,8 +281,8 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { @Test public void optionalClaimNotReachedSucceeds() { openLoginFormWithAcrClaim(false, "4"); - authenticateWithUsername(); - authenticateWithPassword(); + authenticateWithUsernamePassword(); + authenticateWithTotp(); authenticateWithButton(); // the reached loa is 3, but there is no mapping for it, and it was not explicitly // requested, so the highest known and reached ACR is returned @@ -271,7 +298,7 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { @Test public void optionalUnknownClaimSucceeds() { openLoginFormWithAcrClaim(false, "iron"); - authenticateWithUsername(); + authenticateWithUsernamePassword(); assertLoggedInWithAcr("silver"); } @@ -280,24 +307,24 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { driver.navigate().to(UriBuilder.fromUri(oauth.getLoginFormUrl()) .queryParam("acr_values", "gold 3") .build().toString()); - authenticateWithUsername(); - authenticateWithPassword(); + authenticateWithUsernamePassword(); + authenticateWithTotp(); assertLoggedInWithAcr("gold"); } @Test public void multipleEssentialAcrValues() { openLoginFormWithAcrClaim(true, "gold", "3"); - authenticateWithUsername(); - authenticateWithPassword(); + authenticateWithUsernamePassword(); + authenticateWithTotp(); assertLoggedInWithAcr("gold"); } @Test public void multipleOptionalAcrValues() { openLoginFormWithAcrClaim(false, "gold", "3"); - authenticateWithUsername(); - authenticateWithPassword(); + authenticateWithUsernamePassword(); + authenticateWithTotp(); assertLoggedInWithAcr("gold"); } @@ -320,8 +347,8 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { testClient.update(testClientRep); openLoginFormWithAcrClaim(true, "realm:gold"); - authenticateWithUsername(); - authenticateWithPassword(); + authenticateWithUsernamePassword(); + authenticateWithTotp(); assertLoggedInWithAcr("realm:gold"); // Add "acr-to-loa" back to the client. Client mapping will be used instead of realm mapping @@ -332,7 +359,7 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { assertErrorPage("Invalid parameter: claims"); openLoginFormWithAcrClaim(true, "gold"); - authenticateWithPassword(); + authenticateWithTotp(); assertLoggedInWithAcr("gold"); // Rollback @@ -349,23 +376,23 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { // Should request client to authenticate with silver oauth.openLoginForm(); - authenticateWithUsername(); + authenticateWithUsernamePassword(); assertLoggedInWithAcr("silver"); // Re-configure to level gold OIDCAdvancedConfigWrapper.fromClientRepresentation(testClientRep).setAttributeMultivalued(Constants.DEFAULT_ACR_VALUES, Arrays.asList("gold")); testClient.update(testClientRep); oauth.openLoginForm(); - authenticateWithPassword(); + authenticateWithTotp(); assertLoggedInWithAcr("gold"); - // Value from essential ACR should have preference + // Value from essential ACR from claims parameter should have preference over the client default openLoginFormWithAcrClaim(true, "silver"); - assertLoggedInWithAcr("0"); + assertLoggedInWithAcr("silver"); - // Value from non-essential ACR should have preference + // Value from non-essential ACR from claims parameter should have preference over the client default openLoginFormWithAcrClaim(false, "silver"); - assertLoggedInWithAcr("0"); + assertLoggedInWithAcr("silver"); // Revert testClientRep.getAttributes().put(Constants.DEFAULT_ACR_VALUES, null); @@ -413,6 +440,183 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { testRealm().update(realmRep); } + // After initial authentication with "acr=2", there will be further re-authentication requests sent in different intervals + // without "acr" parameter. User should be always re-authenticated due SSO, but with different acr levels due their gradual expirations + @Test + public void testMaxAgeConditionWithSSO() { + reconfigureStepUpFlow(300, 300, 200); + + // Authentication + openLoginFormWithAcrClaim(true, "3"); + authenticateWithUsernamePassword(); + authenticateWithTotp(); + authenticateWithButton(); + assertLoggedInWithAcr("3"); + + // Re-auth 1: Should be automatically authenticated and still return "3" + oauth.claims(null); + oauth.openLoginForm(); + assertLoggedInWithAcr("3"); + + // Time offset to 210 + setTimeOffset(210); + + // Re-auth 2: Should return level 2 (gold) due level 3 expired + oauth.openLoginForm(); + assertLoggedInWithAcr("gold"); + + // Time offset to 310 + setTimeOffset(310); + + // Re-auth 3: Should return level 0 (copper) due levels 1 and 2 expired + oauth.openLoginForm(); + assertLoggedInWithAcr("copper"); + } + + // After initial authentication with "acr=2", there will be further re-authentication requests sent in different intervals + // asking for "acr=2" . User should be asked for re-authentication with various authenticators in various cases. + @Test + public void testMaxAgeConditionWithAcr() { + reconfigureStepUpFlow(300, 200, 200); + + // Authentication + openLoginFormWithAcrClaim(true, "3"); + authenticateWithUsernamePassword(); + authenticateWithTotp(); + authenticateWithButton(); + assertLoggedInWithAcr("3"); + + // Re-auth 1: Should be automatically authenticated and still return "3" + openLoginFormWithAcrClaim(true, "3"); + assertLoggedInWithAcr("3"); + + // Time offset to 210 + setTimeOffset(210); + + // Re-auth 2: Should ask user for re-authentication with level2 and level3. Level1 did not yet expired and should be automatic + openLoginFormWithAcrClaim(true, "3"); + authenticateWithTotp(); + authenticateWithButton(); + assertLoggedInWithAcr("3"); + + // Time offset to 310 + setTimeOffset(310); + + // Re-auth 3: Should ask user for re-authentication with level1. Level2 and Level3 did not yet expired and should be automatic + openLoginFormWithAcrClaim(true, "3"); + reauthenticateWithPassword(); + assertLoggedInWithAcr("3"); + } + + // Authenticate with LoA=3 and then send request with "prompt=login" to force re-authentication + @Test + public void testMaxAgeConditionWithForcedReauthentication() { + reconfigureStepUpFlow(300, 300, 300); + + // Authentication + openLoginFormWithAcrClaim(true, "3"); + authenticateWithUsernamePassword(); + authenticateWithTotp(); + authenticateWithButton(); + assertLoggedInWithAcr("3"); + + // Send request with prompt=login . User should be asked to re-authenticate with level 1 + oauth.claims(null); + oauth.prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN); + oauth.openLoginForm(); + reauthenticateWithPassword(); + assertLoggedInWithAcr("silver"); + + // Request with prompt=login together with "acr=2" . User should be asked to re-authenticate with level 2 + openLoginFormWithAcrClaim(true, "gold"); + reauthenticateWithPassword(); + authenticateWithTotp(); + assertLoggedInWithAcr("gold"); + + // Request with "acr=3", but without prompt. User should be automatically authenticated + oauth.prompt(null); + openLoginFormWithAcrClaim(true, "3"); + assertLoggedInWithAcr("3"); + } + + + @Test + public void testChangingLoaConditionConfiguration() { + // Authentication + oauth.openLoginForm(); + authenticateWithUsernamePassword(); + assertLoggedInWithAcr("silver"); + + setTimeOffset(120); + + + // Change condition configuration to 60 + reconfigureStepUpFlow(60, 0, 0); + + // Re-authenticate without "acr". Should return 0 (copper) due the SSO. Level "silver" should not be returned due it is expired + // based on latest condition configuration + oauth.openLoginForm(); + assertLoggedInWithAcr("copper"); + + // Re-authenticate with requested ACR=1 (silver). User should be asked to re-authenticate + openLoginFormWithAcrClaim(true, "silver"); + reauthenticateWithPassword(); + assertLoggedInWithAcr("silver"); + } + + + // Backwards compatibility with Keycloak 17 when condition was configured with option "Store Loa in User Session" + @Test + public void testBackwardsCompatibilityForLoaConditionConfig() { + // Reconfigure to the format of Keycloak 17 with option "Store Loa in User Session" + BrowserFlowTest.revertFlows(testRealm(), "browser - Level of Authentication FLow"); + final String newFlowAlias = "browser - Level of Authentication FLow"; + testingClient.server(TEST_REALM_NAME).run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(newFlowAlias)); + testingClient.server(TEST_REALM_NAME) + .run(session -> FlowUtil.inCurrentRealm(session).selectFlow(newFlowAlias).inForms(forms -> forms.clear() + // level 1 authentication + .addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> { + subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID, + config -> { + config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "1"); + config.getConfig().put(ConditionalLoaAuthenticator.STORE_IN_USER_SESSION, "true"); + }); + + // username input for level 1 + subFlow.addAuthenticatorExecution(Requirement.REQUIRED, UsernamePasswordFormFactory.PROVIDER_ID); + }) + + // level 2 authentication + .addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> { + subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID, + config -> { + config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "2"); + config.getConfig().put(ConditionalLoaAuthenticator.STORE_IN_USER_SESSION, "false"); + }); + + // password required for level 2 + subFlow.addAuthenticatorExecution(Requirement.REQUIRED, OTPFormAuthenticatorFactory.PROVIDER_ID); + }) + + // level 3 authentication + .addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> { + subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID, + config -> { + config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "3"); + config.getConfig().put(ConditionalLoaAuthenticator.STORE_IN_USER_SESSION, "false"); + }); + + // simply push button for level 3 + subFlow.addAuthenticatorExecution(Requirement.REQUIRED, PushButtonAuthenticatorFactory.PROVIDER_ID); + }) + + ).defineAsBrowserFlow()); + + // Tests that re-authentication always needed for levels 2 and 3 + stepupAuthentication(); + } + + public void openLoginFormWithAcrClaim(boolean essential, String... acrValues) { openLoginFormWithAcrClaim(oauth, essential, acrValues); } @@ -429,14 +633,20 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { oauth.openLoginForm(); } - private void authenticateWithUsername() { - loginUsernameOnlyPage.assertCurrent(); - loginUsernameOnlyPage.login("test-user@localhost"); + private void authenticateWithUsernamePassword() { + loginPage.assertCurrent(); + loginPage.login("test-user@localhost", "password"); } - private void authenticateWithPassword() { - passwordPage.assertCurrent(); - passwordPage.login("password"); + private void reauthenticateWithPassword() { + loginPage.assertCurrent(); + Assert.assertEquals("test-user@localhost", loginPage.getAttemptedUsername()); + loginPage.login("password"); + } + + private void authenticateWithTotp() { + loginTotpPage.assertCurrent(); + loginTotpPage.login(totp.generateTOTP("totpSecret")); } private void authenticateWithButton() { diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index 5fac952c75..6331ec7866 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -1349,10 +1349,10 @@ otp-supported-applications=Supported Applications otp-supported-applications.tooltip=Applications that are known to work with the current OTP policy loa-level=Level of Authentication loa-level.tooltip=Sets the Level of Authentication to the specified value. -loa-store-in-user-session=Store LoA in user session -loa-store-in-user-session.tooltip=Additionally stores the LoA in the user session. If true, it means that subsequent authentications in same browser will see this level and they won't repeat authenticating the user with this level. If this is false, it means that subsequent authentications won't see this level and hence they will repeat authentications for this level again. This is useful if some functionality in the application (e.g. send payment) always require authentication with the particular level. +loa-max-age=Max Age +loa-max-age.tooltip=Maximum age in seconds for which this level is considered valid after successful authentication. For example if this is set to 300 and user authenticated with this level and then tries to authenticate again in less than 300 second, then this level will be automatically considered as authenticated without need of user to re-authenticate. If it is set to 0, then the authenticated level is valid just for this authentication and next authentication will always need to re-authenticate. Default value is 10 hours, which is same as default SSO session timeout and it means that level is valid until end of SSO session and user doesn't need to re-authenticate. loa-condition-level=Level of Authentication (LoA) -loa-condition-level.tooltip=The condition evaluates to true if user does not yet have this authentication level and this level is requested. This level of authentication will be set to the session after the subflow, where this condition is configured, is successfully finished. +loa-condition-level.tooltip=The number value, usually 1 or bigger, which specifies level of authentication. Condition evaluates to true if user does not yet have this authentication level and this level is requested. This level of authentication will be set to the session after the subflow, where this condition is configured, is successfully finished. table-of-password-policies=Table of Password Policies add-policy.placeholder=Add policy... policy-type=Policy Type