Skip to content

Commit

Permalink
feature: password age in days policy
Browse files Browse the repository at this point in the history
Closes keycloak#30210

Signed-off-by: Maciej Mierzwa <[email protected]>
  • Loading branch information
MaciejMierzwa committed Jun 25, 2024
1 parent 1983bfc commit 9426c0d
Show file tree
Hide file tree
Showing 7 changed files with 459 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -125,5 +130,5 @@ The current implementation uses a BloomFilter for fast and memory efficient cont
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.
See <<con-aia-reauth_{context}, AIA section>> for some additional details about this policy.

NOTE: The Maximum Authentication Age is configurable also when configuring the required action *Update Password* in the *Required Actions* tab in the Admin Console. The better choice is to use the
NOTE: The Maximum Authentication Age is configurable also when configuring the required action *Update Password* in the *Required Actions* tab in the Admin Console. The better choice is to use the
required action for the configuration because the _Maximum Authentication Age_ password policy might be deprecated/removed in the future.
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* 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:[email protected]">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) {
return null;
}

@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() {
}
}
Original file line number Diff line number Diff line change
@@ -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:[email protected]">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() {

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ org.keycloak.policy.BlacklistPasswordPolicyProviderFactory
org.keycloak.policy.NotEmailPasswordPolicyProviderFactory
org.keycloak.policy.RecoveryCodesWarningThresholdPasswordPolicyProviderFactory
org.keycloak.policy.MaxAuthAgePasswordPolicyProviderFactory
org.keycloak.policy.AgePasswordPolicyProviderFactory
10 changes: 10 additions & 0 deletions server-spi/src/main/java/org/keycloak/models/PasswordPolicy.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -97,6 +99,14 @@ public int getExpiredPasswords() {
}
}

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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
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;
Expand Down Expand Up @@ -79,6 +80,7 @@ public CredentialModel createCredential(RealmModel realm, UserModel user, Passwo

PasswordPolicy policy = realm.getPasswordPolicy();
int expiredPasswordsPolicyValue = policy.getExpiredPasswords();
int passwordAgeInDaysPolicy = Math.max(0, policy.getPasswordAgeInDays());

// 1) create new or reset existing password
CredentialModel createdCredential;
Expand All @@ -94,24 +96,34 @@ public CredentialModel createCredential(RealmModel realm, UserModel user, Passwo
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);
Expand Down
Loading

0 comments on commit 9426c0d

Please sign in to comment.