feature: password age in days policy
Closes #30210 Signed-off-by: Maciej Mierzwa <dev.maciej.mierzwa@gmail.com>
This commit is contained in:
parent
c8d64f2891
commit
97e89e2071
7 changed files with 460 additions and 5 deletions
|
@ -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}.
|
||||
|
||||
===== 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 must not be in a blacklist 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() {
|
||||
}
|
||||
}
|
|
@ -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() {
|
||||
|
||||
}
|
||||
}
|
|
@ -32,3 +32,4 @@ org.keycloak.policy.BlacklistPasswordPolicyProviderFactory
|
|||
org.keycloak.policy.NotEmailPasswordPolicyProviderFactory
|
||||
org.keycloak.policy.RecoveryCodesWarningThresholdPasswordPolicyProviderFactory
|
||||
org.keycloak.policy.MaxAuthAgePasswordPolicyProviderFactory
|
||||
org.keycloak.policy.AgePasswordPolicyProviderFactory
|
|
@ -45,6 +45,8 @@ public class PasswordPolicy implements Serializable {
|
|||
|
||||
public static final String MAX_AUTH_AGE_ID = "maxAuthAge";
|
||||
|
||||
public static final String PASSWORD_AGE = "passwordAge";
|
||||
|
||||
private Map<String, Object> policyConfig;
|
||||
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() {
|
||||
if (policyConfig.containsKey(FORCE_EXPIRED_ID)) {
|
||||
return getPolicyConfig(FORCE_EXPIRED_ID);
|
||||
|
|
|
@ -29,6 +29,7 @@ import org.keycloak.models.credential.PasswordCredentialModel;
|
|||
import org.keycloak.policy.PasswordPolicyManagerProvider;
|
||||
import org.keycloak.policy.PolicyError;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
@ -79,6 +80,7 @@ public class PasswordCredentialProvider implements CredentialProvider<PasswordCr
|
|||
|
||||
PasswordPolicy policy = realm.getPasswordPolicy();
|
||||
int expiredPasswordsPolicyValue = policy.getExpiredPasswords();
|
||||
int passwordAgeInDaysPolicy = Math.max(0, policy.getPasswordAgeInDays());
|
||||
|
||||
// 1) create new or reset existing password
|
||||
CredentialModel createdCredential;
|
||||
|
@ -94,24 +96,34 @@ public class PasswordCredentialProvider implements CredentialProvider<PasswordCr
|
|||
createdCredential = credentialModel;
|
||||
|
||||
// 2) add a password history item based on the old password
|
||||
if (expiredPasswordsPolicyValue > 1) {
|
||||
if (expiredPasswordsPolicyValue > 1 || passwordAgeInDaysPolicy > 0) {
|
||||
oldPassword.setId(null);
|
||||
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 long passwordMaxAgeMillis = Time.currentTimeMillis() - Duration.ofDays(passwordAgeInDaysPolicy).toMillis();
|
||||
|
||||
CredentialModel finalOldPassword = oldPassword;
|
||||
user.credentialManager().getStoredCredentialsByTypeStream(PasswordCredentialModel.PASSWORD_HISTORY)
|
||||
.sorted(CredentialModel.comparingByStartDateDesc())
|
||||
.skip(passwordHistoryListMaxSize)
|
||||
.filter(credentialModel1 -> !(credentialModel1.getId().equals(finalOldPassword.getId())))
|
||||
.filter(credential -> passwordAgePredicate(credential, passwordMaxAgeMillis))
|
||||
.collect(Collectors.toList())
|
||||
.forEach(p -> user.credentialManager().removeStoredCredentialById(p.getId()));
|
||||
|
||||
return createdCredential;
|
||||
}
|
||||
|
||||
private boolean passwordAgePredicate(CredentialModel credential, long passwordMaxAgeMillis) {
|
||||
return credential.getCreatedDate() < passwordMaxAgeMillis;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteCredential(RealmModel realm, UserModel user, String credentialId) {
|
||||
return user.credentialManager().removeStoredCredentialById(credentialId);
|
||||
|
|
|
@ -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"));
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue