Skip to content

Commit 134437a

Browse files
authored
Create recovery keys in user storage or local (#38446)
closes #38445 Signed-off-by: rtufisi <[email protected]>
1 parent a0852ea commit 134437a

File tree

8 files changed

+256
-31
lines changed

8 files changed

+256
-31
lines changed

server-spi-private/src/main/java/org/keycloak/utils/CredentialHelper.java

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,12 @@
3333
import org.keycloak.models.UserCredentialModel;
3434
import org.keycloak.models.UserModel;
3535
import org.keycloak.models.credential.OTPCredentialModel;
36+
import org.keycloak.models.credential.RecoveryAuthnCodesCredentialModel;
3637
import org.keycloak.representations.idm.CredentialRepresentation;
38+
import org.keycloak.util.JsonSerialization;
3739

40+
import java.io.IOException;
41+
import java.util.List;
3842
import java.util.Objects;
3943

4044
/**
@@ -69,15 +73,15 @@ public static void setOrReplaceAuthenticationRequirement(KeycloakSession session
6973
}));
7074
}
7175

72-
public static ConfigurableAuthenticatorFactory getConfigurableAuthenticatorFactory(KeycloakSession session, String providerId) {
73-
ConfigurableAuthenticatorFactory factory = (AuthenticatorFactory)session.getKeycloakSessionFactory().getProviderFactory(Authenticator.class, providerId);
74-
if (factory == null) {
75-
factory = (FormActionFactory)session.getKeycloakSessionFactory().getProviderFactory(FormAction.class, providerId);
76-
}
77-
if (factory == null) {
78-
factory = (ClientAuthenticatorFactory)session.getKeycloakSessionFactory().getProviderFactory(ClientAuthenticator.class, providerId);
79-
}
80-
return factory;
76+
public static ConfigurableAuthenticatorFactory getConfigurableAuthenticatorFactory(KeycloakSession session, String providerId) {
77+
ConfigurableAuthenticatorFactory factory = (AuthenticatorFactory)session.getKeycloakSessionFactory().getProviderFactory(Authenticator.class, providerId);
78+
if (factory == null) {
79+
factory = (FormActionFactory)session.getKeycloakSessionFactory().getProviderFactory(FormAction.class, providerId);
80+
}
81+
if (factory == null) {
82+
factory = (ClientAuthenticatorFactory)session.getKeycloakSessionFactory().getProviderFactory(ClientAuthenticator.class, providerId);
83+
}
84+
return factory;
8185
}
8286

8387
/**
@@ -105,6 +109,27 @@ public static boolean createOTPCredential(KeycloakSession session, RealmModel re
105109
return user.credentialManager().isValid(credential);
106110
}
107111

112+
/**
113+
* Create RecoveryCodes credential either in userStorage or local storage (Keycloak DB)
114+
*/
115+
public static void createRecoveryCodesCredential(KeycloakSession session, RealmModel realm, UserModel user, RecoveryAuthnCodesCredentialModel credentialModel, List<String> generatedCodes) {
116+
var recoveryCodeCredentialProvider = session.getProvider(CredentialProvider.class, "keycloak-recovery-authn-codes");
117+
String recoveryCodesJson;
118+
try {
119+
recoveryCodesJson = JsonSerialization.writeValueAsString(generatedCodes);
120+
} catch (IOException e) {
121+
throw new RuntimeException(e);
122+
}
123+
UserCredentialModel recoveryCodesCredential = new UserCredentialModel("", credentialModel.getType(), recoveryCodesJson);
124+
125+
boolean userStorageCreated = user.credentialManager().updateCredential(recoveryCodesCredential);
126+
if (userStorageCreated) {
127+
logger.debugf("Created RecoveryCodes credential for user '%s' in the user storage", user.getUsername());
128+
} else {
129+
recoveryCodeCredentialProvider.createCredential(realm, user, credentialModel);
130+
}
131+
}
132+
108133
/**
109134
* Create "dummy" representation of the credential. Typically used when credential is provided by userStorage and we don't know further
110135
* details about the credential besides the type

server-spi/src/main/java/org/keycloak/models/utils/RecoveryAuthnCodesUtils.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package org.keycloak.models.utils;
22

3+
import java.util.Optional;
34
import java.util.function.Supplier;
45
import org.keycloak.common.util.Base64;
56
import org.keycloak.common.util.SecretGenerator;
7+
import org.keycloak.credential.CredentialModel;
68
import org.keycloak.crypto.Algorithm;
79
import org.keycloak.crypto.JavaAlgorithm;
810
import org.keycloak.jose.jws.crypto.HashUtils;
11+
import org.keycloak.models.UserModel;
12+
import org.keycloak.models.credential.RecoveryAuthnCodesCredentialModel;
913

1014
import java.nio.charset.StandardCharsets;
1115
import java.util.List;
@@ -43,4 +47,17 @@ public static List<String> generateRawCodes() {
4347
return Stream.generate(code).limit(QUANTITY_OF_CODES_TO_GENERATE).collect(Collectors.toList());
4448
}
4549

50+
/**
51+
* Checks the user storage for the credential. If not found it will look for the credential in the local storage
52+
*
53+
* @param user - User model
54+
* @return - a optional credential model
55+
*/
56+
public static Optional<CredentialModel> getCredential(UserModel user) {
57+
return user.credentialManager()
58+
.getFederatedCredentialsStream()
59+
.filter(c -> RecoveryAuthnCodesCredentialModel.TYPE.equals(c.getType()))
60+
.findFirst()
61+
.or(() -> user.credentialManager().getStoredCredentialsByTypeStream(RecoveryAuthnCodesCredentialModel.TYPE).findFirst());
62+
}
4663
}

