-
Notifications
You must be signed in to change notification settings - Fork 0
Implement recovery key support for user storage providers #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feature-recovery-keys-foundation
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,16 +5,18 @@ | |||||||||||||||
| import org.keycloak.models.RealmModel; | ||||||||||||||||
| import org.keycloak.models.UserModel; | ||||||||||||||||
| import org.keycloak.models.credential.RecoveryAuthnCodesCredentialModel; | ||||||||||||||||
| import org.keycloak.models.utils.RecoveryAuthnCodesUtils; | ||||||||||||||||
|
|
||||||||||||||||
| import java.util.Optional; | ||||||||||||||||
|
|
||||||||||||||||
| public class RecoveryAuthnCodeInputLoginBean { | ||||||||||||||||
|
|
||||||||||||||||
| private final int codeNumber; | ||||||||||||||||
|
|
||||||||||||||||
|
Comment on lines
13
to
15
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: Calling
Suggested change
Prompt To Fix With AIThis is a comment left during a code review.
Path: services/src/main/java/org/keycloak/forms/login/freemarker/model/RecoveryAuthnCodeInputLoginBean.java
Line: 13:15
Comment:
**logic:** Calling `.get()` on `Optional` without checking `isPresent()` first will throw `NoSuchElementException` if credential not found
```suggestion
Optional<CredentialModel> credentialModelOpt = RecoveryAuthnCodesUtils.getCredential(user);
if (!credentialModelOpt.isPresent()) {
throw new RuntimeException("Recovery codes credential not found for user");
}
RecoveryAuthnCodesCredentialModel recoveryCodeCredentialModel = RecoveryAuthnCodesCredentialModel.createFromCredentialModel(credentialModelOpt.get());
```
How can I resolve this? If you propose a fix, please make it concise. |
||||||||||||||||
| public RecoveryAuthnCodeInputLoginBean(KeycloakSession session, RealmModel realm, UserModel user) { | ||||||||||||||||
| CredentialModel credentialModel = user.credentialManager().getStoredCredentialsByTypeStream(RecoveryAuthnCodesCredentialModel.TYPE) | ||||||||||||||||
| .findFirst().get(); | ||||||||||||||||
| Optional<CredentialModel> credentialModelOpt = RecoveryAuthnCodesUtils.getCredential(user); | ||||||||||||||||
|
|
||||||||||||||||
| RecoveryAuthnCodesCredentialModel recoveryCodeCredentialModel = RecoveryAuthnCodesCredentialModel.createFromCredentialModel(credentialModel); | ||||||||||||||||
| RecoveryAuthnCodesCredentialModel recoveryCodeCredentialModel = RecoveryAuthnCodesCredentialModel.createFromCredentialModel(credentialModelOpt.get()); | ||||||||||||||||
|
|
||||||||||||||||
| this.codeNumber = recoveryCodeCredentialModel.getNextRecoveryAuthnCode().get().getNumber(); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -18,11 +18,12 @@ | |||||
|
|
||||||
| package org.keycloak.testsuite.federation; | ||||||
|
|
||||||
| import java.io.IOException; | ||||||
| import java.util.ArrayList; | ||||||
| import java.util.HashSet; | ||||||
| import java.util.List; | ||||||
| import java.util.Map; | ||||||
| import java.util.Set; | ||||||
| import java.util.stream.Collectors; | ||||||
| import java.util.stream.Stream; | ||||||
|
|
||||||
| import org.jboss.logging.Logger; | ||||||
|
|
@@ -33,7 +34,6 @@ | |||||
| import org.keycloak.credential.CredentialInputValidator; | ||||||
| import org.keycloak.credential.CredentialModel; | ||||||
| import org.keycloak.credential.hash.PasswordHashProvider; | ||||||
| import org.keycloak.credential.hash.Pbkdf2Sha512PasswordHashProviderFactory; | ||||||
| import org.keycloak.models.GroupModel; | ||||||
| import org.keycloak.models.KeycloakSession; | ||||||
| import org.keycloak.models.OTPPolicy; | ||||||
|
|
@@ -43,6 +43,8 @@ | |||||
| import org.keycloak.models.UserModel; | ||||||
| import org.keycloak.models.cache.UserCache; | ||||||
| import org.keycloak.models.credential.PasswordUserCredentialModel; | ||||||
| import org.keycloak.models.credential.RecoveryAuthnCodesCredentialModel; | ||||||
| import org.keycloak.models.utils.KeycloakModelUtils; | ||||||
| import org.keycloak.models.utils.TimeBasedOTP; | ||||||
| import org.keycloak.storage.StorageId; | ||||||
| import org.keycloak.storage.UserStorageProvider; | ||||||
|
|
@@ -51,11 +53,12 @@ | |||||
| import org.keycloak.storage.user.UserLookupProvider; | ||||||
| import org.keycloak.storage.user.UserQueryProvider; | ||||||
| import org.keycloak.storage.user.UserRegistrationProvider; | ||||||
| import org.keycloak.util.JsonSerialization; | ||||||
|
|
||||||
| /** | ||||||
| * UserStorage implementation created in Keycloak 4.8.3. It is used for backwards compatibility testing. Future Keycloak versions | ||||||
| * should work fine without a need to change the code of this provider. | ||||||
| * | ||||||
| * <p> | ||||||
| * TODO: Have some good mechanims to make sure that source code of this provider is really compatible with Keycloak 4.8.3 | ||||||
| * | ||||||
| * @author <a href="mailto:[email protected]">Marek Posolda</a> | ||||||
|
|
@@ -89,7 +92,7 @@ public UserModel getUserById(RealmModel realm, String id) { | |||||
| } | ||||||
|
|
||||||
| private UserModel createUser(RealmModel realm, String username) { | ||||||
| return new AbstractUserAdapterFederatedStorage(session, realm, model) { | ||||||
| return new AbstractUserAdapterFederatedStorage(session, realm, model) { | ||||||
| @Override | ||||||
| public String getUsername() { | ||||||
| return username; | ||||||
|
|
@@ -107,7 +110,8 @@ public void setUsername(String username1) { | |||||
| @Override | ||||||
| public boolean supportsCredentialType(String credentialType) { | ||||||
| if (CredentialModel.PASSWORD.equals(credentialType) | ||||||
| || isOTPType(credentialType)) { | ||||||
| || isOTPType(credentialType) | ||||||
| || credentialType.equals(RecoveryAuthnCodesCredentialModel.TYPE)) { | ||||||
| return true; | ||||||
| } else { | ||||||
| log.infof("Unsupported credential type: %s", credentialType); | ||||||
|
|
@@ -172,6 +176,7 @@ public boolean updateCredential(RealmModel realm, UserModel user, CredentialInpu | |||||
| OTPPolicy otpPolicy = session.getContext().getRealm().getOTPPolicy(); | ||||||
|
|
||||||
| CredentialModel newOTP = new CredentialModel(); | ||||||
| newOTP.setId(KeycloakModelUtils.generateId()); | ||||||
| newOTP.setType(input.getType()); | ||||||
| long createdDate = Time.currentTimeMillis(); | ||||||
| newOTP.setCreatedDate(createdDate); | ||||||
|
|
@@ -184,6 +189,15 @@ public boolean updateCredential(RealmModel realm, UserModel user, CredentialInpu | |||||
|
|
||||||
| users.get(translateUserName(user.getUsername())).otp = newOTP; | ||||||
|
|
||||||
| return true; | ||||||
| } else if (input.getType().equals(RecoveryAuthnCodesCredentialModel.TYPE)) { | ||||||
| CredentialModel recoveryCodesModel = new CredentialModel(); | ||||||
| recoveryCodesModel.setId(KeycloakModelUtils.generateId()); | ||||||
| recoveryCodesModel.setType(input.getType()); | ||||||
| recoveryCodesModel.setCredentialData(input.getChallengeResponse()); | ||||||
| long createdDate = Time.currentTimeMillis(); | ||||||
| recoveryCodesModel.setCreatedDate(createdDate); | ||||||
| users.get(translateUserName(user.getUsername())).recoveryCodes = recoveryCodesModel; | ||||||
| return true; | ||||||
| } else { | ||||||
| log.infof("Attempt to update unsupported credential of type: %s", input.getType()); | ||||||
|
|
@@ -213,6 +227,30 @@ private MyUser getMyUser(UserModel user) { | |||||
| return users.get(translateUserName(user.getUsername())); | ||||||
| } | ||||||
|
|
||||||
| @Override | ||||||
| public Stream<CredentialModel> getCredentials(RealmModel realm, UserModel user) { | ||||||
| var myUser = getMyUser(user); | ||||||
| RecoveryAuthnCodesCredentialModel model; | ||||||
| List<CredentialModel> credentialModels = new ArrayList<>(); | ||||||
| if (myUser.recoveryCodes != null) { | ||||||
| try { | ||||||
| model = RecoveryAuthnCodesCredentialModel.createFromValues( | ||||||
| JsonSerialization.readValue(myUser.recoveryCodes.getCredentialData(), List.class), | ||||||
| myUser.recoveryCodes.getCreatedDate(), | ||||||
| myUser.recoveryCodes.getUserLabel() | ||||||
| ); | ||||||
| credentialModels.add(model); | ||||||
| } catch (IOException e) { | ||||||
| log.error("Could not deserialize credential of type: recovery-codes"); | ||||||
| } | ||||||
| } | ||||||
| if (myUser.otp != null) { | ||||||
| credentialModels.add(myUser.getOtp()); | ||||||
| } | ||||||
|
|
||||||
| return credentialModels.stream(); | ||||||
| } | ||||||
|
|
||||||
| @Override | ||||||
| public Stream<String> getDisableableCredentialTypesStream(RealmModel realm, UserModel user) { | ||||||
| Set<String> types = new HashSet<>(); | ||||||
|
|
@@ -234,6 +272,8 @@ public boolean isConfiguredFor(RealmModel realm, UserModel user, String credenti | |||||
|
|
||||||
| if (isOTPType(credentialType) && myUser.otp != null) { | ||||||
| return true; | ||||||
| } else if (credentialType.equals(RecoveryAuthnCodesCredentialModel.TYPE) && myUser.recoveryCodes != null) { | ||||||
| return true; | ||||||
| } else { | ||||||
| log.infof("Not supported credentialType '%s' for user '%s'", credentialType, user.getUsername()); | ||||||
| return false; | ||||||
|
|
@@ -283,7 +323,22 @@ public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) | |||||
| TimeBasedOTP validator = new TimeBasedOTP(storedOTPCredential.getAlgorithm(), storedOTPCredential.getDigits(), | ||||||
| storedOTPCredential.getPeriod(), realm.getOTPPolicy().getLookAheadWindow()); | ||||||
| return validator.validateTOTP(otpCredential.getValue(), storedOTPCredential.getValue().getBytes()); | ||||||
| } else { | ||||||
| } else if (input.getType().equals(RecoveryAuthnCodesCredentialModel.TYPE)) { | ||||||
| CredentialModel storedRecoveryKeys = myUser.recoveryCodes; | ||||||
| if (storedRecoveryKeys == null) { | ||||||
| log.warnf("Not found credential for the user %s", user.getUsername()); | ||||||
| return false; | ||||||
| } | ||||||
| List generatedKeys; | ||||||
| try { | ||||||
| generatedKeys = JsonSerialization.readValue(storedRecoveryKeys.getCredentialData(), List.class); | ||||||
| } catch (IOException e) { | ||||||
| log.warnf("Cannot deserialize recovery keys credential for the user %s", user.getUsername()); | ||||||
| return false; | ||||||
| } | ||||||
|
|
||||||
| return generatedKeys.stream().anyMatch(key -> key.equals(input.getChallengeResponse())); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: Plain text comparison used instead of hash verification. Should use
Suggested change
Prompt To Fix With AIThis is a comment left during a code review.
Path: testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorage.java
Line: 340:340
Comment:
**logic:** Plain text comparison used instead of hash verification. Should use `RecoveryAuthnCodesUtils.verifyRecoveryCodeInput()` to validate hashed codes consistently with the main credential provider.
```suggestion
return generatedKeys.stream().anyMatch(key -> RecoveryAuthnCodesUtils.verifyRecoveryCodeInput(input.getChallengeResponse(), key));
```
How can I resolve this? If you propose a fix, please make it concise. |
||||||
| } else { | ||||||
| log.infof("Not supported to validate credential of type '%s' for user '%s'", input.getType(), user.getUsername()); | ||||||
| return false; | ||||||
| } | ||||||
|
|
@@ -369,6 +424,7 @@ static class MyUser { | |||||
| private String username; | ||||||
| private CredentialModel hashedPassword; | ||||||
| private CredentialModel otp; | ||||||
| private CredentialModel recoveryCodes; | ||||||
|
|
||||||
| private MyUser(String username) { | ||||||
| this.username = username; | ||||||
|
|
@@ -377,6 +433,10 @@ private MyUser(String username) { | |||||
| public CredentialModel getOtp() { | ||||||
| return otp; | ||||||
| } | ||||||
|
|
||||||
| public CredentialModel getRecoveryCodes() { | ||||||
| return recoveryCodes; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
|
|
||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: Storing unhashed recovery codes in user storage is a critical security vulnerability. The
generatedCodeslist contains plain text codes, but user storage providers may persist these to their backend systems. When stored in local DB viacreateCredential, codes are hashed (viaRecoveryAuthnCodesCredentialModel.createFromValues), but when stored in user storage viaupdateCredential, they remain unhashed.Prompt To Fix With AI