feature: password age in days policy

Closes #30210

Signed-off-by: Maciej Mierzwa <dev.maciej.mierzwa@gmail.com>
This commit is contained in:
Maciej Mierzwa 2024-06-05 21:26:07 +02:00 committed by Pedro Igor
parent c8d64f2891
commit 97e89e2071
7 changed files with 460 additions and 5 deletions

View file

@ -102,6 +102,11 @@ The number of days the password is valid. When the number of days has expired, t
Password cannot be already used by the user. {project_name} stores a history of used passwords. The number of old passwords stored is configurable in {project_name}. Password cannot be already used by the user. {project_name} stores a history of used passwords. The number of old passwords stored is configurable in {project_name}.
===== Not recently used (In Days)
Password cannot be already used by the user. {project_name} stores a history of used passwords. If the new password creation date is older then the date defined in policy, and is not currently in use, the password change will be allowed.
In case of both password history policies used, more restrictive policy is used.
===== Password blacklist ===== Password blacklist
Password must not be in a blacklist file. Password must not be in a blacklist file.

View file

@ -0,0 +1,91 @@
/*
* Copyright 2024 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.common.util.Time;
import org.keycloak.credential.hash.PasswordHashProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.PasswordPolicy;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.jboss.logging.Logger;
import java.time.Duration;
/**
* @author <a href="mailto:dev.maciej.mierzwa@gmail.com">Maciej Mierzwa</a>
*/
public class AgePasswordPolicyProvider implements PasswordPolicyProvider {
private static final String ERROR_MESSAGE = "invalidPasswordGenericMessage";
public static final Logger logger = Logger.getLogger(AgePasswordPolicyProvider.class);
private final KeycloakSession session;
public AgePasswordPolicyProvider(KeycloakSession session) {
this.session = session;
}
@Override
public PolicyError validate(String user, String password) {
RealmModel realm = session.getContext().getRealm();
return validate(realm, session.users().getUserByUsername(realm, user), password);
}
@Override
public PolicyError validate(RealmModel realm, UserModel user, String password) {
PasswordPolicy policy = session.getContext().getRealm().getPasswordPolicy();
int passwordAgePolicyValue = policy.getPolicyConfig(PasswordPolicy.PASSWORD_AGE);
if (passwordAgePolicyValue != -1) {
//current password check
if (user.credentialManager().getStoredCredentialsByTypeStream(PasswordCredentialModel.TYPE)
.map(PasswordCredentialModel::createFromCredentialModel)
.anyMatch(passwordCredential -> {
PasswordHashProvider hash = session.getProvider(PasswordHashProvider.class,
passwordCredential.getPasswordCredentialData().getAlgorithm());
return hash != null && hash.verify(password, passwordCredential);
})) {
return new PolicyError(ERROR_MESSAGE, passwordAgePolicyValue);
}
final long passwordMaxAgeMillis = Time.currentTimeMillis() - Duration.ofDays(passwordAgePolicyValue).toMillis();
if (passwordAgePolicyValue > 0) {
if (user.credentialManager().getStoredCredentialsByTypeStream(PasswordCredentialModel.PASSWORD_HISTORY)
.filter(credentialModel -> credentialModel.getCreatedDate() > passwordMaxAgeMillis)
.map(PasswordCredentialModel::createFromCredentialModel)
.anyMatch(passwordCredential -> {
PasswordHashProvider hash = session.getProvider(PasswordHashProvider.class,
passwordCredential.getPasswordCredentialData().getAlgorithm());
return hash.verify(password, passwordCredential);
})) {
return new PolicyError(ERROR_MESSAGE, passwordAgePolicyValue);
}
}
}
return null;
}
@Override
public Object parseConfig(String value) {
return parseInteger(value, AgePasswordPolicyProviderFactory.DEFAULT_AGE_DAYS);
}
@Override
public void close() {
}
}

View file

