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:
parent
1bbefca92e
commit
d30d692335
7 changed files with 203 additions and 5 deletions
|
@ -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.
|
* 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`.
|
* 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.
|
||||||
|
|
||||||
|
|
|
@ -2078,6 +2078,7 @@ enabled=Enabled
|
||||||
forgotPassword=Forgot password
|
forgotPassword=Forgot password
|
||||||
searchUserByAttributeMissingValueError=Specify a attribute value
|
searchUserByAttributeMissingValueError=Specify a attribute value
|
||||||
passwordPoliciesHelp.maxLength=The maximum number of characters allowed in the password.
|
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}}
|
moveGroupError=Could not move group {{error}}
|
||||||
clientImportSuccess=Client imported successfully
|
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.
|
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.
|
||||||
|
|
|
@ -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() {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -30,3 +30,4 @@ org.keycloak.policy.UpperCasePasswordPolicyProviderFactory
|
||||||
org.keycloak.policy.BlacklistPasswordPolicyProviderFactory
|
org.keycloak.policy.BlacklistPasswordPolicyProviderFactory
|
||||||
org.keycloak.policy.NotEmailPasswordPolicyProviderFactory
|
org.keycloak.policy.NotEmailPasswordPolicyProviderFactory
|
||||||
org.keycloak.policy.RecoveryCodesWarningThresholdPasswordPolicyProviderFactory
|
org.keycloak.policy.RecoveryCodesWarningThresholdPasswordPolicyProviderFactory
|
||||||
|
org.keycloak.policy.MaxAuthAgePasswordPolicyProviderFactory
|
||||||
|
|
|
@ -48,6 +48,8 @@ public class PasswordPolicy implements Serializable {
|
||||||
|
|
||||||
public static final String RECOVERY_CODES_WARNING_THRESHOLD_ID = "recoveryCodesWarningThreshold";
|
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 Map<String, Object> policyConfig;
|
||||||
private Builder builder;
|
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
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
return builder.asString();
|
return builder.asString();
|
||||||
|
|
|
@ -35,6 +35,7 @@ import org.keycloak.models.ModelException;
|
||||||
import org.keycloak.models.UserCredentialModel;
|
import org.keycloak.models.UserCredentialModel;
|
||||||
import org.keycloak.models.UserModel;
|
import org.keycloak.models.UserModel;
|
||||||
import org.keycloak.models.utils.FormMessage;
|
import org.keycloak.models.utils.FormMessage;
|
||||||
|
import org.keycloak.policy.MaxAuthAgePasswordPolicyProviderFactory;
|
||||||
import org.keycloak.services.messages.Messages;
|
import org.keycloak.services.messages.Messages;
|
||||||
import org.keycloak.services.validation.Validation;
|
import org.keycloak.services.validation.Validation;
|
||||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||||
|
@ -49,12 +50,25 @@ import java.util.concurrent.TimeUnit;
|
||||||
*/
|
*/
|
||||||
public class UpdatePassword implements RequiredActionProvider, RequiredActionFactory {
|
public class UpdatePassword implements RequiredActionProvider, RequiredActionFactory {
|
||||||
private static final Logger logger = Logger.getLogger(UpdatePassword.class);
|
private static final Logger logger = Logger.getLogger(UpdatePassword.class);
|
||||||
|
private final KeycloakSession session;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public InitiatedActionSupport initiatedActionSupport() {
|
public InitiatedActionSupport initiatedActionSupport() {
|
||||||
return InitiatedActionSupport.SUPPORTED;
|
return InitiatedActionSupport.SUPPORTED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use {@link #UpdatePassword(KeycloakSession)} instead
|
||||||
|
*/
|
||||||
|
@Deprecated
|
||||||
|
public UpdatePassword() {
|
||||||
|
this(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UpdatePassword(KeycloakSession session) {
|
||||||
|
this.session = session;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void evaluateTriggers(RequiredActionContext context) {
|
public void evaluateTriggers(RequiredActionContext context) {
|
||||||
int daysToExpirePassword = context.getRealm().getPasswordPolicy().getDaysToExpirePassword();
|
int daysToExpirePassword = context.getRealm().getPasswordPolicy().getDaysToExpirePassword();
|
||||||
|
@ -151,7 +165,7 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public RequiredActionProvider create(KeycloakSession session) {
|
public RequiredActionProvider create(KeycloakSession session) {
|
||||||
return this;
|
return new UpdatePassword(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -179,4 +193,21 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac
|
||||||
public boolean isOneTimeAction() {
|
public boolean isOneTimeAction() {
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,7 +22,6 @@ import org.junit.After;
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
import org.junit.Rule;
|
import org.junit.Rule;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.keycloak.OAuth2Constants;
|
|
||||||
import org.keycloak.admin.client.resource.UserResource;
|
import org.keycloak.admin.client.resource.UserResource;
|
||||||
import org.keycloak.events.EventType;
|
import org.keycloak.events.EventType;
|
||||||
import org.keycloak.models.UserModel;
|
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.UserRepresentation;
|
||||||
import org.keycloak.representations.idm.UserSessionRepresentation;
|
import org.keycloak.representations.idm.UserSessionRepresentation;
|
||||||
import org.keycloak.testsuite.admin.ApiUtil;
|
import org.keycloak.testsuite.admin.ApiUtil;
|
||||||
import org.keycloak.testsuite.auth.page.AuthRealm;
|
|
||||||
import org.keycloak.testsuite.pages.LoginPasswordUpdatePage;
|
import org.keycloak.testsuite.pages.LoginPasswordUpdatePage;
|
||||||
import org.keycloak.testsuite.util.GreenMailRule;
|
import org.keycloak.testsuite.util.GreenMailRule;
|
||||||
import org.keycloak.testsuite.util.OAuthClient;
|
import org.keycloak.testsuite.util.OAuthClient;
|
||||||
import org.keycloak.testsuite.util.SecondBrowser;
|
import org.keycloak.testsuite.util.SecondBrowser;
|
||||||
import org.openqa.selenium.By;
|
|
||||||
import org.openqa.selenium.WebDriver;
|
import org.openqa.selenium.WebDriver;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -131,6 +128,54 @@ public class AppInitiatedActionResetPasswordTest extends AbstractAppInitiatedAct
|
||||||
assertKcActionStatus(SUCCESS);
|
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
|
@Test
|
||||||
public void cancelChangePassword() throws Exception {
|
public void cancelChangePassword() throws Exception {
|
||||||
doAIA();
|
doAIA();
|
||||||
|
|
Loading…
Reference in a new issue