KEYCLOAK-13174 Not possible to delegate creating or deleting OTP credential to userStorage

This commit is contained in:
mposolda 2020-03-05 18:07:52 +01:00 committed by Stian Thorgersen
parent 803f398dba
commit 72e4690248
20 changed files with 410 additions and 59 deletions

View file

@ -154,14 +154,14 @@ public interface UserResource {
* Disables or deletes all credentials for specific types. * Disables or deletes all credentials for specific types.
* Type examples "otp", "password" * Type examples "otp", "password"
* *
* This endpoint is deprecated as it is not supported to disable credentials, just delete them * This is typically supported just for the users backed by user storage providers. See {@link UserRepresentation#getDisableableCredentialTypes()}
* to see what credential types can be disabled for the particular user
* *
* @param credentialTypes * @param credentialTypes
*/ */
@Path("disable-credential-types") @Path("disable-credential-types")
@PUT @PUT
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
@Deprecated
void disableCredentialType(List<String> credentialTypes); void disableCredentialType(List<String> credentialTypes);
@PUT @PUT

View file

@ -17,6 +17,8 @@
package org.keycloak.utils; package org.keycloak.utils;
import javax.ws.rs.core.Response;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.authentication.Authenticator; import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory; import org.keycloak.authentication.AuthenticatorFactory;
@ -25,10 +27,18 @@ import org.keycloak.authentication.ClientAuthenticatorFactory;
import org.keycloak.authentication.ConfigurableAuthenticatorFactory; import org.keycloak.authentication.ConfigurableAuthenticatorFactory;
import org.keycloak.authentication.FormAction; import org.keycloak.authentication.FormAction;
import org.keycloak.authentication.FormActionFactory; import org.keycloak.authentication.FormActionFactory;
import org.keycloak.credential.CredentialModel;
import org.keycloak.credential.CredentialProvider;
import org.keycloak.forms.account.AccountPages;
import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticationFlowModel; import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserCredentialModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.OTPCredentialModel;
import org.keycloak.models.utils.CredentialValidation;
import org.keycloak.representations.idm.CredentialRepresentation;
/** /**
* used to set an execution a state based on type. * used to set an execution a state based on type.
@ -79,4 +89,56 @@ public class CredentialHelper {
} }
return factory; return factory;
} }
/**
* Create OTP credential either in userStorage or local storage (Keycloak DB)
*
* @return true if credential was successfully created either in the user storage or Keycloak DB. False if error happened (EG. during HOTP validation)
*/
public static boolean createOTPCredential(KeycloakSession session, RealmModel realm, UserModel user, String totpCode, OTPCredentialModel credentialModel) {
CredentialProvider otpCredentialProvider = session.getProvider(CredentialProvider.class, "keycloak-otp");
String totpSecret = credentialModel.getOTPSecretData().getValue();
UserCredentialModel otpUserCredential = new UserCredentialModel("", realm.getOTPPolicy().getType(), totpSecret);
boolean userStorageCreated = session.userCredentialManager().updateCredential(realm, user, otpUserCredential);
String credentialId = null;
if (userStorageCreated) {
logger.debugf("Created OTP credential for user '%s' in the user storage", user.getUsername());
} else {
CredentialModel createdCredential = otpCredentialProvider.createCredential(realm, user, credentialModel);
credentialId = createdCredential.getId();
}
//If the type is HOTP, call verify once to consume the OTP used for registration and increase the counter.
UserCredentialModel credential = new UserCredentialModel(credentialId, otpCredentialProvider.getType(), totpCode);
return session.userCredentialManager().isValid(realm, user, credential);
}
public static void deleteOTPCredential(KeycloakSession session, RealmModel realm, UserModel user, String credentialId) {
CredentialProvider otpCredentialProvider = session.getProvider(CredentialProvider.class, "keycloak-otp");
boolean removed = otpCredentialProvider.deleteCredential(realm, user, credentialId);
// This can usually happened when credential is stored in the userStorage. Propagate to "disable" credential in the userStorage
if (!removed) {
logger.debug("Removing OTP credential from userStorage");
session.userCredentialManager().disableCredentialType(realm, user, OTPCredentialModel.TYPE);
}
}
/**
* Create "dummy" representation of the credential. Typically used when credential is provided by userStorage and we don't know further
* details about the credential besides the type
*
* @param credentialProviderType
* @return dummy credential
*/
public static CredentialRepresentation createUserStorageCredentialRepresentation(String credentialProviderType) {
CredentialRepresentation credential = new CredentialRepresentation();
credential.setId(credentialProviderType + "-id");
credential.setType(credentialProviderType);
credential.setCreatedDate(-1L);
credential.setPriority(0);
return credential;
}
} }

View file

