8000 Stop the deferred password re-hash from overwriting a password changed after validation by d53dave · Pull Request #53742 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public Argon2PasswordHashProvider(String version, String type, int hashLength, i
public boolean policyCheck(PasswordPolicy policy, PasswordCredentialModel credential) {
PasswordCredentialData data = credential.getPasswordCredentialData();

return iterations == data.getHashIterations() &&
return expectedIterations(policy != null ? policy.getHashIterations() : -1) == data.getHashIterations() &&
checkCredData(TYPE_KEY, type, data) &&
checkCredData(VERSION_KEY, version, data) &&
checkCredData(Argon2PasswordHashProviderFactory.HASH_LENGTH_KEY, hashLength, data) &&
Expand All @@ -60,19 +60,16 @@ public boolean policyCheck(PasswordPolicy policy, PasswordCredentialModel creden
}

/**
* Password hashing iterations from password policy is intentionally ignored for now for two reasons. 1) default
* iterations are 210K, which is way too large for Argon2, and 2) it makes little sense to configure iterations only
* for Argon2, which should be combined with configuring memory, which is not currently configurable in password
* policy.
* Password hashing iterations from the password policy are only used if they are 100 or less, otherwise the
* provider's iterations are used. Larger values are most likely meant for PBKDF2 (default 210K), which is way too
* large for Argon2.
*/
@Override
public PasswordCredentialModel encodedCredential(String rawPassword, int iterations) {
if (iterations == -1) {
iterations = this.iterations;
} else if (iterations > 100) {
if (iterations > 100) {
logger.warn("Iterations for Argon should be less than 100, using default");
iterations = this.iterations;
}
iterations = expectedIterations(iterations);

byte[] salt = Salt.generateSalt();
String encoded = encode(rawPassword, salt, version, type, hashLength, parallelism, memory, iterations);
Expand Down Expand Up @@ -143,6 +140,15 @@ private String encode(String rawPassword, byte[] salt, String version, String ty
});
}

/**
* The iterations that {@link #encodedCredential(String, int)} uses for the given password policy iterations.
* {@link #policyCheck(PasswordPolicy, PasswordCredentialModel)} must use the same, otherwise a freshly created hash
* is considered outdated and re-hashed after every successful login.
*/
private int expectedIterations(int policyIterations) {
return policyIterations == -1 || policyIterations > 100 ? this.iterations : policyIterations;
}

private boolean checkCredData(String key, int expectedValue, PasswordCredentialData data) {
String s = data.getAdditionalParameters().getFirst(key);
Integer v = s != null ? Integer.parseInt(s) : null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import jakarta.persistence.NamedQueries;
import jakarta.persistence.NamedQuery;
import jakarta.persistence.Table;
import jakarta.persistence.Version;

/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
Expand Down Expand Up @@ -81,6 +82,10 @@ public class FederatedUserCredentialEntity {
@Column(name="SALT")
protected byte[] salt;

@Version
@Column(name="VERSION")
private int version;


public String getId() {
return id;
Expand Down
36 changes: 36 additions & 0 deletions model/jpa/src/main/resources/META-INF/jpa-changelog-27.0.0.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!--
~ * Copyright 2026 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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">

<changeSet author="keycloak" id="27.0.0-53633">
<preConditions >
<not>
<columnExists tableName="FED_USER_CREDENTIAL" columnName="VERSION"/>
</not>
</preConditions>
<addColumn tableName="FED_USER_CREDENTIAL">
<column name="VERSION" type="INT" defaultValueNumeric="0" />
</addColumn>
<modifySql dbms="mssql">
<!-- ensure that existing rows also get the new values on mssql -->
<!-- https://github.com/liquibase/liquibase/issues/4644 -->
<replace replace="DEFAULT 0" with="DEFAULT 0 WITH VALUES" />
</modifySql>
</changeSet>

</databaseChangeLog>
Original file line number Diff line number Diff line change
Expand Up @@ -93,5 +93,6 @@
<include file="META-INF/jpa-changelog-26.6.0.xml"/>
<include file="META-INF/jpa-changelog-26.7.0.xml"/>
<include file="META-INF/jpa-changelog-26.8.0.xml"/>
<include file="META-INF/jpa-changelog-27.0.0.xml"/>

</databaseChangeLog>
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.stream.Stream;

Expand All @@ -35,6 +36,9 @@
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.policy.PasswordPolicyManagerProvider;
import org.keycloak.policy.PolicyError;
import org.keycloak.storage.StorageId;
import org.keycloak.storage.UserStoragePrivateUtil;
import org.keycloak.storage.UserStorageUtil;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Meter;
Expand Down Expand Up @@ -295,6 +299,7 @@ private void rehashPasswordIfRequired(KeycloakSession session, RealmModel realm,
if (!provider.policyCheck(passwordPolicy, password)) {
final int iterations = passwordPolicy != null ? passwordPolicy.getHashIterations() : -1;
final String hashAlgorithm = passwordPolicy != null ? passwordPolicy.getHashAlgorithm() : null;
final String validatedSecretData = password.getSecretData();
// Refresh the password in a different transaction, do not fail if there is a model exception on current modifications due to concurrent logins.
// Also do not start it as a nested transaction, as the current transaction might have auto-migrated the credential.
// see: JpaUserCredentialStore#toModel for the on-the-fly migration of the salt column
Expand All @@ -304,7 +309,7 @@ protected void commitImpl() {
try {
KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), session.getContext(),
(KeycloakSession s) -> refreshPassword(s, hashAlgorithm, iterations, input.getChallengeResponse(),
password.getId(), password.getCreatedDate(), password.getUserLabel(), user.getId()));
password.getId(), user.getId(), validatedSecretData));
} catch (ModelException e) {
logger.info("Error re-hashing the password in a different transaction", e);
}
Expand All @@ -319,18 +324,38 @@ protected void rollbackImpl() {
}

private static void refreshPassword(KeycloakSession s, String hashAlgorithm, int iterations, String challenge,
String passwordId, Long passwordDate, String passwordLabel, String userId) {
String passwordId, String userId, String validatedSecretData) {
RealmModel realm = s.getContext().getRealm();
UserModel userModel = s.users().getUserById(realm, userId);
if (userModel == null) {
return;
}
PasswordCredentialModel newPassword = ((hashAlgorithm != null)
? s.getProvider(PasswordHashProvider.class, hashAlgorithm)
: s.getProvider(PasswordHashProvider.class))
.encodedCredential(challenge, iterations);
newPassword.setId(passwordId);
newPassword.setCreatedDate(passwordDate);
newPassword.setUserLabel(passwordLabel);
UserModel userModel = s.users().getUserById(s.getContext().getRealm(), userId);
if (userModel != null) {
userModel.credentialManager().updateStoredCredential(newPassword);

// The credential may have been changed after it was validated, e.g. by a password change in the same request
// or by a concurrent request. Re-hashing the validated password would revert that change.
// Read it from the storage and not from the user cache, after the (slow) hashing. The update below works on the
// same entity, so a change committed after this read fails it on the entity version (ModelException).
CredentialModel current;
if (StorageId.isLocalStorage(userId)) {
UserModel localUser = UserStoragePrivateUtil.userLocalStorage(s).getUserById(realm, userId);
current = localUser != null ? localUser.credentialManager().getStoredCredentialById(passwordId) : null;
} else {
// Credentials of users from a user storage provider are kept in the federated storage.
current = UserStorageUtil.userFederatedStorage(s).getStoredCredentialById(realm, userId, passwordId);
}
if (current == null || !Objects.equals(current.getSecretData(), validatedSecretData)) {
logger.debugf("Skipping re-hash of password credential %s of user %s, it changed after it was validated", passwordId, userId);
return;
}
// Preserve current metadata, including changes made while re-hashing.
newPassword.setCreatedDate(current.getCreatedDate());
newPassword.setUserLabel(current.getUserLabel());
userModel.credentialManager().updateStoredCredential(newPassword);
}

@Override
Expand Down
10BC8
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package org.keycloak.tests.model;

import org.keycloak.component.ComponentModel;
import org.keycloak.credential.CredentialModel;
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.UserCredentialModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.storage.StorageId;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.injection.LifeCycle;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.remote.runonserver.InjectRunOnServer;
import org.keycloak.testframework.remote.runonserver.RunOnServerClient;
import org.keycloak.tests.common.CustomProvidersServerConfig;
import org.keycloak.tests.providers.federation.NoCredentialUserStorageProviderFactory;
import org.keycloak.tests.providers.hash.RehashPasswordHashProviderFactory;
import org.keycloak.tests.providers.hash.RehashPasswordHashProviderFactory.Phase;
import org.keycloak.tests.suites.DatabaseTest;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

@KeycloakIntegrationTest(config = CustomProvidersServerConfig.class)
@DatabaseTest
public class PasswordRehashTest {

private static final String OLD_PASSWORD = "Old-password1";
private static final String NEW_PASSWORD = "New-password1";

@InjectRealm(lifecycle = LifeCycle.METHOD)
ManagedRealm realm;

@InjectRunOnServer(permittedPackages = "org.keycloak.tests.model")
RunOnServerClient runOnServer;

@ParameterizedTest(name = "federated={0}, phase={1}")
@CsvSource({
"false, DURING_HASH",
"true, DURING_HASH",
"false, BEFORE_COMMIT",
"true, BEFORE_COMMIT"
})
public void passwordChangeSurvivesRehash(boolean federated, Phase phase) {
String username = prepareOutdatedPassword(federated);

runOnServer.run(session -> {
RehashPasswordHashProviderFactory.onNextHash(session, phase, rehashSession -> KeycloakModelUtils.runJobInTransaction(
rehashSession.getKeycloakSessionFactory(), rehashSession.getContext(), other -> assertTrue(user(other, username)
.credentialManager().updateCredential(UserCredentialModel.password(NEW_PASSWORD)))));
assertTrue(isPasswordValid(session, username, OLD_PASSWORD));
});

runOnServer.run(session -> {
assertTrue(isPasswordValid(session, username, NEW_PASSWORD), "New password must remain valid");
assertFalse(isPasswordValid(session, username, OLD_PASSWORD), "Old password must remain invalid");
});
}

@Test
public void rehashPreservesConcurrentMetadataChanges() {
String username = prepareOutdatedPassword(false);
CredentialModel original = credential(username);

runOnServer.run(session -> {
RehashPasswordHashProviderFactory.onNextHash(session, Phase.DURING_HASH, rehashSession -> KeycloakModelUtils.runJobInTransaction(
rehashSession.getKeycloakSessionFactory(), rehashSession.getContext(), other -> {
UserModel user = user(other, username);
CredentialModel credential = storedPassword(user);
credential.setUserLabel("updated-label");
credential.setCreatedDate(1234L);
user.credentialManager().updateStoredCredential(credential);
}));
assertTrue(isPasswordValid(session, username, OLD_PASSWORD));
});

CredentialModel current = credential(username);
assertEquals(original.getId(), current.getId());
assertEquals("updated-label", current.getUserLabel());
assertEquals(Long.valueOf(1234L), current.getCreatedDate());
assertNotEquals(original.getSecretData(), current.getSecretData(), "Rehash must still update the password hash");
assertEquals(2, PasswordCredentialModel.createFromCredentialModel(current).getPasswordCredentialData().getHashIterations());
runOnServer.run(session -> assertTrue(isPasswordValid(session, username, OLD_PASSWORD)));
}

@Test
public void freshArgon2HashWithRealmIterationsIsNotRehashed() {
assumeTrue(runOnServer.fetch(session -> session.getProvider(PasswordHashProvider.class, "argon2") != null, Boolean.class),
"Argon2 is unavailable in FIPS mode");
String username = "argon2-user";
runOnServer.run(session -> {
RealmModel realm = session.getContext().getRealm();
realm.setPasswordPolicy(PasswordPolicy.parse(session, "hashAlgorithm(argon2) and hashIterations(2)"));
UserModel user = session.users().addUser(realm, username);
assertTrue(user.credentialManager().updateCredential(UserCredentialModel.password(OLD_PASSWORD)));
});
CredentialModel original = credential(username);
assertEquals(2, PasswordCredentialModel.createFromCredentialModel(original).getPasswordCredentialData().getHashIterations());

runOnServer.run(session -> assertTrue(isPasswordValid(session, username, OLD_PASSWORD)));

assertEquals(original.getSecretData(), credential(username).getSecretData(), "An up-to-date credential must not be re-hashed");
}

@AfterEach
public void clearHashAction() {
runOnServer.run(RehashPasswordHashProviderFactory::clearAction);
}

private String prepareOutdatedPassword(boolean federated) {
String username = federated ? NoCredentialUserStorageProviderFactory.USERNAME_PREFIX + "rehash-user" : "rehash-user";
runOnServer.run(session -> {
RealmModel realm = session.getContext().getRealm();
realm.setPasswordPolicy(PasswordPolicy.parse(session, "hashAlgorithm(" + RehashPasswordHashProviderFactory.ID + ") and hashIterations(1)"));
UserModel user;
if (federated) {
ComponentModel component = new ComponentModel();
component.setName("no-credential");
component.setParentId(realm.getId());
component.setProviderType(UserStorageProvider.class.getName());
component.setProviderId(NoCredentialUserStorageProviderFactory.ID);
realm.addComponentModel(component);
user = user(session, username);
} else {
user = session.users().addUser(realm, username);
}
assertEquals(!federated, StorageId.isLocalStorage(user.getId()));
assertTrue(user.credentialManager().updateCredential(UserCredentialModel.password(OLD_PASSWORD)));
realm.setPasswordPolicy(PasswordPolicy.parse(session, "hashAlgorithm(" + RehashPasswordHashProviderFactory.ID + ") and hashIterations(2)"));
});
return username;
}

private CredentialModel credential(String username) {
return runOnServer.fetch(session -> storedPassword(user(session, username)), CredentialModel.class);
}

private static boolean isPasswordValid(KeycloakSession session, String username, String password) {
return user(session, username).credentialManager().isValid(UserCredentialModel.password(password));
}

private static UserModel user(KeycloakSession session, String username) {
return session.users().getUserByUsername(session.getContext().getRealm(), username);
}

private static CredentialModel storedPassword(UserModel user) {
return user.credentialManager().getStoredCredentialsByTypeStream(PasswordCredentialModel.TYPE).findFirst().orElseThrow();
}
}
Loading
Loading
0