@ -0,0 +1,75 @@
/*
* Copyright 2024 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.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.PasswordPolicy;
/**
* @author <a href="mailto:dev.maciej.mierzwa@gmail.com">Maciej Mierzwa</a>
*/
public class AgePasswordPolicyProviderFactory implements PasswordPolicyProviderFactory {
public static final Integer DEFAULT_AGE_DAYS = 30;
@Override
public String getId() {
return PasswordPolicy.PASSWORD_AGE;
}
@Override
public String getDisplayName() {
return "Not Recently Used (In Days)";
}
@Override
public String getConfigType() {
return PasswordPolicyProvider.INT_CONFIG_TYPE;
}
@Override
public String getDefaultConfigValue() {
return String.valueOf(DEFAULT_AGE_DAYS);
}
@Override
public boolean isMultiplSupported() {
return false;
}
@Override
public PasswordPolicyProvider create(KeycloakSession session) {
return new AgePasswordPolicyProvider(session);
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
}

View file

@ -32,3 +32,4 @@ org.keycloak.policy.BlacklistPasswordPolicyProviderFactory
org.keycloak.policy.NotEmailPasswordPolicyProviderFactory org.keycloak.policy.NotEmailPasswordPolicyProviderFactory
org.keycloak.policy.RecoveryCodesWarningThresholdPasswordPolicyProviderFactory org.keycloak.policy.RecoveryCodesWarningThresholdPasswordPolicyProviderFactory
org.keycloak.policy.MaxAuthAgePasswordPolicyProviderFactory org.keycloak.policy.MaxAuthAgePasswordPolicyProviderFactory
org.keycloak.policy.AgePasswordPolicyProviderFactory

View file

@ -45,6 +45,8 @@ public class PasswordPolicy implements Serializable {
public static final String MAX_AUTH_AGE_ID = "maxAuthAge"; public static final String MAX_AUTH_AGE_ID = "maxAuthAge";
public static final String PASSWORD_AGE = "passwordAge";
private Map<String, Object> policyConfig; private Map<String, Object> policyConfig;
private Builder builder; private Builder builder;
@ -97,6 +99,14 @@ public class PasswordPolicy implements Serializable {
} }
} }
public int getPasswordAgeInDays() {
if (policyConfig.containsKey(PASSWORD_AGE)) {
return getPolicyConfig(PASSWORD_AGE);
} else {
return -1;
}
}
public int getDaysToExpirePassword() { public int getDaysToExpirePassword() {
if (policyConfig.containsKey(FORCE_EXPIRED_ID)) { if (policyConfig.containsKey(FORCE_EXPIRED_ID)) {
return getPolicyConfig(FORCE_EXPIRED_ID); return getPolicyConfig(FORCE_EXPIRED_ID);

View file

@ -29,6 +29,7 @@ import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.policy.PasswordPolicyManagerProvider; import org.keycloak.policy.PasswordPolicyManagerProvider;
import org.keycloak.policy.PolicyError; import org.keycloak.policy.PolicyError;
import java.time.Duration;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@ -79,6 +80,7 @@ public class PasswordCredentialProvider implements CredentialProvider<PasswordCr
PasswordPolicy policy = realm.getPasswordPolicy(); PasswordPolicy policy = realm.getPasswordPolicy();
int expiredPasswordsPolicyValue = policy.getExpiredPasswords(); int expiredPasswordsPolicyValue = policy.getExpiredPasswords();
int passwordAgeInDaysPolicy = Math.max(0, policy.getPasswordAgeInDays());
// 1) create new or reset existing password // 1) create new or reset existing password
CredentialModel createdCredential; CredentialModel createdCredential;
@ -94,24 +96,34 @@ public class PasswordCredentialProvider implements CredentialProvider<PasswordCr
createdCredential = credentialModel; createdCredential = credentialModel;
// 2) add a password history item based on the old password // 2) add a password history item based on the old password
if (expiredPasswordsPolicyValue > 1) { if (expiredPasswordsPolicyValue > 1 || passwordAgeInDaysPolicy > 0) {
oldPassword.setId(null); oldPassword.setId(null);
oldPassword.setType(PasswordCredentialModel.PASSWORD_HISTORY); oldPassword.setType(PasswordCredentialModel.PASSWORD_HISTORY);
user.credentialManager().createStoredCredential(oldPassword); oldPassword = user.credentialManager().createStoredCredential(oldPassword);
} }
} }
// 3) remove old password history items // 3) remove old password history items, if both history policies are set, more restrictive policy wins
final int passwordHistoryListMaxSize = Math.max(0, expiredPasswordsPolicyValue - 1); final int passwordHistoryListMaxSize = Math.max(0, expiredPasswordsPolicyValue - 1);
final long passwordMaxAgeMillis = Time.currentTimeMillis() - Duration.ofDays(passwordAgeInDaysPolicy).toMillis();
CredentialModel finalOldPassword = oldPassword;
user.credentialManager().getStoredCredentialsByTypeStream(PasswordCredentialModel.PASSWORD_HISTORY) user.credentialManager().getStoredCredentialsByTypeStream(PasswordCredentialModel.PASSWORD_HISTORY)
.sorted(CredentialModel.comparingByStartDateDesc()) .sorted(CredentialModel.comparingByStartDateDesc())
.skip(passwordHistoryListMaxSize) .skip(passwordHistoryListMaxSize)
.filter(credentialModel1 -> !(credentialModel1.getId().equals(finalOldPassword.getId())))
.filter(credential -> passwordAgePredicate(credential, passwordMaxAgeMillis))
.collect(Collectors.toList()) .collect(Collectors.toList())
.forEach(p -> user.credentialManager().removeStoredCredentialById(p.getId())); .forEach(p -> user.credentialManager().removeStoredCredentialById(p.getId()));
return createdCredential; return createdCredential;
} }
private boolean passwordAgePredicate(CredentialModel credential, long passwordMaxAgeMillis) {
return credential.getCreatedDate() < passwordMaxAgeMillis;
}
@Override @Override
public boolean deleteCredential(RealmModel realm, UserModel user, String credentialId) { public boolean deleteCredential(RealmModel realm, UserModel user, String credentialId) {
return user.credentialManager().removeStoredCredentialById(credentialId); return user.credentialManager().removeStoredCredentialById(credentialId);

View file

@ -0,0 +1,261 @@
/*
* Copyright 2024 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.testsuite.policy;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.core.Response;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.AbstractAuthTest;
import java.util.function.Consumer;
import static org.keycloak.representations.idm.CredentialRepresentation.PASSWORD;
import static org.keycloak.testsuite.admin.ApiUtil.getCreatedId;
public class PasswordAgePolicyTest extends AbstractAuthTest {
UserResource user;
private void setPasswordAgePolicy(String passwordAge) {
log.info(String.format("Setting %s", passwordAge));
RealmRepresentation testRealmRepresentation = testRealmResource().toRepresentation();
testRealmRepresentation.setPasswordPolicy(passwordAge);
testRealmResource().update(testRealmRepresentation);
}
private void setPasswordHistory(String passwordHistory) {
log.info(String.format("Setting %s", passwordHistory));
RealmRepresentation testRealmRepresentation = testRealmResource().toRepresentation();
testRealmRepresentation.setPasswordPolicy(passwordHistory);
testRealmResource().update(testRealmRepresentation);
}
private void setPasswordAgePolicyValue(String value) {
setPasswordAgePolicy(String.format("passwordAge(%s)", value));
}
private void setPasswordAgePolicyValue(int value) {
setPasswordAgePolicyValue(String.valueOf(value));
}
private void setPasswordHistoryValue(String value) {
setPasswordHistory(String.format("passwordHistory(%s)", value));
}
private void setPasswordHistoryValue(int value) {
setPasswordHistoryValue(String.valueOf(value));
}
public UserRepresentation createUserRepresentation(String username) {
UserRepresentation userRepresentation = new UserRepresentation();
userRepresentation.setUsername(username);
userRepresentation.setEmail(String.format("%s@email.test", userRepresentation.getUsername()));
userRepresentation.setEmailVerified(true);
return userRepresentation;
}
public UserResource createUser(UserRepresentation user) {
String createdUserId;
try (Response response = testRealmResource().users().create(user)) {
createdUserId = getCreatedId(response);
}
return testRealmResource().users().get(createdUserId);
}
public void resetUserPassword(UserResource userResource, String newPassword) {
CredentialRepresentation newCredential = new CredentialRepresentation();
newCredential.setType(PASSWORD);
newCredential.setValue(newPassword);
newCredential.setTemporary(false);
userResource.resetPassword(newCredential);
}
private void expectBadRequestException(Consumer<Void> f) {
try {
f.accept(null);
throw new AssertionError("An expected BadRequestException was not thrown.");
} catch (BadRequestException bre) {
log.info("An expected BadRequestException was caught.");
}
}
static private int daysToSeconds(int days) {
return days * 24 * 60 * 60;
}
@Before
public void before() {
user = createUser(createUserRepresentation("test_user"));
}
@After
public void after() {
user.remove();
}
@Test
public void testPasswordHistoryRetrySamePassword() {
setPasswordAgePolicyValue(1);
//set offset to 12h ago
setTimeOffset(-12 * 60 * 60);
resetUserPassword(user, "secret");
//try to set again same password
setTimeOffset(0);
expectBadRequestException(f -> resetUserPassword(user, "secret"));
}
@Test
public void testPasswordHistoryWithTwoPasswordsErrorThrown() {
setPasswordAgePolicyValue(1);
//set offset to 12h ago
setTimeOffset(-12 * 60 * 60);
resetUserPassword(user, "secret");
setTimeOffset(-10 * 60 * 60);
resetUserPassword(user, "secret1");
//try to set again same password after 12h
setTimeOffset(0);
expectBadRequestException(f -> resetUserPassword(user, "secret"));
}
@Test
public void testPasswordHistoryWithTwoPasswords() {
setPasswordAgePolicyValue(1);
//set offset to more than a day ago
setTimeOffset(-24 * 60 * 60 * 2);
resetUserPassword(user, "secret");
setTimeOffset(-10 * 60 * 60);
resetUserPassword(user, "secret1");
//try to set again same password after 48h
setTimeOffset(0);
resetUserPassword(user, "secret");
}
@Test
public void testPasswordHistoryWithMultiplePasswordsErrorThrown() {
setPasswordAgePolicyValue(30);
//set offset to 29 days, 23:45:00
setTimeOffset(-30 * 24 * 60 * 60 + 15 * 60);
resetUserPassword(user, "secret");
setTimeOffset(-25 * 24 * 60 * 60);
resetUserPassword(user, "secret1");
setTimeOffset(-20 * 24 * 60 * 60);
resetUserPassword(user, "secret2");
setTimeOffset(-10 * 24 * 60 * 60);
resetUserPassword(user, "secret3");
//try to set again same password after 30 days, should throw error, 15 minutes too early
setTimeOffset(0);
expectBadRequestException(f -> resetUserPassword(user, "secret"));
}
@Test
public void testPasswordHistoryWithMultiplePasswords() {
setPasswordAgePolicyValue(30);
//set offset to 30 days and 15 minutes
setTimeOffset(-30 * 24 * 60 * 60 - 5 * 60);
resetUserPassword(user, "secret");
setTimeOffset(-25 * 24 * 60 * 60);
resetUserPassword(user, "secret1");
setTimeOffset(-20 * 24 * 60 * 60);
resetUserPassword(user, "secret2");
setTimeOffset(-10 * 24 * 60 * 60);
resetUserPassword(user, "secret3");
//try to set again same password after 30 days and 15 minutes
setTimeOffset(0);
resetUserPassword(user, "secret");
}
@Test
public void testPasswordAge0Days() {
setPasswordAgePolicyValue(0);
resetUserPassword(user, "secret");
//can't set the same password
expectBadRequestException(f -> resetUserPassword(user, "secret"));
resetUserPassword(user, "secret1");
resetUserPassword(user, "secret");
}
@Test
public void testPasswordAgeSetToNegative() {
setPasswordAgePolicyValue(-1);
resetUserPassword(user, "secret");
//no check is performed
setPasswordAgePolicyValue(10);
resetUserPassword(user, "secret1");
resetUserPassword(user, "secret2");
resetUserPassword(user, "secret3");
setPasswordAgePolicyValue(-2);
//no check is performed
resetUserPassword(user, "secret");
resetUserPassword(user, "secret1");
setPasswordAgePolicyValue(-3);
}
@Test
public void testPasswordAgeSetToInvalid() {
expectBadRequestException(f -> setPasswordAgePolicyValue("abc"));
expectBadRequestException(f -> setPasswordAgePolicyValue("2a"));
expectBadRequestException(f -> setPasswordAgePolicyValue("asda2"));
expectBadRequestException(f -> setPasswordAgePolicyValue("-/!"));
}
@Test
public void testBothPasswordHistoryPoliciesPasswordHistoryPolicyTakesOver() {
//1 day
setPasswordAgePolicyValue(1);
//last 3 passwords
setPasswordHistoryValue(3);
setTimeOffset(daysToSeconds(-2));
resetUserPassword(user, "secret");
resetUserPassword(user, "secret1");
resetUserPassword(user, "secret2");
setTimeOffset(daysToSeconds(0));
//password history takes precedence
expectBadRequestException(f -> setPasswordAgePolicyValue("secret"));
}
@Test
public void testBothPasswordHistoryPoliciesPasswordAgePolicyTakesOver() {
//2 days
setPasswordAgePolicyValue(2);
//last 10 passwords
setPasswordHistoryValue(10);
setTimeOffset(daysToSeconds(-1));
resetUserPassword(user, "secret");
resetUserPassword(user, "secret1");
resetUserPassword(user, "secret2");
setTimeOffset(daysToSeconds(0));
//password age takes precedence
expectBadRequestException(f -> setPasswordAgePolicyValue("secret"));
}
}