-
Notifications
You must be signed in to change notification settings - Fork 0
Automated Test: feature-recovery-keys-implementation #308
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 |
|---|---|---|
|
|
@@ -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:mposolda@redhat.com">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(); | ||
| } | ||
|
Comment on lines
+230
to
+252
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. Null-pointer risk:
Proposed fix public Stream<CredentialModel> getCredentials(RealmModel realm, UserModel user) {
var myUser = getMyUser(user);
+ if (myUser == null) return Stream.empty();
RecoveryAuthnCodesCredentialModel model;
List<CredentialModel> credentialModels = new ArrayList<>();🤖 Prompt for AI Agents |
||
|
|
||
| @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())); | ||
| } else { | ||
|
Comment on lines
+326
to
+341
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. Recovery code validation doesn't consume the used code. The standard This may be acceptable for the current test scope — flagging for awareness. 🤖 Prompt for AI Agents |
||
| 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.
Unchecked
Optional.get()will throwNoSuchElementExceptionif credential is missing.credentialModelOpt.get()on Line 19 is called without a presence check. While this bean should only be constructed when the user has recovery codes configured, a race condition or storage inconsistency could leave the Optional empty, resulting in a 500 error during login.Proposed fix — fail with a clear message
📝 Committable suggestion
🤖 Prompt for AI Agents