8000 Replace a credential only if unchanged when re-hashing a password by ahus1 · Pull Request #55052 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
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
11 changes: 11 additions & 0 deletions docs/documentation/upgrading/topics/changes/changes-26_8_3.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,14 @@ The organization Redirect URL field is now deprecated and will be removed in a f
=== Organization invitation endpoints without `client_id`

The `invite-user` and `invite-existing-user` admin client overloads that do not accept a `client_id` parameter are now deprecated. Use the overloads that accept an explicit `client_id` instead. When omitted, the endpoints default to the `account` client, but callers should migrate to always specify a client.

=== Atomic credential update for custom credential stores
// https://github.com/keycloak/keycloak/issues/55028

The new methods `SubjectCredentialManager.updateStoredCredential(oldCred, newCred)`, `UserCredentialStore.updateCredential(realm, user, oldCred, newCred)` and `UserFederatedUserCredentialStore.updateCredential(realm, userId, oldCred, newCred)` replace a credential only if the stored credential still has the content of `oldCred`, and return `false` otherwise.
{project_name} uses them to re-hash a password after a login, so that a password or label that was changed in the meantime is not overwritten.

Custom implementations of these interfaces should override the new methods, and perform the comparison and the update atomically, for example by locking the stored credential.
The default implementations exist only for backwards compatibility.
They check and update in two separate steps, so a concurrent change can still be lost.
The default implementations will be removed in a future release, at which point custom implementations that do not provide the methods will no longer compile.
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ public void updateStoredCredential(CredentialModel cred) {
super.updateStoredCredential(cred);
}

@Override
public boolean updateStoredCredential(CredentialModel oldCred, CredentialModel newCred) {
invalidateCacheForEntity();
return super.updateStoredCredential(oldCred, newCred);
}

