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 12, 2024
1 parent 7d42ab8 commit 6b903ab
Show file tree
Hide file tree
Showing 6 changed files with 397 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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;

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,55 @@
package org.keycloak.policy;

import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.PasswordPolicy;

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 6b903ab

Please sign in to comment.