services/src/main/java/org/keycloak/authentication/authenticators/browser/RecoveryAuthnCodesFormAuthenticator.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,7 @@ private boolean isRecoveryAuthnCodeInputValid(AuthenticationFlowContext authnFlo
7777
authnFlowContext.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, responseChallenge);
7878
} else {
7979
result = true;
80-
Optional<CredentialModel> optUserCredentialFound = authenticatedUser.credentialManager().getStoredCredentialsByTypeStream(
81-
RecoveryAuthnCodesCredentialModel.TYPE).findFirst();
80+
Optional<CredentialModel> optUserCredentialFound = RecoveryAuthnCodesUtils.getCredential(authenticatedUser);
8281
RecoveryAuthnCodesCredentialModel recoveryCodeCredentialModel = null;
8382
if (optUserCredentialFound.isPresent()) {
8483
recoveryCodeCredentialModel = RecoveryAuthnCodesCredentialModel

services/src/main/java/org/keycloak/authentication/requiredactions/RecoveryAuthnCodesAction.java

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import java.util.Arrays;
44
import java.util.List;
5+
56
import org.keycloak.Config;
67
import org.keycloak.authentication.AuthenticatorUtil;
78
import org.keycloak.authentication.CredentialRegistrator;
@@ -11,8 +12,6 @@
1112
import org.keycloak.authentication.RequiredActionProvider;
1213
import org.keycloak.authentication.authenticators.browser.RecoveryAuthnCodesFormAuthenticator;
1314
import org.keycloak.common.Profile;
14-
import org.keycloak.credential.RecoveryAuthnCodesCredentialProviderFactory;
15-
import org.keycloak.credential.CredentialProvider;
1615
import org.keycloak.events.Details;
1716
import org.keycloak.events.EventBuilder;
1817
import org.keycloak.events.EventType;
@@ -26,6 +25,8 @@
2625
import jakarta.ws.rs.core.Response;
2726
import org.keycloak.sessions.AuthenticationSessionModel;
2827

28+
import static org.keycloak.utils.CredentialHelper.createRecoveryCodesCredential;
29+
2930
public class RecoveryAuthnCodesAction implements RequiredActionProvider, RequiredActionFactory, EnvironmentDependentProviderFactory, CredentialRegistrator {
3031

3132
private static final String FIELD_GENERATED_RECOVERY_AUTHN_CODES_HIDDEN = "generatedRecoveryAuthnCodes";
@@ -86,13 +87,8 @@ public void requiredActionChallenge(RequiredActionContext context) {
8687
public void processAction(RequiredActionContext reqActionContext) {
8788
EventBuilder event = reqActionContext.getEvent();
8889
event.event(EventType.UPDATE_CREDENTIAL);
89-
90-
CredentialProvider recoveryCodeCredentialProvider;
9190
MultivaluedMap<String, String> httpReqParamsMap;
9291

93-
recoveryCodeCredentialProvider = reqActionContext.getSession().getProvider(CredentialProvider.class,
94-
RecoveryAuthnCodesCredentialProviderFactory.PROVIDER_ID);
95-
9692
event.detail(Details.CREDENTIAL_TYPE, RecoveryAuthnCodesCredentialModel.TYPE);
9793

9894
httpReqParamsMap = reqActionContext.getHttpRequest().getDecodedFormParameters();
@@ -117,8 +113,7 @@ public void processAction(RequiredActionContext reqActionContext) {
117113
AuthenticatorUtil.logoutOtherSessions(reqActionContext);
118114
}
119115

120-
recoveryCodeCredentialProvider.createCredential(reqActionContext.getRealm(), reqActionContext.getUser(),
121-
credentialModel);
116+
createRecoveryCodesCredential(reqActionContext.getSession(), reqActionContext.getRealm(), reqActionContext.getUser(), credentialModel, generatedCodes);
122117

123118
reqActionContext.success();
124119
}

services/src/main/java/org/keycloak/forms/login/freemarker/model/RecoveryAuthnCodeInputLoginBean.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,18 @@
55
import org.keycloak.models.RealmModel;
66
import org.keycloak.models.UserModel;
77
import org.keycloak.models.credential.RecoveryAuthnCodesCredentialModel;
8+
import org.keycloak.models.utils.RecoveryAuthnCodesUtils;
9+
10+
import java.util.Optional;
811

912
public class RecoveryAuthnCodeInputLoginBean {
1013

1114
private final int codeNumber;
1215

1316
public RecoveryAuthnCodeInputLoginBean(KeycloakSession session, RealmModel realm, UserModel user) {
14-
CredentialModel credentialModel = user.credentialManager().getStoredCredentialsByTypeStream(RecoveryAuthnCodesCredentialModel.TYPE)
15-
.findFirst().get();
17+
Optional<CredentialModel> credentialModelOpt = RecoveryAuthnCodesUtils.getCredential(user);
1618

17-
RecoveryAuthnCodesCredentialModel recoveryCodeCredentialModel = RecoveryAuthnCodesCredentialModel.createFromCredentialModel(credentialModel);
19+
RecoveryAuthnCodesCredentialModel recoveryCodeCredentialModel = RecoveryAuthnCodesCredentialModel.createFromCredentialModel(credentialModelOpt.get());
1820

1921
this.codeNumber = recoveryCodeCredentialModel.getNextRecoveryAuthnCode().get().getNumber();
2022
}

testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorage.java

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@
1818

1919
package org.keycloak.testsuite.federation;
2020

21+
import java.io.IOException;
22+
import java.util.ArrayList;
2123
import java.util.HashSet;
2224
import java.util.List;
2325
import java.util.Map;
2426
import java.util.Set;
25-
import java.util.stream.Collectors;
2627
import java.util.stream.Stream;
2728

2829
import org.jboss.logging.Logger;
@@ -33,7 +34,6 @@
3334
import org.keycloak.credential.CredentialInputValidator;
3435
import org.keycloak.credential.CredentialModel;
3536
import org.keycloak.credential.hash.PasswordHashProvider;
36-
import org.keycloak.credential.hash.Pbkdf2Sha512PasswordHashProviderFactory;
3737
import org.keycloak.models.GroupModel;
3838
import org.keycloak.models.KeycloakSession;
3939
import org.keycloak.models.OTPPolicy;
@@ -43,6 +43,8 @@
4343
import org.keycloak.models.UserModel;
4444
import org.keycloak.models.cache.UserCache;
4545
import org.keycloak.models.credential.PasswordUserCredentialModel;
46+
import org.keycloak.models.credential.RecoveryAuthnCodesCredentialModel;
47+
import org.keycloak.models.utils.KeycloakModelUtils;
4648
import org.keycloak.models.utils.TimeBasedOTP;
4749
import org.keycloak.storage.StorageId;
4850
import org.keycloak.storage.UserStorageProvider;
@@ -51,11 +53,12 @@
5153
import org.keycloak.storage.user.UserLookupProvider;
5254
import org.keycloak.storage.user.UserQueryProvider;
5355
import org.keycloak.storage.user.UserRegistrationProvider;
56+
import org.keycloak.util.JsonSerialization;
5457

5558
/**
5659
* UserStorage implementation created in Keycloak 4.8.3. It is used for backwards compatibility testing. Future Keycloak versions
5760
* should work fine without a need to change the code of this provider.
58-
*
61+
* <p>
5962
* TODO: Have some good mechanims to make sure that source code of this provider is really compatible with Keycloak 4.8.3
6063
*
6164
* @author <a href="mailto:[email protected]">Marek Posolda</a>
@@ -89,7 +92,7 @@ public UserModel getUserById(RealmModel realm, String id) {
8992
}
9093

9194
private UserModel createUser(RealmModel realm, String username) {
92-
return new AbstractUserAdapterFederatedStorage(session, realm, model) {
95+
return new AbstractUserAdapterFederatedStorage(session, realm, model) {
9396
@Override
9497
public String getUsername() {
9598
return username;
@@ -107,7 +110,8 @@ public void setUsername(String username1) {
107110
@Override
108111
public boolean supportsCredentialType(String credentialType) {
109112
if (CredentialModel.PASSWORD.equals(credentialType)
110-
|| isOTPType(credentialType)) {
113+
|| isOTPType(credentialType)
114+
|| credentialType.equals(RecoveryAuthnCodesCredentialModel.TYPE)) {
111115
return true;
112116
} else {
113117
log.infof("Unsupported credential type: %s", credentialType);
@@ -172,6 +176,7 @@ public boolean updateCredential(RealmModel realm, UserModel user, CredentialInpu
172176
OTPPolicy otpPolicy = session.getContext().getRealm().getOTPPolicy();
173177

174178
CredentialModel newOTP = new CredentialModel();
179+
newOTP.setId(KeycloakModelUtils.generateId());
175180
newOTP.setType(input.getType());
176181
long createdDate = Time.currentTimeMillis();
177182
newOTP.setCreatedDate(createdDate);
@@ -184,6 +189,15 @@ public boolean updateCredential(RealmModel realm, UserModel user, CredentialInpu
184189

185190
users.get(translateUserName(user.getUsername())).otp = newOTP;
186191

192+
return true;
193+
} else if (input.getType().equals(RecoveryAuthnCodesCredentialModel.TYPE)) {
194+
CredentialModel recoveryCodesModel = new CredentialModel();
195+
recoveryCodesModel.setId(KeycloakModelUtils.generateId());
196+
recoveryCodesModel.setType(input.getType());
197+
recoveryCodesModel.setCredentialData(input.getChallengeResponse());
198+
long createdDate = Time.currentTimeMillis();
199+
recoveryCodesModel.setCreatedDate(createdDate);
200+
users.get(translateUserName(user.getUsername())).recoveryCodes = recoveryCodesModel;
187201
return true;
188202
} else {
189203
log.infof("Attempt to update unsupported credential of type: %s", input.getType());
@@ -213,6 +227,30 @@ private MyUser getMyUser(UserModel user) {
213227
return users.get(translateUserName(user.getUsername()));
214228
}
215229

230+
@Override
231+
public Stream<CredentialModel> getCredentials(RealmModel realm, UserModel user) {
232+
var myUser = getMyUser(user);
233+
RecoveryAuthnCodesCredentialModel model;
234+
List<CredentialModel> credentialModels = new ArrayList<>();
235+
if (myUser.recoveryCodes != null) {
236+
try {
237+
model = RecoveryAuthnCodesCredentialModel.createFromValues(
238+
JsonSerialization.readValue(myUser.recoveryCodes.getCredentialData(), List.class),
239+
myUser.recoveryCodes.getCreatedDate(),
240+
myUser.recoveryCodes.getUserLabel()
241+
);
242+
credentialModels.add(model);
243+
} catch (IOException e) {
244+
log.error("Could not deserialize credential of type: recovery-codes");
245+
}
246+
}
247+
if (myUser.otp != null) {
248+
credentialModels.add(myUser.getOtp());
249+
}
250+
251+
return credentialModels.stream();
252+
}
253+
216254
@Override
217255
public Stream<String> getDisableableCredentialTypesStream(RealmModel realm, UserModel user) {
218256
Set<String> types = new HashSet<>();
@@ -234,6 +272,8 @@ public boolean isConfiguredFor(RealmModel realm, UserModel user, String credenti
234272

235273
if (isOTPType(credentialType) && myUser.otp != null) {
236274
return true;
275+
} else if (credentialType.equals(RecoveryAuthnCodesCredentialModel.TYPE) && myUser.recoveryCodes != null) {
276+
return true;
237277
} else {
238278
log.infof("Not supported credentialType '%s' for user '%s'", credentialType, user.getUsername());
239279
return false;
@@ -283,7 +323,22 @@ public boolean isValid(RealmModel realm, UserModel user, CredentialInput input)
283323
TimeBasedOTP validator = new TimeBasedOTP(storedOTPCredential.getAlgorithm(), storedOTPCredential.getDigits(),
284324
storedOTPCredential.getPeriod(), realm.getOTPPolicy().getLookAheadWindow());
285325
return validator.validateTOTP(otpCredential.getValue(), storedOTPCredential.getValue().getBytes());
286-
} else {
326+
} else if (input.getType().equals(RecoveryAuthnCodesCredentialModel.TYPE)) {
327+
CredentialModel storedRecoveryKeys = myUser.recoveryCodes;
328+
if (storedRecoveryKeys == null) {
329+
log.warnf("Not found credential for the user %s", user.getUsername());
330+
return false;
331+
}
332+
List generatedKeys;
333+
try {
334+
generatedKeys = JsonSerialization.readValue(storedRecoveryKeys.getCredentialData(), List.class);
335+
} catch (IOException e) {
336+
log.warnf("Cannot deserialize recovery keys credential for the user %s", user.getUsername());
337+
return false;
338+
}
339+
340+
return generatedKeys.stream().anyMatch(key -> key.equals(input.getChallengeResponse()));
341+
} else {
287342
log.infof("Not supported to validate credential of type '%s' for user '%s'", input.getType(), user.getUsername());
288343
return false;
289344
}
@@ -369,6 +424,7 @@ static class MyUser {
369424
private String username;
370425
private CredentialModel hashedPassword;
371426
private CredentialModel otp;
427+
private CredentialModel recoveryCodes;
372428

373429
private MyUser(String username) {
374430
this.username = username;
@@ -377,6 +433,10 @@ private MyUser(String username) {
377433
public CredentialModel getOtp() {
378434
return otp;
379435
}
436+
437+
public CredentialModel getRecoveryCodes() {
438+
return recoveryCodes;
439+
}
380440
}
381441

382442

testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorageFactory.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,9 @@ public boolean hasUserOTP(String username) {
5050
return user.getOtp() != null;
5151
}
5252

53+
public boolean hasRecoveryCodes(String username) {
54+
BackwardsCompatibilityUserStorage.MyUser user = userPasswords.get(username);
55+
if (user == null) return false;
56+
return user.getRecoveryCodes() != null;
57+
}
5358
}

0 commit comments

Comments
 (0)