KEYCLOAK-12340 KEYCLOAK-12386 Regression in credential handling when … (#6668)
This commit is contained in:
parent
337e8f8fad
commit
154bce5693
15 changed files with 533 additions and 56 deletions
|
@ -104,6 +104,18 @@ public interface UserResource {
|
|||
@Produces(MediaType.APPLICATION_JSON)
|
||||
List<CredentialRepresentation> credentials();
|
||||
|
||||
|
||||
/**
|
||||
* Return credential types, which are provided by the user storage where user is stored. Returned values can contain for example "password", "otp" etc.
|
||||
* This will always return empty list for "local" users, which are not backed by any user storage
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
@GET
|
||||
@Path("configured-user-storage-credential-types")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
List<String> getConfiguredUserStorageCredentialTypes();
|
||||
|
||||
/**
|
||||
* Remove a credential for a user
|
||||
*
|
||||
|
|
|
@ -133,4 +133,12 @@ public interface UserCredentialManager extends UserCredentialStore {
|
|||
* @return
|
||||
*/
|
||||
CredentialValidationOutput authenticate(KeycloakSession session, RealmModel realm, CredentialInput input);
|
||||
|
||||
/**
|
||||
* Return credential types, which are provided by the user storage where user is stored. Returned values can contain for example "password", "otp" etc.
|
||||
* This will always return empty list for "local" users, which are not backed by any user storage
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
List<String> getConfiguredUserStorageCredentialTypes(RealmModel realm, UserModel user);
|
||||
}
|
||||
|
|
|
@ -33,6 +33,7 @@ import org.keycloak.models.KeycloakSession;
|
|||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserCredentialModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.credential.OTPCredentialModel;
|
||||
import org.keycloak.services.messages.Messages;
|
||||
|
||||
import javax.ws.rs.core.MultivaluedMap;
|
||||
|
@ -74,8 +75,9 @@ public class OTPFormAuthenticator extends AbstractUsernameFormAuthenticator impl
|
|||
String credentialId = inputData.getFirst("selectedCredentialId");
|
||||
|
||||
if (credentialId == null || credentialId.isEmpty()) {
|
||||
credentialId = getCredentialProvider(context.getSession())
|
||||
.getDefaultCredential(context.getSession(), context.getRealm(), context.getUser()).getId();
|
||||
OTPCredentialModel defaultOtpCredential = getCredentialProvider(context.getSession())
|
||||
.getDefaultCredential(context.getSession(), context.getRealm(), context.getUser());
|
||||
credentialId = defaultOtpCredential==null ? "" : defaultOtpCredential.getId();
|
||||
}
|
||||
context.form().setAttribute(SELECTED_OTP_CREDENTIAL_ID, credentialId);
|
||||
|
||||
|
@ -90,7 +92,7 @@ public class OTPFormAuthenticator extends AbstractUsernameFormAuthenticator impl
|
|||
context.challenge(challengeResponse);
|
||||
return;
|
||||
}
|
||||
boolean valid = getCredentialProvider(context.getSession()).isValid(context.getRealm(),context.getUser(),
|
||||
boolean valid = context.getSession().userCredentialManager().isValid(context.getRealm(),context.getUser(),
|
||||
new UserCredentialModel(credentialId, getCredentialProvider(context.getSession()).getType(), otp));
|
||||
if (!valid) {
|
||||
context.getEvent().user(userModel)
|
||||
|
@ -119,7 +121,7 @@ public class OTPFormAuthenticator extends AbstractUsernameFormAuthenticator impl
|
|||
|
||||
@Override
|
||||
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
|
||||
return getCredentialProvider(session).isConfiguredFor(realm, user);
|
||||
return session.userCredentialManager().isConfiguredFor(realm, user, getCredentialProvider(session).getType());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -79,7 +79,7 @@ public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory
|
|||
.createResponse(UserModel.RequiredAction.CONFIGURE_TOTP);
|
||||
context.challenge(challenge);
|
||||
return;
|
||||
} else if (!CredentialValidation.validOTP(challengeResponse, credentialModel, policy.getLookAheadWindow())) {
|
||||
} else if (!validateOTPCredential(context, challengeResponse, credentialModel, policy)) {
|
||||
Response challenge = context.form()
|
||||
.setAttribute("mode", mode)
|
||||
.setError(Messages.INVALID_TOTP)
|
||||
|
@ -91,7 +91,7 @@ public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory
|
|||
CredentialModel createdCredential = otpCredentialProvider.createCredential(context.getRealm(), context.getUser(), credentialModel);
|
||||
UserCredentialModel credential = new UserCredentialModel(createdCredential.getId(), otpCredentialProvider.getType(), challengeResponse);
|
||||
//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()) && !otpCredentialProvider.isValid(context.getRealm(), context.getUser(), credential)) {
|
||||
if (OTPCredentialModel.HOTP.equals(credentialModel.getOTPCredentialData().getSubType()) && !context.getSession().userCredentialManager().isValid(context.getRealm(), context.getUser(), credential)) {
|
||||
Response challenge = context.form()
|
||||
.setAttribute("mode", mode)
|
||||
.setError(Messages.INVALID_TOTP)
|
||||
|
@ -103,6 +103,12 @@ public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory
|
|||
}
|
||||
|
||||
|
||||
// Use separate method, so it's possible to override in the custom provider
|
||||
protected boolean validateOTPCredential(RequiredActionContext context, String token, OTPCredentialModel credentialModel, OTPPolicy policy) {
|
||||
return CredentialValidation.validOTP(token, credentialModel, policy.getLookAheadWindow());
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
package org.keycloak.credential;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.common.util.ObjectUtil;
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.models.RequiredActionProviderModel;
|
||||
import org.keycloak.models.credential.OTPCredentialModel;
|
||||
|
@ -109,6 +110,10 @@ public class OTPCredentialProvider implements CredentialProvider<OTPCredentialMo
|
|||
if (challengeResponse == null) {
|
||||
return false;
|
||||
}
|
||||
if (ObjectUtil.isBlank(credentialInput.getCredentialId())) {
|
||||
logger.debugf("CredentialId is null when validating credential of user %s", user.getUsername());
|
||||
return false;
|
||||
}
|
||||
|
||||
CredentialModel credential = getCredentialStore().getStoredCredentialById(realm, user, credentialInput.getCredentialId());
|
||||
OTPCredentialModel otpCredentialModel = OTPCredentialModel.createFromCredentialModel(credential);
|
||||
|
|
|
@ -285,28 +285,49 @@ public class UserCredentialStoreManager implements UserCredentialManager, OnUser
|
|||
|
||||
@Override
|
||||
public boolean isConfiguredFor(RealmModel realm, UserModel user, String type) {
|
||||
UserStorageCredentialConfigured userStorageConfigured = isConfiguredThroughUserStorage(realm, user, type);
|
||||
|
||||
// Check if we can rely just on userStorage to decide if credential is configured for the user or not
|
||||
switch (userStorageConfigured) {
|
||||
case CONFIGURED: return true;
|
||||
case USER_STORAGE_DISABLED: return false;
|
||||
}
|
||||
|
||||
// Check locally as a fallback
|
||||
return isConfiguredLocally(realm, user, type);
|
||||
}
|
||||
|
||||
|
||||
private enum UserStorageCredentialConfigured {
|
||||
CONFIGURED,
|
||||
USER_STORAGE_DISABLED,
|
||||
NOT_CONFIGURED
|
||||
}
|
||||
|
||||
|
||||
private UserStorageCredentialConfigured isConfiguredThroughUserStorage(RealmModel realm, UserModel user, String type) {
|
||||
if (!StorageId.isLocalStorage(user)) {
|
||||
String providerId = StorageId.resolveProviderId(user);
|
||||
UserStorageProvider provider = UserStorageManager.getStorageProvider(session, realm, providerId);
|
||||
if (provider instanceof CredentialInputValidator) {
|
||||
if (!UserStorageManager.isStorageProviderEnabled(realm, providerId)) return false;
|
||||
if (!UserStorageManager.isStorageProviderEnabled(realm, providerId)) return UserStorageCredentialConfigured.USER_STORAGE_DISABLED;
|
||||
CredentialInputValidator validator = (CredentialInputValidator) provider;
|
||||
if (validator.supportsCredentialType(type) && validator.isConfiguredFor(realm, user, type)) {
|
||||
return true;
|
||||
return UserStorageCredentialConfigured.CONFIGURED;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (user.getFederationLink() != null) {
|
||||
UserStorageProvider provider = UserStorageManager.getStorageProvider(session, realm, user.getFederationLink());
|
||||
if (provider instanceof CredentialInputValidator) {
|
||||
if (!UserStorageManager.isStorageProviderEnabled(realm, user.getFederationLink())) return false;
|
||||
if (((CredentialInputValidator) provider).isConfiguredFor(realm, user, type)) return true;
|
||||
if (!UserStorageManager.isStorageProviderEnabled(realm, user.getFederationLink())) return UserStorageCredentialConfigured.USER_STORAGE_DISABLED;
|
||||
if (((CredentialInputValidator) provider).isConfiguredFor(realm, user, type)) return UserStorageCredentialConfigured.CONFIGURED;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return isConfiguredLocally(realm, user, type);
|
||||
return UserStorageCredentialConfigured.NOT_CONFIGURED;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -350,6 +371,15 @@ public class UserCredentialStoreManager implements UserCredentialManager, OnUser
|
|||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getConfiguredUserStorageCredentialTypes(RealmModel realm, UserModel user) {
|
||||
List<CredentialProvider> credentialProviders = getCredentialProviders(session, realm, CredentialProvider.class);
|
||||
|
||||
return credentialProviders.stream().map(CredentialProvider::getType)
|
||||
.filter(credentialType -> UserStorageCredentialConfigured.CONFIGURED == isConfiguredThroughUserStorage(realm, user, credentialType))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
|
|
|
@ -27,8 +27,6 @@ import org.keycloak.common.ClientConnection;
|
|||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.credential.CredentialModel;
|
||||
import org.keycloak.credential.CredentialProvider;
|
||||
import org.keycloak.credential.PasswordCredentialProvider;
|
||||
import org.keycloak.email.EmailException;
|
||||
import org.keycloak.email.EmailTemplateProvider;
|
||||
import org.keycloak.events.Details;
|
||||
|
@ -47,6 +45,7 @@ import org.keycloak.models.ModelDuplicateException;
|
|||
import org.keycloak.models.ModelException;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserConsentModel;
|
||||
import org.keycloak.models.UserCredentialModel;
|
||||
import org.keycloak.models.UserLoginFailureModel;
|
||||
import org.keycloak.models.UserManager;
|
||||
import org.keycloak.models.UserModel;
|
||||
|
@ -80,7 +79,6 @@ import javax.ws.rs.Consumes;
|
|||
import javax.ws.rs.DELETE;
|
||||
import javax.ws.rs.DefaultValue;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.NotFoundException;
|
||||
import javax.ws.rs.NotSupportedException;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.PUT;
|
||||
|
@ -598,8 +596,7 @@ public class UserResource {
|
|||
}
|
||||
|
||||
try {
|
||||
PasswordCredentialProvider provider = (PasswordCredentialProvider)session.getProvider(CredentialProvider.class, "keycloak-password");
|
||||
provider.createCredential(realm, user, cred.getValue());
|
||||
session.userCredentialManager().updateCredential(realm, user, UserCredentialModel.password(cred.getValue(), false));
|
||||
} catch (IllegalStateException ise) {
|
||||
throw new BadRequestException("Resetting to N old passwords is not allowed.");
|
||||
} catch (ReadOnlyException mre) {
|
||||
|
@ -627,6 +624,24 @@ public class UserResource {
|
|||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return credential types, which are provided by the user storage where user is stored. Returned values can contain for example "password", "otp" etc.
|
||||
* This will always return empty list for "local" users, which are not backed by any user storage
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
@GET
|
||||
@Path("configured-user-storage-credential-types")
|
||||
@NoCache
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public List<String> getConfiguredUserStorageCredentialTypes() {
|
||||
// This has "requireManage" due the compatibility with "credentials()" endpoint. Strictly said, it is reading endpoint, not writing,
|
||||
// so may be revisited if to rather use "requireView" here in the future.
|
||||
auth.users().requireManage(user);
|
||||
return session.userCredentialManager().getConfiguredUserStorageCredentialTypes(realm, user);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Remove a credential for a user
|
||||
*
|
||||
|
|
|
@ -27,12 +27,15 @@ import org.keycloak.models.RealmModel;
|
|||
import org.keycloak.models.RoleModel;
|
||||
import org.keycloak.models.UserCredentialModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.credential.OTPCredentialModel;
|
||||
import org.keycloak.models.credential.PasswordCredentialModel;
|
||||
import org.keycloak.storage.UserStorageProvider;
|
||||
import org.keycloak.storage.user.UserLookupProvider;
|
||||
import org.keycloak.storage.user.UserRegistrationProvider;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
|
@ -49,6 +52,12 @@ public class DummyUserFederationProvider implements UserStorageProvider,
|
|||
private KeycloakSession session;
|
||||
private ComponentModel component;
|
||||
|
||||
// Hardcoded password of test-user
|
||||
public static final String HARDCODED_PASSWORD = "secret";
|
||||
|
||||
// Hardcoded otp code, which will be always considered valid for the test-user
|
||||
public static final String HARDCODED_OTP = "123456";
|
||||
|
||||
|
||||
|
||||
public DummyUserFederationProvider(KeycloakSession session, ComponentModel component, Map<String, UserModel> users) {
|
||||
|
@ -104,7 +113,7 @@ public class DummyUserFederationProvider implements UserStorageProvider,
|
|||
}
|
||||
|
||||
public Set<String> getSupportedCredentialTypes() {
|
||||
return Collections.singleton(PasswordCredentialModel.TYPE);
|
||||
return new HashSet<>(Arrays.asList(PasswordCredentialModel.TYPE, OTPCredentialModel.TYPE));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -114,7 +123,7 @@ public class DummyUserFederationProvider implements UserStorageProvider,
|
|||
|
||||
@Override
|
||||
public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
|
||||
if (!PasswordCredentialModel.TYPE.equals(credentialType)) return false;
|
||||
if (!supportsCredentialType(credentialType)) return false;
|
||||
|
||||
if (user.getUsername().equals("test-user")) {
|
||||
return true;
|
||||
|
@ -126,7 +135,11 @@ public class DummyUserFederationProvider implements UserStorageProvider,
|
|||
@Override
|
||||
public boolean isValid(RealmModel realm, UserModel user, CredentialInput credentialInput) {
|
||||
if (user.getUsername().equals("test-user")) {
|
||||
return "secret".equals(credentialInput.getChallengeResponse());
|
||||
if (PasswordCredentialModel.TYPE.equals(credentialInput.getType())) {
|
||||
return HARDCODED_PASSWORD.equals(credentialInput.getChallengeResponse());
|
||||
} else if (OTPCredentialModel.TYPE.equals(credentialInput.getType())) {
|
||||
return HARDCODED_OTP.equals(credentialInput.getChallengeResponse());
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@ import org.keycloak.testsuite.pages.AccountPasswordPage;
|
|||
import org.keycloak.testsuite.pages.AccountUpdateProfilePage;
|
||||
import org.keycloak.testsuite.pages.AppPage;
|
||||
import org.keycloak.testsuite.pages.LoginPage;
|
||||
import org.keycloak.testsuite.pages.LoginPasswordUpdatePage;
|
||||
import org.keycloak.testsuite.pages.OAuthGrantPage;
|
||||
import org.keycloak.testsuite.pages.RegisterPage;
|
||||
import org.keycloak.testsuite.util.LDAPRule;
|
||||
|
@ -64,6 +65,9 @@ public abstract class AbstractLDAPTest extends AbstractTestRealmKeycloakTest {
|
|||
@Page
|
||||
protected OAuthGrantPage grantPage;
|
||||
|
||||
@Page
|
||||
protected LoginPasswordUpdatePage requiredActionChangePasswordPage;
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
|
|
|
@ -23,6 +23,7 @@ import org.junit.FixMethodOrder;
|
|||
import org.junit.Test;
|
||||
import org.junit.runners.MethodSorters;
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.admin.client.resource.UserResource;
|
||||
import org.keycloak.component.ComponentModel;
|
||||
import org.keycloak.credential.CredentialModel;
|
||||
import org.keycloak.models.GroupModel;
|
||||
|
@ -38,6 +39,7 @@ import org.keycloak.models.utils.KeycloakModelUtils;
|
|||
import org.keycloak.models.utils.ModelToRepresentation;
|
||||
import org.keycloak.representations.AccessToken;
|
||||
import org.keycloak.representations.idm.ComponentRepresentation;
|
||||
import org.keycloak.representations.idm.CredentialRepresentation;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
import org.keycloak.services.managers.RealmManager;
|
||||
import org.keycloak.storage.ReadOnlyException;
|
||||
|
@ -66,8 +68,11 @@ import org.keycloak.testsuite.util.OAuthClient;
|
|||
|
||||
import javax.naming.AuthenticationException;
|
||||
import javax.ws.rs.core.Response;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
|
@ -329,7 +334,7 @@ public class LDAPProvidersIntegrationTest extends AbstractLDAPTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
public void passwordChangeLdap() throws Exception {
|
||||
public void ldapPasswordChangeWithAccountConsole() throws Exception {
|
||||
changePasswordPage.open();
|
||||
loginPage.login("johnkeycloak", "Password1");
|
||||
changePasswordPage.changePassword("Password1", "New-password1", "New-password1");
|
||||
|
@ -352,6 +357,77 @@ public class LDAPProvidersIntegrationTest extends AbstractLDAPTest {
|
|||
Assert.assertEquals("Your password has been updated.", profilePage.getSuccess());
|
||||
}
|
||||
|
||||
|
||||
// KEYCLOAK-12340
|
||||
@Test
|
||||
public void ldapPasswordChangeWithAdminEndpointAndRequiredAction() throws Exception {
|
||||
String username = "adminEndpointAndRequiredActionTest";
|
||||
String email = username + "@email.cz";
|
||||
|
||||
// Register new LDAP user with password, logout user
|
||||
loginPage.open();
|
||||
loginPage.clickRegister();
|
||||
registerPage.assertCurrent();
|
||||
registerPage.register("firstName", "lastName", email,
|
||||
username, "Password1", "Password1");
|
||||
|
||||
|
||||
Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType());
|
||||
|
||||
appPage.logout();
|
||||
|
||||
// Test admin endpoint. Assert federated endpoint returns password in LDAP "supportedCredentials", but there is no stored password
|
||||
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), username);
|
||||
assertPasswordConfiguredThroughLDAPOnly(user);
|
||||
|
||||
// Update password through admin REST endpoint. Assert user can authenticate with the new password
|
||||
ApiUtil.resetUserPassword(user, "Password1-updated1", false);
|
||||
|
||||
loginPage.open();
|
||||
|
||||
loginSuccessAndLogout(username, "Password1-updated1");
|
||||
|
||||
// Test admin endpoint. Assert federated endpoint returns password in LDAP "supportedCredentials", but there is no stored password
|
||||
assertPasswordConfiguredThroughLDAPOnly(user);
|
||||
|
||||
// Test this just for the import mode. No-import mode doesn't support requiredActions right now
|
||||
if (isImportEnabled()) {
|
||||
// Update password through required action.
|
||||
UserRepresentation user2 = user.toRepresentation();
|
||||
user2.setRequiredActions(Arrays.asList(UserModel.RequiredAction.UPDATE_PASSWORD.toString()));
|
||||
user.update(user2);
|
||||
|
||||
loginPage.open();
|
||||
loginPage.login(username, "Password1-updated1");
|
||||
requiredActionChangePasswordPage.assertCurrent();
|
||||
|
||||
requiredActionChangePasswordPage.changePassword("Password1-updated2", "Password1-updated2");
|
||||
|
||||
appPage.assertCurrent();
|
||||
appPage.logout();
|
||||
|
||||
// Assert user can authenticate with the new password
|
||||
loginSuccessAndLogout(username, "Password1-updated2");
|
||||
|
||||
// Test admin endpoint. Assert federated endpoint returns password in LDAP "supportedCredentials", but there is no stored password
|
||||
assertPasswordConfiguredThroughLDAPOnly(user);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Use admin REST endpoints
|
||||
private void assertPasswordConfiguredThroughLDAPOnly(UserResource user) {
|
||||
// Assert password not stored locally
|
||||
List<CredentialRepresentation> storedCredentials = user.credentials();
|
||||
for (CredentialRepresentation credential : storedCredentials) {
|
||||
Assert.assertFalse(PasswordCredentialModel.TYPE.equals(credential.getType()));
|
||||
}
|
||||
|
||||
// Assert password is stored in the LDAP
|
||||
List<String> userStorageCredentials = user.getConfiguredUserStorageCredentialTypes();
|
||||
Assert.assertTrue(userStorageCredentials.contains(PasswordCredentialModel.TYPE));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void registerExistingLdapUser() {
|
||||
loginPage.open();
|
||||
|
@ -908,6 +984,25 @@ public class LDAPProvidersIntegrationTest extends AbstractLDAPTest {
|
|||
} catch (AuthenticationException ex) {
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
});
|
||||
|
||||
// Test admin REST endpoints
|
||||
UserResource userResource = ApiUtil.findUserByUsernameId(testRealm(), "johnkeycloak");
|
||||
|
||||
// Assert password is stored locally
|
||||
List<String> storedCredentials = userResource.credentials().stream()
|
||||
.map(CredentialRepresentation::getType)
|
||||
.collect(Collectors.toList());
|
||||
Assert.assertTrue(storedCredentials.contains(PasswordCredentialModel.TYPE));
|
||||
|
||||
// Assert password is supported in the LDAP too.
|
||||
List<String> userStorageCredentials = userResource.getConfiguredUserStorageCredentialTypes();
|
||||
Assert.assertTrue(userStorageCredentials.contains(PasswordCredentialModel.TYPE));
|
||||
|
||||
testingClient.server().run(session -> {
|
||||
LDAPTestContext ctx = LDAPTestContext.init(session);
|
||||
RealmModel appRealm = ctx.getRealm();
|
||||
UserModel user = session.users().getUserByUsername("johnkeycloak", appRealm);
|
||||
|
||||
// User is deleted just locally
|
||||
Assert.assertTrue(session.users().removeUser(appRealm, user));
|
||||
|
|
|
@ -0,0 +1,222 @@
|
|||
/*
|
||||
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.keycloak.testsuite.federation.storage;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
import org.jboss.arquillian.graphene.page.Page;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.admin.client.resource.UserResource;
|
||||
import org.keycloak.common.util.MultivaluedHashMap;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.credential.OTPCredentialModel;
|
||||
import org.keycloak.models.credential.PasswordCredentialModel;
|
||||
import org.keycloak.models.utils.TimeBasedOTP;
|
||||
import org.keycloak.representations.idm.ComponentRepresentation;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
import org.keycloak.storage.UserStorageProvider;
|
||||
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
|
||||
import org.keycloak.testsuite.Assert;
|
||||
import org.keycloak.testsuite.admin.ApiUtil;
|
||||
import org.keycloak.testsuite.federation.DummyUserFederationProvider;
|
||||
import org.keycloak.testsuite.federation.DummyUserFederationProviderFactory;
|
||||
import org.keycloak.testsuite.pages.AppPage;
|
||||
import org.keycloak.testsuite.pages.LoginConfigTotpPage;
|
||||
import org.keycloak.testsuite.pages.LoginPage;
|
||||
import org.keycloak.testsuite.pages.LoginTotpPage;
|
||||
import org.keycloak.testsuite.util.UserBuilder;
|
||||
|
||||
import static org.keycloak.storage.UserStorageProviderModel.IMPORT_ENABLED;
|
||||
import static org.keycloak.testsuite.federation.storage.UserStorageTest.addComponent;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
*/
|
||||
public class UserStorageOTPTest extends AbstractTestRealmKeycloakTest {
|
||||
|
||||
|
||||
@Page
|
||||
protected LoginPage loginPage;
|
||||
|
||||
@Page
|
||||
protected LoginTotpPage loginTotpPage;
|
||||
|
||||
@Page
|
||||
protected LoginConfigTotpPage loginConfigTotpPage;
|
||||
|
||||
@Page
|
||||
protected AppPage appPage;
|
||||
|
||||
protected TimeBasedOTP totp = new TimeBasedOTP();
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void configureTestRealm(RealmRepresentation testRealm) {
|
||||
|
||||
}
|
||||
|
||||
@Before
|
||||
public void addProvidersBeforeTest() throws URISyntaxException, IOException {
|
||||
ComponentRepresentation dummyProvider = new ComponentRepresentation();
|
||||
dummyProvider.setName("dummy");
|
||||
dummyProvider.setId(DummyUserFederationProviderFactory.PROVIDER_NAME);
|
||||
dummyProvider.setProviderId(DummyUserFederationProviderFactory.PROVIDER_NAME);
|
||||
dummyProvider.setProviderType(UserStorageProvider.class.getName());
|
||||
dummyProvider.setConfig(new MultivaluedHashMap<>());
|
||||
dummyProvider.getConfig().putSingle("priority", Integer.toString(0));
|
||||
dummyProvider.getConfig().putSingle(IMPORT_ENABLED, Boolean.toString(false));
|
||||
|
||||
addComponent(testRealm(), getCleanup(), dummyProvider);
|
||||
|
||||
UserRepresentation user = UserBuilder.create()
|
||||
.username("test-user")
|
||||
.email("test-user@something.org")
|
||||
.build();
|
||||
String testUserId = ApiUtil.createUserWithAdminClient(testRealm(), user);
|
||||
|
||||
getCleanup().addUserId(testUserId);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testCredentialsThroughRESTAPI() {
|
||||
// Test that test-user has federation link on him
|
||||
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), "test-user");
|
||||
Assert.assertEquals(DummyUserFederationProviderFactory.PROVIDER_NAME, user.toRepresentation().getFederationLink());
|
||||
|
||||
// Test that both "password" and "otp" are configured for the test-user
|
||||
List<String> userStorageCredentialTypes = user.getConfiguredUserStorageCredentialTypes();
|
||||
Assert.assertNames(userStorageCredentialTypes, PasswordCredentialModel.TYPE, OTPCredentialModel.TYPE);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testAuthentication() {
|
||||
// Test that user is required to provide OTP credential during authentication
|
||||
loginPage.open();
|
||||
loginPage.login("test-user", DummyUserFederationProvider.HARDCODED_PASSWORD);
|
||||
|
||||
loginTotpPage.assertCurrent();
|
||||
|
||||
loginTotpPage.login("654321");
|
||||
loginTotpPage.assertCurrent();
|
||||
Assert.assertEquals("Invalid authenticator code.", loginPage.getError());
|
||||
|
||||
loginTotpPage.login(DummyUserFederationProvider.HARDCODED_OTP);
|
||||
|
||||
appPage.assertCurrent();
|
||||
Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType());
|
||||
Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE));
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testUpdateOTP() {
|
||||
// Add requiredAction to the user for update OTP
|
||||
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), "test-user");
|
||||
UserRepresentation userRep = user.toRepresentation();
|
||||
userRep.setRequiredActions(Collections.singletonList(UserModel.RequiredAction.CONFIGURE_TOTP.toString()));
|
||||
user.update(userRep);
|
||||
|
||||
// Authenticate as the user
|
||||
loginPage.open();
|
||||
loginPage.login("test-user", DummyUserFederationProvider.HARDCODED_PASSWORD);
|
||||
loginTotpPage.assertCurrent();
|
||||
loginTotpPage.login(DummyUserFederationProvider.HARDCODED_OTP);
|
||||
|
||||
// User should be required to update OTP
|
||||
loginConfigTotpPage.assertCurrent();
|
||||
|
||||
// Dummy OTP code won't work when configure new OTP
|
||||
loginConfigTotpPage.configure(DummyUserFederationProvider.HARDCODED_OTP);
|
||||
Assert.assertEquals("Invalid authenticator code.", loginConfigTotpPage.getError());
|
||||
|
||||
// This will save the credential to the local DB
|
||||
String totpSecret = loginConfigTotpPage.getTotpSecret();
|
||||
log.infof("Totp Secret: %s", totpSecret);
|
||||
String totpCode = totp.generateTOTP(totpSecret);
|
||||
loginConfigTotpPage.configure(totpCode);
|
||||
|
||||
appPage.assertCurrent();
|
||||
|
||||
// Logout
|
||||
appPage.logout();
|
||||
|
||||
// Authenticate as the user again with the dummy OTP should still work
|
||||
loginPage.open();
|
||||
loginPage.login("test-user", DummyUserFederationProvider.HARDCODED_PASSWORD);
|
||||
loginTotpPage.assertCurrent();
|
||||
loginTotpPage.login(DummyUserFederationProvider.HARDCODED_OTP);
|
||||
|
||||
appPage.assertCurrent();
|
||||
appPage.logout();
|
||||
|
||||
// Authenticate with the new OTP code should work as well
|
||||
loginPage.open();
|
||||
loginPage.login("test-user", DummyUserFederationProvider.HARDCODED_PASSWORD);
|
||||
loginTotpPage.assertCurrent();
|
||||
loginTotpPage.login(totp.generateTOTP(totpSecret));
|
||||
|
||||
appPage.assertCurrent();
|
||||
appPage.logout();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNormalUser() {
|
||||
// Add some other user to the KEycloak
|
||||
UserRepresentation user = UserBuilder.create()
|
||||
.username("test-user2")
|
||||
.email("test-user2@something.org")
|
||||
.build();
|
||||
String testUserId = ApiUtil.createUserWithAdminClient(testRealm(), user);
|
||||
getCleanup().addUserId(testUserId);
|
||||
|
||||
// Assert he has federation link on him
|
||||
UserResource userResource = ApiUtil.findUserByUsernameId(testRealm(), "test-user2");
|
||||
Assert.assertEquals(DummyUserFederationProviderFactory.PROVIDER_NAME, userResource.toRepresentation().getFederationLink());
|
||||
|
||||
// Assert no userStorage supported credentials shown through admin REST API for that user. For this user, the validation of password and OTP is not delegated
|
||||
// to the dummy user storage provider
|
||||
Assert.assertTrue(userResource.getConfiguredUserStorageCredentialTypes().isEmpty());
|
||||
|
||||
// Update password
|
||||
ApiUtil.resetUserPassword(userResource, "pass", false);
|
||||
|
||||
// Authenticate as the user. Only the password will be required for him
|
||||
loginPage.open();
|
||||
loginPage.login("test-user2", "pass");
|
||||
|
||||
appPage.assertCurrent();
|
||||
Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType());
|
||||
Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
|
@ -1548,7 +1548,11 @@ disable-credential-types=Disable Credential Types
|
|||
credentials.disable.tooltip=Click button to disable selected credential types
|
||||
credential-types=Credential Types
|
||||
manage-user-password=Manage Password
|
||||
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.
|
||||
provided-by=Provided By
|
||||
manage-credentials=Manage Credentials
|
||||
manage-credentials.tooltip=Credentials, which are not provided by the user storage. They are saved in the local database.
|
||||
disable-credentials=Disable Credentials
|
||||
credential-reset-actions=Credential Reset
|
||||
credential-reset-actions-timeout=Expires In
|
||||
|
|
|
@ -331,6 +331,46 @@ module.controller('UserTabCtrl', function($scope, $location, Dialog, Notificatio
|
|||
};
|
||||
});
|
||||
|
||||
function loadUserStorageLink(realm, user, console, Components, UserStorageOperations, $scope, $location) {
|
||||
if(user.federationLink) {
|
||||
console.log("federationLink is not null. It is " + user.federationLink);
|
||||
|
||||
if ($scope.access.viewRealm) {
|
||||
Components.get({realm: realm.realm, componentId: user.federationLink}, function (link) {
|
||||
$scope.federationLinkName = link.name;
|
||||
$scope.federationLink = "#/realms/" + realm.realm + "/user-storage/providers/" + link.providerId + "/" + link.id;
|
||||
});
|
||||
} else {
|
||||
// KEYCLOAK-4328
|
||||
UserStorageOperations.simpleName.get({realm: realm.realm, componentId: user.federationLink}, function (link) {
|
||||
$scope.federationLinkName = link.name;
|
||||
$scope.federationLink = $location.absUrl();
|
||||
})
|
||||
}
|
||||
|
||||
} else {
|
||||
console.log("federationLink is null");
|
||||
}
|
||||
|
||||
if(user.origin) {
|
||||
if ($scope.access.viewRealm) {
|
||||
Components.get({realm: realm.realm, componentId: user.origin}, function (link) {
|
||||
$scope.originName = link.name;
|
||||
$scope.originLink = "#/realms/" + realm.realm + "/user-storage/providers/" + link.providerId + "/" + link.id;
|
||||
})
|
||||
}
|
||||
else {
|
||||
// KEYCLOAK-4328
|
||||
UserStorageOperations.simpleName.get({realm: realm.realm, componentId: user.origin}, function (link) {
|
||||
$scope.originName = link.name;
|
||||
$scope.originLink = $location.absUrl();
|
||||
})
|
||||
}
|
||||
} else {
|
||||
console.log("origin is null");
|
||||
}
|
||||
};
|
||||
|
||||
module.controller('UserDetailCtrl', function($scope, realm, user, BruteForceUser, User,
|
||||
Components,
|
||||
UserImpersonation, RequiredActions,
|
||||
|
@ -359,42 +399,9 @@ module.controller('UserDetailCtrl', function($scope, realm, user, BruteForceUser
|
|||
}
|
||||
});
|
||||
};
|
||||
if(user.federationLink) {
|
||||
console.log("federationLink is not null. It is " + user.federationLink);
|
||||
|
||||
if ($scope.access.viewRealm) {
|
||||
Components.get({realm: realm.realm, componentId: user.federationLink}, function (link) {
|
||||
$scope.federationLinkName = link.name;
|
||||
$scope.federationLink = "#/realms/" + realm.realm + "/user-storage/providers/" + link.providerId + "/" + link.id;
|
||||
});
|
||||
} else {
|
||||
// KEYCLOAK-4328
|
||||
UserStorageOperations.simpleName.get({realm: realm.realm, componentId: user.federationLink}, function (link) {
|
||||
$scope.federationLinkName = link.name;
|
||||
$scope.federationLink = $location.absUrl();
|
||||
})
|
||||
}
|
||||
loadUserStorageLink(realm, user, console, Components, UserStorageOperations, $scope, $location);
|
||||
|
||||
} else {
|
||||
console.log("federationLink is null");
|
||||
}
|
||||
if(user.origin) {
|
||||
if ($scope.access.viewRealm) {
|
||||
Components.get({realm: realm.realm, componentId: user.origin}, function (link) {
|
||||
$scope.originName = link.name;
|
||||
$scope.originLink = "#/realms/" + realm.realm + "/user-storage/providers/" + link.providerId + "/" + link.id;
|
||||
})
|
||||
}
|
||||
else {
|
||||
// KEYCLOAK-4328
|
||||
UserStorageOperations.simpleName.get({realm: realm.realm, componentId: user.origin}, function (link) {
|
||||
$scope.originName = link.name;
|
||||
$scope.originLink = $location.absUrl();
|
||||
})
|
||||
}
|
||||
} else {
|
||||
console.log("origin is null");
|
||||
}
|
||||
console.log('realm brute force? ' + realm.bruteForceProtected)
|
||||
$scope.temporarilyDisabled = false;
|
||||
var isDisabled = function () {
|
||||
|
@ -514,7 +521,8 @@ module.controller('UserDetailCtrl', function($scope, realm, user, BruteForceUser
|
|||
}
|
||||
});
|
||||
|
||||
module.controller('UserCredentialsCtrl', function($scope, realm, user, $route, RequiredActions, User, UserExecuteActionsEmail, UserCredentials, Notifications, Dialog, TimeUnit2) {
|
||||
module.controller('UserCredentialsCtrl', function($scope, realm, user, $route, $location, RequiredActions, User, UserExecuteActionsEmail,
|
||||
UserCredentials, Notifications, Dialog, TimeUnit2, Components, UserStorageOperations) {
|
||||
console.log('UserCredentialsCtrl');
|
||||
|
||||
$scope.hasPassword = false;
|
||||
|
@ -523,6 +531,16 @@ module.controller('UserCredentialsCtrl', function($scope, realm, user, $route, R
|
|||
|
||||
loadCredentials();
|
||||
|
||||
loadUserStorageLink(realm, user, console, Components, UserStorageOperations, $scope, $location);
|
||||
|
||||
$scope.getUserStorageProviderName = function() {
|
||||
return user.federationLink ? $scope.federationLinkName : $scope.originName;
|
||||
}
|
||||
|
||||
$scope.getUserStorageProviderLink = function() {
|
||||
return user.federationLink ? $scope.federationLink : $scope.originLink;
|
||||
}
|
||||
|
||||
$scope.keys = function(object) {
|
||||
return object ? Object.keys(object) : [];
|
||||
}
|
||||
|
@ -648,6 +666,14 @@ module.controller('UserCredentialsCtrl', function($scope, realm, user, $route, R
|
|||
Notifications.error("Error while loading user credentials. See console for more information.");
|
||||
console.log(err);
|
||||
});
|
||||
|
||||
UserCredentials.getConfiguredUserStorageCredentialTypes({ realm: realm.realm, userId: user.id }, null, function(userStorageCredentialTypes) {
|
||||
$scope.userStorageCredentialTypes = userStorageCredentialTypes;
|
||||
$scope.hasPassword = $scope.hasPassword || userStorageCredentialTypes.lastIndexOf("password") > -1;
|
||||
}, function(err) {
|
||||
Notifications.error("Error while loading user storage credentials. See console for more information.");
|
||||
console.log(err);
|
||||
});
|
||||
}
|
||||
|
||||
$scope.resetPassword = function() {
|
||||
|
|
|
@ -659,6 +659,11 @@ module.factory('UserCredentials', function($resource) {
|
|||
userId : '@userId'
|
||||
}).query;
|
||||
|
||||
credentials.getConfiguredUserStorageCredentialTypes = $resource(authUrl + '/admin/realms/:realm/users/:userId/configured-user-storage-credential-types', {
|
||||
realm : '@realm',
|
||||
userId : '@userId'
|
||||
}).query;
|
||||
|
||||
credentials.deleteCredential = $resource(authUrl + '/admin/realms/:realm/users/:userId/credentials/:credentialId', {
|
||||
realm : '@realm',
|
||||
userId : '@userId',
|
||||
|
|
|
@ -7,8 +7,38 @@
|
|||
<kc-tabs-user></kc-tabs-user>
|
||||
|
||||
<form class="form-horizontal" name="userForm" novalidate>
|
||||
|
||||
<fieldset class="border-top" data-ng-show="user.federationLink || user.origin">
|
||||
<legend>
|
||||
<span class="text">{{:: 'supported-user-storage-credential-types' | translate}}</span>
|
||||
<kc-tooltip>{{:: 'supported-user-storage-credential-types.tooltip' | translate}}</kc-tooltip>
|
||||
</legend>
|
||||
<table class="datatable table table-bordered dataTable no-footer credentials-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{:: 'type' | translate}}</th>
|
||||
<th>{{:: 'provided-by' | translate}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr ng-repeat="credential in userStorageCredentialTypes">
|
||||
<td>
|
||||
<b>{{credential}}</b>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{getUserStorageProviderLink()}}">{{getUserStorageProviderName()}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="border-top">
|
||||
<legend><span class="text">{{:: 'manage-credentials' | translate}}</span></legend>
|
||||
<legend data-ng-show="user.federationLink || user.origin">
|
||||
<span class="text">{{:: 'manage-credentials' | translate}}</span>
|
||||
<kc-tooltip>{{:: 'manage-credentials.tooltip' | translate}}</kc-tooltip>
|
||||
</legend>
|
||||
<legend data-ng-hide="user.federationLink || user.origin">
|
||||
<span class="text">{{:: 'manage-credentials' | translate}}</span>
|
||||
</legend>
|
||||
<table class="datatable table table-bordered dataTable no-footer credentials-table">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
Loading…
Reference in a new issue