@Override
public CredentialModel createStoredCredential(CredentialModel cred) {
invalidateCacheForEntity();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import org.keycloak.credential.UserCredentialStore;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelDuplicateException;
import org.keycloak.models.ModelIllegalStateException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.jpa.entities.CredentialEntity;
Expand Down Expand Up @@ -75,6 +76,34 @@ public void updateCredential(RealmModel realm, UserModel user, CredentialModel c
entity.setCredentialData(cred.getCredentialData());
}

@Override
public boolean updateCredential(RealmModel realm, UserModel user, CredentialModel oldCred, CredentialModel newCred) {
CredentialEntity entity = em.find(CredentialEntity.class, oldCred.getId());
if (!checkCredentialEntity(entity, user)) return false;
// write pending changes first, as the refresh discards them
em.flush();
// Lock the row and read its current state from the database, to compare with the latest committed version.
// If this transaction already holds the lock, the state is current. Skip the refresh in that case: Hibernate
// reloads an already locked entity with a plain read, and on MySQL/MariaDB under REPEATABLE READ that read
// is served from the transaction snapshot taken earlier, which can miss rows committed since then.
if (em.getLockMode(entity) != LockModeType.PESSIMISTIC_WRITE) {
try {
em.refresh(entity, LockModeType.PESSIMISTIC_WRITE);
} catch (ModelIllegalStateException e) {
// MariaDB with snapshot isolation refuses to lock a row that was changed since the snapshot of this
// transaction was taken ("Record has changed since last read"), which means the credential was changed.
// Only report that if the transaction is still usable, otherwise the failure is a different problem.
if (session.getTransactionManager().getRollbackOnly()) {
throw e;
}
return false;
}
}
if (!toModel(entity).hasSameContent(oldCred)) return false;
updateCredential(realm, user, newCred);
return true;
}

@Override
public CredentialModel createCredential(RealmModel realm, UserModel user, CredentialModel cred) {
CredentialEntity entity = createCredentialEntity(realm, user, cred);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,11 @@ public void updateCredential(RealmModel realm, UserModel user, CredentialModel c
credentialStore.updateCredential(realm, user, cred);
}

@Override
public boolean updateCredential(RealmModel realm, UserModel user, CredentialModel oldCred, CredentialModel newCred) {
return credentialStore.updateCredential(realm, user, oldCred, newCred);
}

@Override
public CredentialModel createCredential(RealmModel realm, UserModel user, CredentialModel cred) {
CredentialEntity entity = credentialStore.createCredentialEntity(realm, user, cred);
< 10BC8 a href="#diff-e5737e47c97cffabf466eab5aeb754e79ce19152a36cc8607e8a198aae75025b" id="expand-down-link--diff-e5737e47c97cffabf466eab5aeb754e79ce19152a36cc8607e8a198aae75025b" class="js-expand directional-expander single-expander" aria-label="Expand Down" data-url="/keycloak/keycloak/blob_excerpt/004da9242449817e32693e7e573378053e7a084c?context=pull_request&diff=unified&direction=down&in_wiki_context&last_left=1089&last_right=1094&left=1419&left_hunk_size&mode=100755&path=model%2Fjpa%2Fsrc%2Fmain%2Fjava%2Forg%2Fkeycloak%2Fmodels%2Fjpa%2FJpaUserProvider.java&pull_request_id=5262880182&right=1419&right_hunk_size" data-left-range="1090-1418" data-right-range="1095-1418"> Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelDuplicateException;
import org.keycloak.models.ModelException;
import org.keycloak.models.ModelIllegalStateException;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
Expand Down Expand Up @@ -604,6 +605,34 @@ public void updateCredential(RealmModel realm, String userId, CredentialModel cr
entity.setUserLabel(cred.getUserLabel());
}

@Override
public boolean updateCredential(RealmModel realm, String userId, CredentialModel oldCred, CredentialModel newCred) {
FederatedUserCredentialEntity entity = em.find(FederatedUserCredentialEntity.class, oldCred.getId());
if (!checkCredentialEntity(entity, userId)) return false;
// write pending changes first, as the refresh discards them
em.flush();
// Lock the row and read its current state from the database, to compare with the latest committed version.
// If this transaction already holds the lock, the state is current. Skip the refresh in that case: Hibernate
// reloads an already locked entity with a plain read, and on MySQL/MariaDB under REPEATABLE READ that read
// is served from the transaction snapshot taken earlier, which can miss rows committed since then.
if (em.getLockMode(entity) != LockModeType.PESSIMISTIC_WRITE) {
try {
em.refresh(entity, LockModeType.PESSIMISTIC_WRITE);
} catch (ModelIllegalStateException e) {
// MariaDB with snapshot isolation refuses to lock a row that was changed since the snapshot of this
// transaction was taken ("Record has changed since last read"), which means the credential was changed.
// Only report that if the transaction is still usable, otherwise the failure is a different problem.
if (session.getTransactionManager().getRollbackOnly()) {
throw e;
}
return false;
}
}
if (!toModel(entity).hasSameContent(oldCred)) return false;
updateCredential(realm, userId, newCred);
return true;
}

/**
* Validates if a credential with the same user label already exists for the given user.
* Excludes the credential itself if updating an existing one.
Expand Down Expand Up @@ -761,6 +790,11 @@ public void updateCredential(RealmModel realm, UserModel user, CredentialModel c
updateCredential(realm, user.getId(), cred);
}

@Override
public boolean updateCredential(RealmModel realm, UserModel user, CredentialModel oldCred, CredentialModel newCred) {
return updateCredential(realm, user.getId(), oldCred, newCred);
}

@Override
public CredentialModel createCredential(RealmModel realm, UserModel user, CredentialModel cred) {
return createCredential(realm, user.getId(), cred);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ public void updateStoredCredential(CredentialModel cred) {
getStoreForUser(user).updateCredential(realm, user, cred);
}

@Override
public boolean updateStoredCredential(CredentialModel oldCred, CredentialModel newCred) {
throwExceptionIfInvalidUser(user);
return getStoreForUser(user).updateCredential(realm, user, oldCred, newCred);
}

@Override
public CredentialModel createStoredCredential(CredentialModel cred) {
throwExceptionIfInvalidUser(user);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,31 @@
*/
public interface UserFederatedUserCredentialStore extends Provider {
void updateCredential(RealmModel realm, String userId, CredentialModel cred);

/**
* Replaces a stored credential with a new version, but only if it still has the content of the old version.
* <p>
* Implementations should make the check and the replacement atomic, for example by locking the stored credential.
* The default implementation is not atomic, and a change that is committed between the check and the update can be lost.
* <p>
* The default implementation exists for backwards compatibility. It should be removed in the next major release,
* to force implementations to provide an atomic implementation.
*
* @param realm the realm
* @param userId the user id
* @param oldCred the credential as it was read, identified by its id
* @param newCred the credential to store instead
* @return {@code true} if the credential was replaced, {@code false} if it was changed or removed in the meantime
*/
default boolean updateCredential(RealmModel realm, String userId, CredentialModel oldCred, CredentialModel newCred) {
CredentialModel current = getStoredCredentialById(realm, userId, oldCred.getId());
if (current == null || !current.hasSameContent(oldCred)) {
return false;
}
updateCredential(realm, userId, newCred);
return true;
}

CredentialModel createCredential(RealmModel realm, String userId, CredentialModel cred);
boolean removeStoredCredential(RealmModel realm, String userId, String id);
CredentialModel getStoredCredentialById(RealmModel realm, String userId, String id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ public void updateStoredCredential(CredentialModel cred) {
// no-op
}

@Override
public boolean updateStoredCredential(CredentialModel oldCred, CredentialModel newCred) {
return false;
}

@Override
public CredentialModel createStoredCredential(CredentialModel cred) {
// no-op
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import java.util.Comparator;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.util.JsonSerialization;
Expand Down Expand Up @@ -83,6 +84,19 @@ public CredentialModel shallowClone() {
return res;
}

/**
* Checks if the other credential has the same type, label, creation date, secret data and credential data, which
* means that both describe the same state of a stored credential.
*/
public boolean hasSameContent(CredentialModel other) {
return other != null
&& Objects.equals(type, other.type)
&& Objects.equals(userLabel, other.userLabel)
&& Objects.equals(createdDate, other.createdDate)
&& Objects.equals(secretData, other.secretData)
&& Objects.equals(credentialData, other.credentialData);
}

public String getId() {
return id;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,31 @@
*/
public interface UserCredentialStore extends Provider {
void updateCredential(RealmModel realm, UserModel user, CredentialModel cred);

/**
* Replaces a stored credential with a new version, but only if it still has the content of the old version.
* <p>
* Implementations should make the check and the replacement atomic, for example by locking the stored credential.
* The default implementation is not atomic, and a change that is committed between the check and the update can be lost.
* <p>
* The default implementation exists for backwards compatibility. It should be removed in the next major release,
* to force implementations to provide an atomic implementation.
*
* @param realm the realm
* @param user the user
* @param oldCred the credential as it was read, identified by its id
* @param newCred the credential to store instead
* @return {@code true} if the credential was replaced, {@code false} if it was changed or removed in the meantime
*/
default boolean updateCredential(RealmModel realm, UserModel user, CredentialModel oldCred, CredentialModel newCred) {
CredentialModel current = getStoredCredentialById(realm, user, oldCred.getId());
if (current == null || !current.hasSameContent(oldCred)) {
return false;
}
updateCredential(realm, user, newCred);
return true;
}

CredentialModel createCredential(RealmModel realm, UserModel user, CredentialModel cred);

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,30 @@ default boolean isValid(CredentialInput... inputs) {
*/
void updateStoredCredential(CredentialModel cred);

/**
* Replaces a credential of the entity with a new version, but only if the stored credential still has the content
* of the old version. Use this to update a credential that was read earlier, so that changes made in the meantime
* are not overwritten.
* <p>
* Implementations writing to the Keycloak credential store replace the credential atomically. The default
* implementation is not atomic, and a change that is committed between the check and the update can be lost.
* <p>
* The default implementation exists for backwards compatibility. It should be removed in the next major release,
* to force implementations to provide an atomic implementation.
*
* @param oldCred the credential as it was read, identified by its id
* @param newCred the credential to store instead
* @return {@code true} if the credential was replaced, {@code false} if it was changed or removed in the meantime
*/
default boolean updateStoredCredential(CredentialModel oldCred, CredentialModel newCred) {
CredentialModel current = getStoredCredentialById(oldCred.getId());
if (current == null || !current.hasSameContent(oldCred)) {
return false;
}
updateStoredCredential(newCred);
return true;
}

/**
* Updates a credential of the entity with an updated {@link CredentialModel}.
* Usually called by a {@link org.keycloak.credential.CredentialProvider}.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,8 +294,7 @@ private void rehashPasswordIfRequired(KeycloakSession session, RealmModel realm,
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()));
(KeycloakSession s) -> refreshPassword(s, hashAlgorithm, iterations, input.getChallengeResponse(), password, user.getId()));
} catch (ModelException e) {
logger.info("Error re-hashing the password in a different transaction", e);
}
Expand All @@ -310,17 +309,18 @@ protected void rollbackImpl() {
}

private static void refreshPassword(KeycloakSession s, String hashAlgorithm, int iterations, String challenge,
String passwordId, Long passwordDate, String passwordLabel, String userId) {
PasswordCredentialModel oldPassword, String userId) {
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);
newPassword.setId(oldPassword.getId());
newPassword.setCreatedDate(oldPassword.getCreatedDate());
newPassword.setUserLabel(oldPassword.getUserLabel());
UserModel userModel = s.users().getUserById(s.getContext().getRealm(), userId);
if (userModel != null) {
userModel.credentialManager().updateStoredCredential(newPassword);
if (userModel != null && !userModel.credentialManager().updateStoredCredential(oldPassword, newPassword)) {
// the password was changed or removed after it was validated, the new hash would overwrite that change
logger.debugf("Skipped re-hashing the password of user %s as the credential was changed in the meantime", userId);
}
}

Expand Down
Loading
Loading
0