Introduce MaxAuthAge Password policy (#12943)

This policy allows to specify the maximum age of an authentication
with which a password may be changed without re-authentication.

Defaults to 300 seconds (default taken from Constants.KC_ACTION_MAX_AGE) to remain backwards compatible.
A value of 0 will always require reauthentication to update the password.
Add documentation for MaxAuthAgePasswordPolicy to server_admin

Fixes #12943

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
This commit is contained in:
Thomas Darimont 2023-10-24 13:46:03 +02:00 committed by Marek Posolda
parent 1bbefca92e
commit d30d692335
7 changed files with 203 additions and 5 deletions

View file

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

View file

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

View file

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

View file

@ -30,3 +30,4 @@ org.keycloak.policy.UpperCasePasswordPolicyProviderFactory
org.keycloak.policy.BlacklistPasswordPolicyProviderFactory
org.keycloak.policy.NotEmailPasswordPolicyProviderFactory
org.keycloak.policy.RecoveryCodesWarningThresholdPasswordPolicyProviderFactory
org.keycloak.policy.MaxAuthAgePasswordPolicyProviderFactory

View file

@ -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<String, Object> 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:
* <ul>
* <li>{@code maxAuthAge(0)} means the user has to reauthenticate immediately.</li>
* <li>{@code maxAuthAge(60)} means the user has to reauthenticate if authentication is older than 60 seconds.</li>
* </ul>
* @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();

View file

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

View file

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