@ -23,6 +23,7 @@ import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import com.fasterxml.jackson.annotation.JsonIgnore;
import org.keycloak.common.util.Base64; import org.keycloak.common.util.Base64;
import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.models.credential.PasswordCredentialModel; import org.keycloak.models.credential.PasswordCredentialModel;
@ -134,6 +135,7 @@ public class CredentialModel implements Serializable {
* @deprecated Recommended to use PasswordCredentialModel.getSecretData().getValue() or OTPCredentialModel.getSecretData().getValue() * @deprecated Recommended to use PasswordCredentialModel.getSecretData().getValue() or OTPCredentialModel.getSecretData().getValue()
*/ */
@Deprecated @Deprecated
@JsonIgnore
public String getValue() { public String getValue() {
return readString("value", true); return readString("value", true);
} }
@ -150,6 +152,7 @@ public class CredentialModel implements Serializable {
* @deprecated Recommended to use OTPCredentialModel.getCredentialData().getDevice() * @deprecated Recommended to use OTPCredentialModel.getCredentialData().getDevice()
*/ */
@Deprecated @Deprecated
@JsonIgnore
public String getDevice() { public String getDevice() {
return readString("device", false); return readString("device", false);
} }
@ -166,6 +169,7 @@ public class CredentialModel implements Serializable {
* @deprecated Recommended to use PasswordCredentialModel.getSecretData().getSalt() * @deprecated Recommended to use PasswordCredentialModel.getSecretData().getSalt()
*/ */
@Deprecated @Deprecated
@JsonIgnore
public byte[] getSalt() { public byte[] getSalt() {
try { try {
String saltStr = readString("salt", true); String saltStr = readString("salt", true);
@ -188,6 +192,7 @@ public class CredentialModel implements Serializable {
* @deprecated Recommended to use PasswordCredentialModel.getCredentialData().getHashIterations() * @deprecated Recommended to use PasswordCredentialModel.getCredentialData().getHashIterations()
*/ */
@Deprecated @Deprecated
@JsonIgnore
public int getHashIterations() { public int getHashIterations() {
return readInt("hashIterations", false); return readInt("hashIterations", false);
} }
@ -204,6 +209,7 @@ public class CredentialModel implements Serializable {
* @deprecated Recommended to use OTPCredentialModel.getCredentialData().getCounter() * @deprecated Recommended to use OTPCredentialModel.getCredentialData().getCounter()
*/ */
@Deprecated @Deprecated
@JsonIgnore
public int getCounter() { public int getCounter() {
return readInt("counter", false); return readInt("counter", false);
} }
@ -220,6 +226,7 @@ public class CredentialModel implements Serializable {
* @deprecated Recommended to use PasswordCredentialModel.getCredentialData().getAlgorithm() or OTPCredentialModel.getCredentialData().getAlgorithm() * @deprecated Recommended to use PasswordCredentialModel.getCredentialData().getAlgorithm() or OTPCredentialModel.getCredentialData().getAlgorithm()
*/ */
@Deprecated @Deprecated
@JsonIgnore
public String getAlgorithm() { public String getAlgorithm() {
return readString("algorithm", false); return readString("algorithm", false);
} }
@ -236,6 +243,7 @@ public class CredentialModel implements Serializable {
* @deprecated Recommended to use OTPCredentialModel.getCredentialData().getDigits() * @deprecated Recommended to use OTPCredentialModel.getCredentialData().getDigits()
*/ */
@Deprecated @Deprecated
@JsonIgnore
public int getDigits() { public int getDigits() {
return readInt("digits", false); return readInt("digits", false);
} }
@ -252,6 +260,7 @@ public class CredentialModel implements Serializable {
* @deprecated Recommended to use OTPCredentialModel.getCredentialData().getPeriod() * @deprecated Recommended to use OTPCredentialModel.getCredentialData().getPeriod()
*/ */
@Deprecated @Deprecated
@JsonIgnore
public int getPeriod() { public int getPeriod() {
return readInt("period", false); return readInt("period", false);
} }
@ -268,6 +277,7 @@ public class CredentialModel implements Serializable {
* @deprecated Recommended to use {@link #getCredentialData()} instead and use the subtype of CredentialData specific to your credential * @deprecated Recommended to use {@link #getCredentialData()} instead and use the subtype of CredentialData specific to your credential
*/ */
@Deprecated @Deprecated
@JsonIgnore
public MultivaluedHashMap<String, String> getConfig() { public MultivaluedHashMap<String, String> getConfig() {
Map<String, Object> credentialData = readMapFromJson(false); Map<String, Object> credentialData = readMapFromJson(false);
if (credentialData == null) { if (credentialData == null) {

View file

@ -38,7 +38,7 @@ public interface CredentialProvider<T extends CredentialModel> extends Provider
CredentialModel createCredential(RealmModel realm, UserModel user, T credentialModel); CredentialModel createCredential(RealmModel realm, UserModel user, T credentialModel);
void deleteCredential(RealmModel realm, UserModel user, String credentialId); boolean deleteCredential(RealmModel realm, UserModel user, String credentialId);
T getCredentialFromModel(CredentialModel model); T getCredentialFromModel(CredentialModel model);

View file

@ -57,9 +57,9 @@ public interface UserCredentialManager extends UserCredentialStore {
* *
* @param realm * @param realm
* @param user * @param user
* @return * @return true if credential was successfully updated by UserStorage or any CredentialInputUpdater
*/ */
void updateCredential(RealmModel realm, UserModel user, CredentialInput input); boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input);
/** /**
* Creates a credential from the credentialModel, by looping through the providers to find a match for the type * Creates a credential from the credentialModel, by looping through the providers to find a match for the type

View file

@ -52,16 +52,19 @@ public class UserCredentialModel implements CredentialInput {
public static final String KERBEROS = CredentialModel.KERBEROS; public static final String KERBEROS = CredentialModel.KERBEROS;
public static final String CLIENT_CERT = CredentialModel.CLIENT_CERT; public static final String CLIENT_CERT = CredentialModel.CLIENT_CERT;
private final String credentialId; private String credentialId;
private String type; private String type;
private String challengeResponse; private String challengeResponse;
private String device; private String device;
private String algorithm; private String algorithm;
private final boolean adminRequest; private boolean adminRequest;
// Additional context informations // Additional context informations
protected Map<String, Object> notes = new HashMap<>(); protected Map<String, Object> notes = new HashMap<>();
public UserCredentialModel() {
}
public UserCredentialModel(String credentialId, String type, String challengeResponse) { public UserCredentialModel(String credentialId, String type, String challengeResponse) {
this.credentialId = credentialId; this.credentialId = credentialId;
this.type = type; this.type = type;

View file

@ -32,6 +32,7 @@ import org.keycloak.models.credential.OTPCredentialModel;
import org.keycloak.models.utils.CredentialValidation; import org.keycloak.models.utils.CredentialValidation;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation; import org.keycloak.services.validation.Validation;
import org.keycloak.utils.CredentialHelper;
import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
@ -85,10 +86,7 @@ public class ConsoleUpdateTotp implements RequiredActionProvider {
return; return;
} }
OTPCredentialProvider otpCredentialProvider = (OTPCredentialProvider) context.getSession().getProvider(CredentialProvider.class, "keycloak-otp"); if (!CredentialHelper.createOTPCredential(context.getSession(), context.getRealm(), context.getUser(), challengeResponse, credentialModel)) {
CredentialModel createdCredential = otpCredentialProvider.createCredential(context.getRealm(), context.getUser(), credentialModel);
UserCredentialModel credential = new UserCredentialModel(createdCredential.getId(), otpCredentialProvider.getType(), challengeResponse);
if (!otpCredentialProvider.isValid(context.getRealm(), context.getUser(), credential)) {
context.challenge(challenge(context).message(Messages.INVALID_TOTP)); context.challenge(challenge(context).message(Messages.INVALID_TOTP));
return; return;
} }

View file

@ -34,6 +34,7 @@ import org.keycloak.models.credential.OTPCredentialModel;
import org.keycloak.models.utils.CredentialValidation; import org.keycloak.models.utils.CredentialValidation;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation; import org.keycloak.services.validation.Validation;
import org.keycloak.utils.CredentialHelper;
import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
@ -102,10 +103,8 @@ public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory
context.challenge(challenge); context.challenge(challenge);
return; return;
} }
CredentialModel createdCredential = otpCredentialProvider.createCredential(context.getRealm(), context.getUser(), credentialModel);
UserCredentialModel credential = new UserCredentialModel(createdCredential.getId(), otpCredentialProvider.getType(), challengeResponse); if (!CredentialHelper.createOTPCredential(context.getSession(), context.getRealm(), context.getUser(), challengeResponse, credentialModel)) {
//If the type is HOTP, call verify once to consume the OTP used for registration and increase the counter.
if (OTPCredentialModel.HOTP.equals(credentialModel.getOTPCredentialData().getSubType()) && !context.getSession().userCredentialManager().isValid(context.getRealm(), context.getUser(), credential)) {
Response challenge = context.form() Response challenge = context.form()
.setAttribute("mode", mode) .setAttribute("mode", mode)
.setError(Messages.INVALID_TOTP) .setError(Messages.INVALID_TOTP)

View file

@ -75,8 +75,8 @@ public class OTPCredentialProvider implements CredentialProvider<OTPCredentialMo
} }
@Override @Override
public void deleteCredential(RealmModel realm, UserModel user, String credentialId) { public boolean deleteCredential(RealmModel realm, UserModel user, String credentialId) {
getCredentialStore().removeStoredCredential(realm, user, credentialId); return getCredentialStore().removeStoredCredential(realm, user, credentialId);
} }
@Override @Override

View file

@ -132,8 +132,8 @@ public class PasswordCredentialProvider implements CredentialProvider<PasswordCr
} }
@Override @Override
public void deleteCredential(RealmModel realm, UserModel user, String credentialId) { public boolean deleteCredential(RealmModel realm, UserModel user, String credentialId) {
getCredentialStore().removeStoredCredential(realm, user, credentialId); return getCredentialStore().removeStoredCredential(realm, user, credentialId);
} }
@Override @Override

View file

@ -198,15 +198,15 @@ public class UserCredentialStoreManager implements UserCredentialManager, OnUser
} }
@Override @Override
public void updateCredential(RealmModel realm, UserModel user, CredentialInput input) { public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) {
if (!StorageId.isLocalStorage(user)) { if (!StorageId.isLocalStorage(user)) {
String providerId = StorageId.resolveProviderId(user); String providerId = StorageId.resolveProviderId(user);
UserStorageProvider provider = UserStorageManager.getStorageProvider(session, realm, providerId); UserStorageProvider provider = UserStorageManager.getStorageProvider(session, realm, providerId);
if (provider instanceof CredentialInputUpdater) { if (provider instanceof CredentialInputUpdater) {
if (!UserStorageManager.isStorageProviderEnabled(realm, providerId)) return; if (!UserStorageManager.isStorageProviderEnabled(realm, providerId)) return false;
CredentialInputUpdater updater = (CredentialInputUpdater) provider; CredentialInputUpdater updater = (CredentialInputUpdater) provider;
if (updater.supportsCredentialType(input.getType())) { if (updater.supportsCredentialType(input.getType())) {
if (updater.updateCredential(realm, user, input)) return; if (updater.updateCredential(realm, user, input)) return true;
} }
} }
@ -215,8 +215,8 @@ public class UserCredentialStoreManager implements UserCredentialManager, OnUser
if (user.getFederationLink() != null) { if (user.getFederationLink() != null) {
UserStorageProvider provider = UserStorageManager.getStorageProvider(session, realm, user.getFederationLink()); UserStorageProvider provider = UserStorageManager.getStorageProvider(session, realm, user.getFederationLink());
if (provider instanceof CredentialInputUpdater) { if (provider instanceof CredentialInputUpdater) {
if (!UserStorageManager.isStorageProviderEnabled(realm, user.getFederationLink())) return; if (!UserStorageManager.isStorageProviderEnabled(realm, user.getFederationLink())) return false;
if (((CredentialInputUpdater) provider).updateCredential(realm, user, input)) return; if (((CredentialInputUpdater) provider).updateCredential(realm, user, input)) return true;
} }
} }
} }
@ -224,9 +224,11 @@ public class UserCredentialStoreManager implements UserCredentialManager, OnUser
List<CredentialInputUpdater> credentialProviders = getCredentialProviders(session, realm, CredentialInputUpdater.class); List<CredentialInputUpdater> credentialProviders = getCredentialProviders(session, realm, CredentialInputUpdater.class);
for (CredentialInputUpdater updater : credentialProviders) { for (CredentialInputUpdater updater : credentialProviders) {
if (!updater.supportsCredentialType(input.getType())) continue; if (!updater.supportsCredentialType(input.getType())) continue;
if (updater.updateCredential(realm, user, input)) return; if (updater.updateCredential(realm, user, input)) return true;
} }
return false;
} }
@Override @Override

View file

@ -75,9 +75,9 @@ public class WebAuthnCredentialProvider implements CredentialProvider<WebAuthnCr
} }
@Override @Override
public void deleteCredential(RealmModel realm, UserModel user, String credentialId) { public boolean deleteCredential(RealmModel realm, UserModel user, String credentialId) {
logger.debugv("Delete WebAuthn credential. username = {0}, credentialId = {1}", user.getUsername(), credentialId); logger.debugv("Delete WebAuthn credential. username = {0}, credentialId = {1}", user.getUsername(), credentialId);
getCredentialStore().removeStoredCredential(realm, user, credentialId); return getCredentialStore().removeStoredCredential(realm, user, credentialId);
} }
@Override @Override

View file

@ -26,12 +26,16 @@ import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.credential.OTPCredentialModel; import org.keycloak.models.credential.OTPCredentialModel;
import org.keycloak.models.utils.HmacOTP; import org.keycloak.models.utils.HmacOTP;
import org.keycloak.models.utils.RepresentationToModel;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.utils.TotpUtils; import org.keycloak.utils.TotpUtils;
import javax.ws.rs.core.UriBuilder; import javax.ws.rs.core.UriBuilder;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import static org.keycloak.utils.CredentialHelper.createUserStorageCredentialRepresentation;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -48,11 +52,19 @@ public class TotpBean {
public TotpBean(KeycloakSession session, RealmModel realm, UserModel user, UriBuilder uriBuilder) { public TotpBean(KeycloakSession session, RealmModel realm, UserModel user, UriBuilder uriBuilder) {
this.uriBuilder = uriBuilder; this.uriBuilder = uriBuilder;
this.enabled = ((OTPCredentialProvider)session.getProvider(CredentialProvider.class, "keycloak-otp")).isConfiguredFor(realm, user); this.enabled = session.userCredentialManager().isConfiguredFor(realm, user, OTPCredentialModel.TYPE);
if (enabled) { if (enabled) {
otpCredentials = session.userCredentialManager().getStoredCredentialsByType(realm, user, OTPCredentialModel.TYPE); List<CredentialModel> otpCredentials = session.userCredentialManager().getStoredCredentialsByType(realm, user, OTPCredentialModel.TYPE);
if (otpCredentials.isEmpty()) {
// Credential is configured on userStorage side. Create the "fake" credential similar like we do for the new account console
CredentialRepresentation credential = createUserStorageCredentialRepresentation(OTPCredentialModel.TYPE);
this.otpCredentials = Collections.singletonList(RepresentationToModel.toModel(credential));
} else {
this.otpCredentials = otpCredentials;
}
} else { } else {
otpCredentials = Collections.EMPTY_LIST; this.otpCredentials = Collections.EMPTY_LIST;
} }
this.realm = realm; this.realm = realm;

View file

@ -41,6 +41,7 @@ import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.keycloak.models.AuthenticationExecutionModel.Requirement.DISABLED; import static org.keycloak.models.AuthenticationExecutionModel.Requirement.DISABLED;
import static org.keycloak.utils.CredentialHelper.createUserStorageCredentialRepresentation;
public class AccountCredentialResource { public class AccountCredentialResource {
@ -201,11 +202,7 @@ public class AccountCredentialResource {
session.userCredentialManager().isConfiguredFor(realm, user, credentialProviderType)) { session.userCredentialManager().isConfiguredFor(realm, user, credentialProviderType)) {
// In case user is federated in the userStorage, he may have credential configured on the userStorage side. We're // In case user is federated in the userStorage, he may have credential configured on the userStorage side. We're
// creating "dummy" credential representing the credential provided by userStorage // creating "dummy" credential representing the credential provided by userStorage
CredentialRepresentation credential = new CredentialRepresentation(); CredentialRepresentation credential = createUserStorageCredentialRepresentation(credentialProviderType);
credential.setId(credentialProviderType + "-id");
credential.setType(credentialProviderType);
credential.setCreatedDate(-1L);
credential.setPriority(0);
userCredentialModels = Collections.singletonList(credential); userCredentialModels = Collections.singletonList(credential);
} }

View file

@ -77,6 +77,7 @@ import org.keycloak.services.validation.Validation;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.storage.ReadOnlyException; import org.keycloak.storage.ReadOnlyException;
import org.keycloak.util.JsonSerialization; import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.CredentialHelper;
import javax.ws.rs.Consumes; import javax.ws.rs.Consumes;
import javax.ws.rs.FormParam; import javax.ws.rs.FormParam;
@ -494,14 +495,13 @@ public class AccountFormService extends AbstractSecuredLocalService {
UserModel user = auth.getUser(); UserModel user = auth.getUser();
OTPCredentialProvider otpCredentialProvider = (OTPCredentialProvider) session.getProvider(CredentialProvider.class, "keycloak-otp");
if (action != null && action.equals("Delete")) { if (action != null && action.equals("Delete")) {
String credentialId = formData.getFirst("credentialId"); String credentialId = formData.getFirst("credentialId");
if (credentialId == null) { if (credentialId == null) {
setReferrerOnPage(); setReferrerOnPage();
return account.setError(Status.OK, Messages.UNEXPECTED_ERROR_HANDLING_REQUEST).createResponse(AccountPages.TOTP); return account.setError(Status.OK, Messages.UNEXPECTED_ERROR_HANDLING_REQUEST).createResponse(AccountPages.TOTP);
} }
otpCredentialProvider.deleteCredential(realm, user, credentialId); CredentialHelper.deleteOTPCredential(session, realm, user, credentialId);
event.event(EventType.REMOVE_TOTP).client(auth.getClient()).user(auth.getUser()).success(); event.event(EventType.REMOVE_TOTP).client(auth.getClient()).user(auth.getUser()).success();
setReferrerOnPage(); setReferrerOnPage();
return account.setSuccess(Messages.SUCCESS_TOTP_REMOVED).createResponse(AccountPages.TOTP); return account.setSuccess(Messages.SUCCESS_TOTP_REMOVED).createResponse(AccountPages.TOTP);
@ -520,10 +520,7 @@ public class AccountFormService extends AbstractSecuredLocalService {
return account.setError(Status.OK, Messages.INVALID_TOTP).createResponse(AccountPages.TOTP); return account.setError(Status.OK, Messages.INVALID_TOTP).createResponse(AccountPages.TOTP);
} }
if (!CredentialHelper.createOTPCredential(session, realm, user, challengeResponse, credentialModel)) {
CredentialModel createdCredential = otpCredentialProvider.createCredential(realm, user, credentialModel);
UserCredentialModel credential = new UserCredentialModel(createdCredential.getId(), otpCredentialProvider.getType(), challengeResponse);
if (!otpCredentialProvider.isValid(realm, user, credential)) {
setReferrerOnPage(); setReferrerOnPage();
return account.setError(Status.OK, Messages.INVALID_TOTP).createResponse(AccountPages.TOTP); return account.setError(Status.OK, Messages.INVALID_TOTP).createResponse(AccountPages.TOTP);
} }

View file

@ -581,7 +581,12 @@ public class UserResource {
@PUT @PUT
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
public void disableCredentialType(List<String> credentialTypes) { public void disableCredentialType(List<String> credentialTypes) {
throw new NotSupportedException("Not supported to disable credentials. Only credentials removal is supported"); auth.users().requireManage(user);
if (credentialTypes == null) return;
for (String type : credentialTypes) {
session.userCredentialManager().disableCredentialType(realm, user, type);
}
} }
/** /**

View file

@ -19,6 +19,7 @@
package org.keycloak.testsuite.federation; package org.keycloak.testsuite.federation;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@ -31,12 +32,14 @@ import org.keycloak.credential.CredentialInputValidator;
import org.keycloak.credential.CredentialModel; import org.keycloak.credential.CredentialModel;
import org.keycloak.credential.hash.PasswordHashProvider; import org.keycloak.credential.hash.PasswordHashProvider;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OTPPolicy;
import org.keycloak.models.PasswordPolicy; import org.keycloak.models.PasswordPolicy;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserCredentialModel; import org.keycloak.models.UserCredentialModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.cache.UserCache; import org.keycloak.models.cache.UserCache;
import org.keycloak.models.credential.PasswordUserCredentialModel; import org.keycloak.models.credential.PasswordUserCredentialModel;
import org.keycloak.models.utils.TimeBasedOTP;
import org.keycloak.storage.StorageId; import org.keycloak.storage.StorageId;
import org.keycloak.storage.UserStorageProvider; import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.adapter.AbstractUserAdapterFederatedStorage; import org.keycloak.storage.adapter.AbstractUserAdapterFederatedStorage;
@ -94,7 +97,19 @@ public class BackwardsCompatibilityUserStorage implements UserLookupProvider, Us
@Override @Override
public boolean supportsCredentialType(String credentialType) { public boolean supportsCredentialType(String credentialType) {
return CredentialModel.PASSWORD.equals(credentialType); if (CredentialModel.PASSWORD.equals(credentialType)
|| isOTPType(credentialType)) {
return true;
} else {
log.infof("Unsupported credential type: %s", credentialType);
return false;
}
}
private boolean isOTPType(String credentialType) {
return CredentialModel.OTP.equals(credentialType)
|| CredentialModel.HOTP.equals(credentialType)
|| CredentialModel.TOTP.equals(credentialType);
} }
@Override @Override
@ -137,8 +152,32 @@ public class BackwardsCompatibilityUserStorage implements UserLookupProvider, Us
if (userCache != null) { if (userCache != null) {
userCache.evict(realm, user); userCache.evict(realm, user);
} }
return true;
} else if (isOTPType(input.getType())) {
UserCredentialModel otpCredential = (UserCredentialModel) input;
// Those are not supposed to be set when calling this method in Keycloak 4.8.3 for password credential
assertNull(otpCredential.getDevice());
assertNull(otpCredential.getAlgorithm());
OTPPolicy otpPolicy = session.getContext().getRealm().getOTPPolicy();
CredentialModel newOTP = new CredentialModel();
newOTP.setType(input.getType());
long createdDate = Time.currentTimeMillis();
newOTP.setCreatedDate(createdDate);
newOTP.setValue(otpCredential.getValue());
newOTP.setCounter(otpPolicy.getInitialCounter());
newOTP.setDigits(otpPolicy.getDigits());
newOTP.setAlgorithm(otpPolicy.getAlgorithm());
newOTP.setPeriod(otpPolicy.getPeriod());
users.get(user.getUsername()).otp = newOTP;
return true; return true;
} else { } else {
log.infof("Attempt to update unsupported credential of type: %s", input.getType());
return false; return false;
} }
} }
@ -154,24 +193,53 @@ public class BackwardsCompatibilityUserStorage implements UserLookupProvider, Us
@Override @Override
public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) { public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) {
if (isOTPType(credentialType)) {
MyUser myUser = getMyUser(user);
myUser.otp = null;
} else {
log.infof("Unsupported to disable credential of type: %s", credentialType);
}
}
private MyUser getMyUser(UserModel user) {
return users.get(user.getUsername());
} }
@Override @Override
public Set<String> getDisableableCredentialTypes(RealmModel realm, UserModel user) { public Set<String> getDisableableCredentialTypes(RealmModel realm, UserModel user) {
return Collections.EMPTY_SET; Set<String> types = new HashSet<>();
MyUser myUser = getMyUser(user);
if (myUser != null && myUser.otp != null) {
types.add(CredentialModel.OTP);
}
return types;
} }
@Override @Override
public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) { public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
return CredentialModel.PASSWORD.equals(credentialType); // Always assume that password is supported
if (CredentialModel.PASSWORD.equals(credentialType)) return true;
MyUser myUser = getMyUser(user);
if (myUser == null) return false;
if (isOTPType(credentialType) && myUser.otp != null) {
return true;
} else {
log.infof("Not supported credentialType '%s' for user '%s'", credentialType, user.getUsername());
return false;
}
} }
@Override @Override
public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) { public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
if (!(input instanceof PasswordUserCredentialModel)) return false; MyUser myUser = users.get(user.getUsername());
if (myUser == null) return false;
if (input.getType().equals(UserCredentialModel.PASSWORD)) { if (input.getType().equals(UserCredentialModel.PASSWORD)) {
CredentialModel hashedPassword = users.get(user.getUsername()).hashedPassword; if (!(input instanceof PasswordUserCredentialModel)) return false;
CredentialModel hashedPassword = myUser.hashedPassword;
if (hashedPassword == null) { if (hashedPassword == null) {
log.warnf("Password not set for user %s", user.getUsername()); log.warnf("Password not set for user %s", user.getUsername());
return false; return false;
@ -190,7 +258,25 @@ public class BackwardsCompatibilityUserStorage implements UserLookupProvider, Us
// Compatibility with 4.8.3 - using "legacy" signature of this method // Compatibility with 4.8.3 - using "legacy" signature of this method
return hashProvider.verify(rawPassword, hashedPassword); return hashProvider.verify(rawPassword, hashedPassword);
} else if (isOTPType(input.getType())) {
UserCredentialModel otpCredential = (UserCredentialModel) input;
// Special hardcoded OTP, which is always considered valid
if ("123456".equals(otpCredential.getValue())) {
return true;
}
CredentialModel storedOTPCredential = myUser.otp;
if (storedOTPCredential == null) {
log.warnf("Not found credential for the user %s", user.getUsername());
return false;
}
TimeBasedOTP validator = new TimeBasedOTP(storedOTPCredential.getAlgorithm(), storedOTPCredential.getDigits(),
storedOTPCredential.getPeriod(), realm.getOTPPolicy().getLookAheadWindow());
return validator.validateTOTP(otpCredential.getValue(), storedOTPCredential.getValue().getBytes());
} else { } else {
log.infof("Not supported to validate credential of type '%s' for user '%s'", input.getType(), user.getUsername());
return false; return false;
} }
} }
@ -227,11 +313,15 @@ public class BackwardsCompatibilityUserStorage implements UserLookupProvider, Us
private String username; private String username;
private CredentialModel hashedPassword; private CredentialModel hashedPassword;
private CredentialModel otp;
private MyUser(String username) { private MyUser(String username) {
this.username = username; this.username = username;
} }
public CredentialModel getOtp() {
return otp;
}
} }
@ -254,4 +344,3 @@ public class BackwardsCompatibilityUserStorage implements UserLookupProvider, Us
} }
} }

View file

@ -44,4 +44,10 @@ public class BackwardsCompatibilityUserStorageFactory implements UserStorageProv
return PROVIDER_ID; return PROVIDER_ID;
} }
public boolean hasUserOTP(String username) {
BackwardsCompatibilityUserStorage.MyUser user = userPasswords.get(username);
if (user == null) return false;
return user.getOtp() != null;
}
} }

View file

@ -20,25 +20,36 @@ package org.keycloak.testsuite.federation.storage;
import java.io.IOException; import java.io.IOException;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import org.jboss.arquillian.graphene.page.Page; import org.jboss.arquillian.graphene.page.Page;
import org.junit.Assert;
import org.junit.Before; import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.credential.CredentialModel; import org.keycloak.credential.CredentialModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.TimeBasedOTP;
import org.keycloak.representations.idm.ComponentRepresentation; import org.keycloak.representations.idm.ComponentRepresentation;
import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.storage.StorageId; import org.keycloak.storage.StorageId;
import org.keycloak.storage.UserStorageProvider; import org.keycloak.storage.UserStorageProvider;
import org.keycloak.testsuite.AbstractAuthTest; import org.keycloak.testsuite.AbstractAuthTest;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.federation.BackwardsCompatibilityUserStorageFactory; import org.keycloak.testsuite.federation.BackwardsCompatibilityUserStorageFactory;
import org.keycloak.testsuite.pages.AccountTotpPage;
import org.keycloak.testsuite.pages.AppPage; import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.LoginConfigTotpPage;
import org.keycloak.testsuite.pages.LoginPage; import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.LoginTotpPage;
import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlDoesntStartWith; import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlDoesntStartWith;
import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlStartsWith; import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlStartsWith;
@ -48,10 +59,29 @@ import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlStartsWith;
* *
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a> * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/ */
@AuthServerContainerExclude(AuthServerContainerExclude.AuthServer.REMOTE)
public class BackwardsCompatibilityUserStorageTest extends AbstractAuthTest { public class BackwardsCompatibilityUserStorageTest extends AbstractAuthTest {
private String backwardsCompProviderId; private String backwardsCompProviderId;
@Page
protected AppPage appPage;
@Page
protected LoginPage loginPage;
@Page
protected LoginTotpPage loginTotpPage;
@Page
protected AccountTotpPage accountTotpSetupPage;
@Page
protected LoginConfigTotpPage configureTotpRequiredActionPage;
private TimeBasedOTP totp = new TimeBasedOTP();
@Before @Before
public void addProvidersBeforeTest() throws URISyntaxException, IOException { public void addProvidersBeforeTest() throws URISyntaxException, IOException {
ComponentRepresentation memProvider = new ComponentRepresentation(); ComponentRepresentation memProvider = new ComponentRepresentation();
@ -72,13 +102,6 @@ public class BackwardsCompatibilityUserStorageTest extends AbstractAuthTest {
return id; return id;
} }
@Page
protected AppPage appPage;
@Page
protected LoginPage loginPage;
private void loginSuccessAndLogout(String username, String password) { private void loginSuccessAndLogout(String username, String password) {
testRealmAccountPage.navigateTo(); testRealmAccountPage.navigateTo();
loginPage.login(username, password); loginPage.login(username, password);
@ -102,7 +125,7 @@ public class BackwardsCompatibilityUserStorageTest extends AbstractAuthTest {
loginBadPassword("tbrady"); loginBadPassword("tbrady");
} }
private void addUserAndResetPassword(String username, String password) { private String addUserAndResetPassword(String username, String password) {
// Save user and assert he is saved in the new storage // Save user and assert he is saved in the new storage
UserRepresentation user = new UserRepresentation(); UserRepresentation user = new UserRepresentation();
user.setEnabled(true); user.setEnabled(true);
@ -119,5 +142,153 @@ public class BackwardsCompatibilityUserStorageTest extends AbstractAuthTest {
passwordRep.setTemporary(false); passwordRep.setTemporary(false);
testRealmResource().users().get(userId).resetPassword(passwordRep); testRealmResource().users().get(userId).resetPassword(passwordRep);
return userId;
}
@Test
public void testOTPUpdateAndLogin() {
String userId = addUserAndResetPassword("otp1", "pass");
getCleanup().addUserId(userId);
// Setup OTP for the user
String totpSecret = setupOTPForUserWithRequiredAction(userId);
// Assert user has OTP in the userStorage
assertUserDontHaveDBCredentials();
assertUserHasOTPCredentialInUserStorage(true);
assertUserDontHaveDBCredentials();
assertUserHasOTPCredentialInUserStorage(true);
// Authenticate as the user with the hardcoded OTP. Should be supported
loginPage.login("otp1", "pass");
loginTotpPage.assertCurrent();
loginTotpPage.login("123456");
assertCurrentUrlStartsWith(testRealmAccountPage);
testRealmAccountPage.logOut();
// Authenticate as the user with bad OTP
loginPage.login("otp1", "pass");
loginTotpPage.assertCurrent();
loginTotpPage.login("7123456");
assertCurrentUrlDoesntStartWith(testRealmAccountPage);
Assert.assertNotNull(loginTotpPage.getError());
// Authenticate as the user with correct OTP
loginTotpPage.login(totp.generateTOTP(totpSecret));
assertCurrentUrlStartsWith(testRealmAccountPage);
testRealmAccountPage.logOut();
}
@Test
public void testOTPSetupThroughAccountMgmtAndLogin() {
String userId = addUserAndResetPassword("otp1", "pass");
getCleanup().addUserId(userId);
// Login as user to account mgmt
accountTotpSetupPage.open();
loginPage.login("otp1", "pass");
// Setup OTP
String totpSecret = accountTotpSetupPage.getTotpSecret();
accountTotpSetupPage.configure(totp.generateTOTP(totpSecret));
assertUserDontHaveDBCredentials();
assertUserHasOTPCredentialInUserStorage(true);
// Logout and assert user can login with hardcoded OTP
accountTotpSetupPage.logout();
loginPage.login("otp1", "pass");
loginTotpPage.login("123456");
assertCurrentUrlStartsWith(testRealmAccountPage);
// Logout and assert user can login with valid credential
accountTotpSetupPage.logout();
loginPage.login("otp1", "pass");
loginTotpPage.login(totp.generateTOTP(totpSecret));
assertCurrentUrlStartsWith(testRealmAccountPage);
// Delete OTP credential in account console
accountTotpSetupPage.removeTotp();
accountTotpSetupPage.logout();
assertUserDontHaveDBCredentials();
assertUserHasOTPCredentialInUserStorage(false);
// Assert user can login without OTP
loginSuccessAndLogout("otp1", "pass");
}
@Test
public void testDisableCredentialsInUserStorage() {
String userId = addUserAndResetPassword("otp1", "pass");
getCleanup().addUserId(userId);
// Setup OTP for the user
setupOTPForUserWithRequiredAction(userId);
// Assert user has OTP in the userStorage
assertUserDontHaveDBCredentials();
assertUserHasOTPCredentialInUserStorage(true);
UserResource user = testRealmResource().users().get(userId);
// Disable OTP credential for the user through REST endpoint
UserRepresentation userRep = user.toRepresentation();
Assert.assertNames(userRep.getDisableableCredentialTypes(), CredentialModel.OTP);
user.disableCredentialType(Collections.singletonList(CredentialModel.OTP));
// User don't have OTP credential in userStorage anymore
assertUserDontHaveDBCredentials();
assertUserHasOTPCredentialInUserStorage(false);
// Assert user can login without OTP
loginSuccessAndLogout("otp1", "pass");
}
// return created totpSecret
private String setupOTPForUserWithRequiredAction(String userId) {
// Add required action to the user to reset OTP
UserResource user = testRealmResource().users().get(userId);
UserRepresentation userRep = user.toRepresentation();
userRep.setRequiredActions(Arrays.asList(UserModel.RequiredAction.CONFIGURE_TOTP.toString()));
user.update(userRep);
// Login as the user and setup OTP
testRealmAccountPage.navigateTo();
loginPage.login("otp1", "pass");
configureTotpRequiredActionPage.assertCurrent();
String totpSecret = configureTotpRequiredActionPage.getTotpSecret();
configureTotpRequiredActionPage.configure(totp.generateTOTP(totpSecret));
assertCurrentUrlStartsWith(testRealmAccountPage);
// Logout
testRealmAccountPage.logOut();
return totpSecret;
}
private void assertUserDontHaveDBCredentials() {
testingClient.server().run(session -> {
RealmModel realm1 = session.realms().getRealmByName("test");
UserModel user1 = session.users().getUserByUsername("otp1", realm1);
List<CredentialModel> keycloakDBCredentials = session.userCredentialManager().getStoredCredentials(realm1, user1);
Assert.assertTrue(keycloakDBCredentials.isEmpty());
});
}
private void assertUserHasOTPCredentialInUserStorage(boolean expectedUserHasOTP) {
boolean hasUserOTP = testingClient.server().fetch(session -> {
BackwardsCompatibilityUserStorageFactory storageFactory = (BackwardsCompatibilityUserStorageFactory) session.getKeycloakSessionFactory()
.getProviderFactory(UserStorageProvider.class, BackwardsCompatibilityUserStorageFactory.PROVIDER_ID);
return storageFactory.hasUserOTP("otp1");
}, Boolean.class);
Assert.assertEquals(expectedUserHasOTP, hasUserOTP);
} }
} }

View file

@ -1554,7 +1554,7 @@ credentials.disable.tooltip=Click button to disable selected credential types
credential-types=Credential Types credential-types=Credential Types
manage-user-password=Manage Password manage-user-password=Manage Password
supported-user-storage-credential-types=Supported User Storage Credential Types supported-user-storage-credential-types=Supported User Storage Credential Types
supported-user-storage-credential-types.tooltip=Credential types, which are provided by User Storage Provider. Validation and eventually update of the credentials of those types can be delegated to the User Storage Provider based on the configuration and implementation of the particular provider. supported-user-storage-credential-types.tooltip=Credential types, which are provided by User Storage Provider and which are configured for this user. Validation and eventually update of the credentials of those types can be delegated to the User Storage Provider based on the configuration and implementation of the particular provider.
provided-by=Provided By provided-by=Provided By
manage-credentials=Manage Credentials manage-credentials=Manage Credentials
manage-credentials.tooltip=Credentials, which are not provided by the user storage. They are saved in the local database. manage-credentials.tooltip=Credentials, which are not provided by the user storage. They are saved in the local database.