diff --git a/docs/documentation/server_admin/topics/authentication/password-policies.adoc b/docs/documentation/server_admin/topics/authentication/password-policies.adoc index 8f96a5009d..c6fe1aecef 100644 --- a/docs/documentation/server_admin/topics/authentication/password-policies.adoc +++ b/docs/documentation/server_admin/topics/authentication/password-policies.adoc @@ -96,3 +96,8 @@ The current implementation uses a BloomFilter for fast and memory efficient cont * By default a false positive probability of `0.01%` is used. * To change the false positive probability by CLI configuration, use `--spi-password-policy-password-blacklist-false-positive-probability=0.00001`. + +===== Maximum Authentication Age + +Specifies the maximum age of a user authentication in seconds with which the user can update a password without re-authentication. A value of `0` indicates that the user has to always re-authenticate with their current password before they can update the password. + diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 3580dc9627..82db8e695b 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -2078,6 +2078,7 @@ enabled=Enabled forgotPassword=Forgot password searchUserByAttributeMissingValueError=Specify a attribute value passwordPoliciesHelp.maxLength=The maximum number of characters allowed in the password. +passwordPoliciesHelp.maxAuthAge=The maximum age of an authentication with which a password may be changed without re-authentication. moveGroupError=Could not move group {{error}} clientImportSuccess=Client imported successfully dragHelp=Press space or enter to begin dragging, and use the arrow keys to navigate up or down. Press enter to confirm the drag, or any other key to cancel the drag operation. diff --git a/server-spi-private/src/main/java/org/keycloak/policy/MaxAuthAgePasswordPolicyProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/policy/MaxAuthAgePasswordPolicyProviderFactory.java new file mode 100644 index 0000000000..30dca87c3b --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/policy/MaxAuthAgePasswordPolicyProviderFactory.java @@ -0,0 +1,93 @@ +/* + * Copyright 2023 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.policy; + +import org.keycloak.Config; +import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.PasswordPolicy; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; + +/** + * Specifies the maximum age of an authentication with which a password may be changed without re-authentication. + */ +public class MaxAuthAgePasswordPolicyProviderFactory implements PasswordPolicyProvider, PasswordPolicyProviderFactory { + + public static final int DEFAULT_MAX_AUTH_AGE = Constants.KC_ACTION_MAX_AGE; + + @Override + public PasswordPolicyProvider create(KeycloakSession session) { + return this; + } + + @Override + public void init(Config.Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public String getId() { + return PasswordPolicy.MAX_AUTH_AGE_ID; + } + + @Override + public PolicyError validate(RealmModel realm, UserModel user, String password) { + return null; + } + + @Override + public PolicyError validate(String user, String password) { + return null; + } + + @Override + public Object parseConfig(String value) { + return parseInteger(value, -1); + } + + @Override + public String getDisplayName() { + return "Maximum Authentication Age"; + } + + @Override + public String getConfigType() { + return PasswordPolicyProvider.INT_CONFIG_TYPE; + } + + @Override + public String getDefaultConfigValue() { + return String.valueOf(DEFAULT_MAX_AUTH_AGE); + } + + @Override + public boolean isMultiplSupported() { + return false; + } + + @Override + public void close() { + } + +} + diff --git a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.policy.PasswordPolicyProviderFactory b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.policy.PasswordPolicyProviderFactory index fd6da8459a..f9b9167f03 100644 --- a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.policy.PasswordPolicyProviderFactory +++ b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.policy.PasswordPolicyProviderFactory @@ -30,3 +30,4 @@ org.keycloak.policy.UpperCasePasswordPolicyProviderFactory org.keycloak.policy.BlacklistPasswordPolicyProviderFactory org.keycloak.policy.NotEmailPasswordPolicyProviderFactory org.keycloak.policy.RecoveryCodesWarningThresholdPasswordPolicyProviderFactory +org.keycloak.policy.MaxAuthAgePasswordPolicyProviderFactory diff --git a/server-spi/src/main/java/org/keycloak/models/PasswordPolicy.java b/server-spi/src/main/java/org/keycloak/models/PasswordPolicy.java index 0a22e3983c..e9e62afe81 100755 --- a/server-spi/src/main/java/org/keycloak/models/PasswordPolicy.java +++ b/server-spi/src/main/java/org/keycloak/models/PasswordPolicy.java @@ -48,6 +48,8 @@ public class PasswordPolicy implements Serializable { public static final String RECOVERY_CODES_WARNING_THRESHOLD_ID = "recoveryCodesWarningThreshold"; + public static final String MAX_AUTH_AGE_ID = "maxAuthAge"; + private Map policyConfig; private Builder builder; @@ -116,6 +118,26 @@ public class PasswordPolicy implements Serializable { } } + /** + * Policy to configure the maximum age of the authentication in seconds. + * + * If the user authentication is older than the given value, a reauthentication is enforced. + * + * Examples: + * + * @return + */ + public int getMaxAuthAge() { + if (policyConfig.containsKey(MAX_AUTH_AGE_ID)) { + return getPolicyConfig(MAX_AUTH_AGE_ID); + } else { + return -1; + } + } + @Override public String toString() { return builder.asString(); diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java index e3403a9ea2..92340e9fcc 100755 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java @@ -35,6 +35,7 @@ import org.keycloak.models.ModelException; import org.keycloak.models.UserCredentialModel; import org.keycloak.models.UserModel; import org.keycloak.models.utils.FormMessage; +import org.keycloak.policy.MaxAuthAgePasswordPolicyProviderFactory; import org.keycloak.services.messages.Messages; import org.keycloak.services.validation.Validation; import org.keycloak.sessions.AuthenticationSessionModel; @@ -49,11 +50,24 @@ import java.util.concurrent.TimeUnit; */ public class UpdatePassword implements RequiredActionProvider, RequiredActionFactory { private static final Logger logger = Logger.getLogger(UpdatePassword.class); - + private final KeycloakSession session; + @Override public InitiatedActionSupport initiatedActionSupport() { return InitiatedActionSupport.SUPPORTED; } + + /** + * @deprecated use {@link #UpdatePassword(KeycloakSession)} instead + */ + @Deprecated + public UpdatePassword() { + this(null); + } + + public UpdatePassword(KeycloakSession session) { + this.session = session; + } @Override public void evaluateTriggers(RequiredActionContext context) { @@ -151,7 +165,7 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac @Override public RequiredActionProvider create(KeycloakSession session) { - return this; + return new UpdatePassword(session); } @Override @@ -179,4 +193,21 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac public boolean isOneTimeAction() { return true; } + + @Override + public int getMaxAuthAge() { + + if (session == null) { + // session is null, support for legacy implementation, fallback to default maxAuthAge + return MaxAuthAgePasswordPolicyProviderFactory.DEFAULT_MAX_AUTH_AGE; + } + + int maxAge = session.getContext().getRealm().getPasswordPolicy().getMaxAuthAge(); + if (maxAge < 0) { + // passwordPolicy is not present fallback to default maxAuthAge + return MaxAuthAgePasswordPolicyProviderFactory.DEFAULT_MAX_AUTH_AGE; + } + + return maxAge; + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AppInitiatedActionResetPasswordTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AppInitiatedActionResetPasswordTest.java index aacd07bfe5..280b873755 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AppInitiatedActionResetPasswordTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AppInitiatedActionResetPasswordTest.java @@ -22,7 +22,6 @@ import org.junit.After; import org.junit.Assert; import org.junit.Rule; import org.junit.Test; -import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.resource.UserResource; import org.keycloak.events.EventType; import org.keycloak.models.UserModel; @@ -31,12 +30,10 @@ import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserSessionRepresentation; import org.keycloak.testsuite.admin.ApiUtil; -import org.keycloak.testsuite.auth.page.AuthRealm; import org.keycloak.testsuite.pages.LoginPasswordUpdatePage; import org.keycloak.testsuite.util.GreenMailRule; import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.SecondBrowser; -import org.openqa.selenium.By; import org.openqa.selenium.WebDriver; import java.util.List; @@ -131,6 +128,54 @@ public class AppInitiatedActionResetPasswordTest extends AbstractAppInitiatedAct assertKcActionStatus(SUCCESS); } + /** + * See GH-12943 + * @throws Exception + */ + @Test + public void resetPasswordRequiresReAuthWithMaxAuthAgePasswordPolicy() throws Exception { + + // set password policy + RealmRepresentation currentTestRealmRep = testRealm().toRepresentation(); + String previousPasswordPolicy = currentTestRealmRep.getPasswordPolicy(); + if (previousPasswordPolicy == null) { + previousPasswordPolicy = ""; + } + currentTestRealmRep.setPasswordPolicy("maxAuthAge(0)"); + try { + testRealm().update(currentTestRealmRep); + + loginPage.open(); + loginPage.login("test-user@localhost", "password"); + + events.expectLogin().assertEvent(); + + // we need to add some slack to avoid timing issues + setTimeOffset(1); + + // Should prompt for re-authentication due to maxAuthAge password policy + doAIA(); + + loginPage.assertCurrent(); + + Assert.assertEquals("test-user@localhost", loginPage.getAttemptedUsername()); + + loginPage.login("password"); + + changePasswordPage.assertCurrent(); + assertTrue(changePasswordPage.isCancelDisplayed()); + + changePasswordPage.changePassword("new-password", "new-password"); + + events.expectRequiredAction(EventType.UPDATE_PASSWORD).assertEvent(); + assertKcActionStatus(SUCCESS); + } finally { + // reset password policy to previous state + currentTestRealmRep.setPasswordPolicy(previousPasswordPolicy); + testRealm().update(currentTestRealmRep); + } + } + @Test public void cancelChangePassword() throws Exception { doAIA();