KEYCLOAK-12860 KEYCLOAK-12875 Fix for Account REST Credentials to work with LDAP and social users

This commit is contained in:
mposolda 2020-02-12 08:44:23 +01:00 committed by Marek Posolda
parent 876086c846
commit a76c496c23
15 changed files with 309 additions and 23 deletions

View file

@ -2,6 +2,7 @@ package org.keycloak.authentication;
import org.keycloak.credential.CredentialProvider;
import org.keycloak.credential.CredentialTypeMetadata;
import org.keycloak.credential.CredentialTypeMetadataContext;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
@ -15,7 +16,10 @@ public class AuthenticationSelectionOption {
Authenticator authenticator = session.getProvider(Authenticator.class, authExec.getAuthenticator());
if (authenticator instanceof CredentialValidator) {
CredentialProvider credentialProvider = ((CredentialValidator) authenticator).getCredentialProvider(session);
credentialTypeMetadata = credentialProvider.getCredentialTypeMetadata();
CredentialTypeMetadataContext ctx = CredentialTypeMetadataContext.builder()
.build(session);
credentialTypeMetadata = credentialProvider.getCredentialTypeMetadata(ctx);
} else {
credentialTypeMetadata = null;
}

View file

@ -50,5 +50,5 @@ public interface CredentialProvider<T extends CredentialModel> extends Provider
return getCredentialFromModel(models.get(0));
}
CredentialTypeMetadata getCredentialTypeMetadata();
CredentialTypeMetadata getCredentialTypeMetadata(CredentialTypeMetadataContext metadataContext);
}

View file

@ -50,7 +50,7 @@ public class CredentialTypeMetadata implements Comparable<CredentialTypeMetadata
public enum Category {
PASSWORD("password", 1),
BASIC_AUTHENTICATION("basic-authentication", 1),
TWO_FACTOR("two-factor", 2),
PASSWORDLESS("passwordless", 3);

View file

@ -0,0 +1,62 @@
/*
* 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.credential;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class CredentialTypeMetadataContext {
private UserModel user;
private CredentialTypeMetadataContext() {
}
/**
* @return user, for which we create metadata. Could be null
*/
public UserModel getUser() {
return user;
}
public static CredentialTypeMetadataContext.CredentialTypeMetadataContextBuilder builder() {
return new CredentialTypeMetadataContext.CredentialTypeMetadataContextBuilder();
}
// BUILDER
public static class CredentialTypeMetadataContextBuilder {
private CredentialTypeMetadataContext instance = new CredentialTypeMetadataContext();
public CredentialTypeMetadataContext.CredentialTypeMetadataContextBuilder user(UserModel user) {
instance.user = user;
return this;
}
public CredentialTypeMetadataContext build(KeycloakSession session) {
// Possible to have null user
return instance;
}
}
}

View file

@ -142,7 +142,7 @@ public class OTPCredentialProvider implements CredentialProvider<OTPCredentialMo
}
@Override
public CredentialTypeMetadata getCredentialTypeMetadata() {
public CredentialTypeMetadata getCredentialTypeMetadata(CredentialTypeMetadataContext metadataContext) {
return CredentialTypeMetadata.builder()
.type(getType())
.category(CredentialTypeMetadata.Category.TWO_FACTOR)

View file

@ -17,7 +17,6 @@
package org.keycloak.credential;
import org.jboss.logging.Logger;
import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory;
import org.keycloak.common.util.Time;
import org.keycloak.credential.hash.PasswordHashProvider;
import org.keycloak.models.ModelException;
@ -296,14 +295,23 @@ public class PasswordCredentialProvider implements CredentialProvider<PasswordCr
}
@Override
public CredentialTypeMetadata getCredentialTypeMetadata() {
return CredentialTypeMetadata.builder()
public CredentialTypeMetadata getCredentialTypeMetadata(CredentialTypeMetadataContext metadataContext) {
CredentialTypeMetadata.CredentialTypeMetadataBuilder metadataBuilder = CredentialTypeMetadata.builder()
.type(getType())
.category(CredentialTypeMetadata.Category.PASSWORD)
.displayName("password")
.category(CredentialTypeMetadata.Category.BASIC_AUTHENTICATION)
.displayName("password-display-name")
.helpText("password-help-text")
.iconCssClass("kcAuthenticatorPasswordClass")
.updateAction(UserModel.RequiredAction.UPDATE_PASSWORD.toString())
.iconCssClass("kcAuthenticatorPasswordClass");
// Check if we are creating or updating password
UserModel user = metadataContext.getUser();
if (user != null && session.userCredentialManager().isConfiguredFor(session.getContext().getRealm(), user, getType())) {
metadataBuilder.updateAction(UserModel.RequiredAction.UPDATE_PASSWORD.toString());
} else {
metadataBuilder.createAction(UserModel.RequiredAction.UPDATE_PASSWORD.toString());
}
return metadataBuilder
.removeable(false)
.build(session);
}

View file

@ -230,7 +230,7 @@ public class WebAuthnCredentialProvider implements CredentialProvider<WebAuthnCr
}
@Override
public CredentialTypeMetadata getCredentialTypeMetadata() {
public CredentialTypeMetadata getCredentialTypeMetadata(CredentialTypeMetadataContext metadataContext) {
return CredentialTypeMetadata.builder()
.type(getType())
.category(CredentialTypeMetadata.Category.TWO_FACTOR)

View file

@ -40,7 +40,7 @@ public class WebAuthnPasswordlessCredentialProvider extends WebAuthnCredentialPr
}
@Override
public CredentialTypeMetadata getCredentialTypeMetadata() {
public CredentialTypeMetadata getCredentialTypeMetadata(CredentialTypeMetadataContext metadataContext) {
return CredentialTypeMetadata.builder()
.type(getType())
.category(CredentialTypeMetadata.Category.PASSWORDLESS)

View file

@ -7,6 +7,7 @@ import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.credential.CredentialModel;
import org.keycloak.credential.CredentialProvider;
import org.keycloak.credential.CredentialTypeMetadata;
import org.keycloak.credential.CredentialTypeMetadataContext;
import org.keycloak.credential.PasswordCredentialProvider;
import org.keycloak.credential.PasswordCredentialProviderFactory;
import org.keycloak.credential.UserCredentialStoreManager;
@ -31,6 +32,7 @@ import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Response;
import java.io.IOException;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedList;
@ -185,13 +187,29 @@ public class AccountCredentialResource {
continue;
}
CredentialTypeMetadata metadata = credentialProvider.getCredentialTypeMetadata();
CredentialTypeMetadataContext ctx = CredentialTypeMetadataContext.builder()
.user(user)
.build(session);
CredentialTypeMetadata metadata = credentialProvider.getCredentialTypeMetadata(ctx);
List<CredentialRepresentation> userCredentialModels = filterUserCredentials ? null : models.stream()
.filter(credentialModel -> credentialProvider.getType().equals(credentialModel.getType()))
.map(ModelToRepresentation::toRepresentation)
.collect(Collectors.toList());
if (userCredentialModels != null && userCredentialModels.isEmpty() &&
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
// creating "dummy" credential representing the credential provided by userStorage
CredentialRepresentation credential = new CredentialRepresentation();
credential.setId(credentialProviderType + "-id");
credential.setType(credentialProviderType);
credential.setCreatedDate(-1L);
credential.setPriority(0);
userCredentialModels = Collections.singletonList(credential);
}
CredentialContainer credType = new CredentialContainer(metadata, userCredentialModels);
credentialTypes.add(credType);
}

View file

@ -20,6 +20,7 @@
<resource-root path="integration-arquillian-testsuite-providers-${project.version}.jar"/>
</resources>
<dependencies>
<module name="com.fasterxml.jackson.core.jackson-core"/>
<module name="javax.api"/>
<module name="javax.ws.rs.api"/>
<module name="javax.servlet.api"/>

View file

@ -20,10 +20,10 @@ import com.fasterxml.jackson.core.type.TypeReference;
import org.junit.Assert;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.authentication.authenticators.browser.WebAuthnAuthenticatorFactory;
import org.keycloak.authentication.authenticators.browser.WebAuthnPasswordlessAuthenticatorFactory;
import org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory;
import org.keycloak.authentication.requiredactions.WebAuthnRegister;
import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.credential.CredentialTypeMetadata;
@ -45,7 +45,6 @@ import org.keycloak.representations.idm.AuthenticationFlowRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
import org.keycloak.representations.idm.RequiredActionProviderSimpleRepresentation;
@ -70,7 +69,6 @@ import static org.junit.Assert.*;
import org.keycloak.services.resources.account.AccountCredentialResource.PasswordUpdate;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer;
import org.keycloak.testsuite.util.WaitUtils;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -91,6 +89,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
@Test
public void testUpdateProfile() throws IOException {
UserRepresentation user = SimpleHttp.doGet(getAccountUrl(null), httpClient).auth(tokenUtil.getToken()).asJson(UserRepresentation.class);
String originalUsername = user.getUsername();
String originalFirstName = user.getFirstName();
String originalLastName = user.getLastName();
String originalEmail = user.getEmail();
@ -157,6 +156,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
realmRep.setEditUsernameAllowed(true);
adminClient.realm("test").update(realmRep);
user.setUsername(originalUsername);
user.setFirstName(originalFirstName);
user.setLastName(originalLastName);
user.setEmail(originalEmail);
@ -316,8 +316,8 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
Assert.assertEquals(4, credentials.size());
AccountCredentialResource.CredentialContainer password = credentials.get(0);
assertCredentialContainerExpected(password, PasswordCredentialModel.TYPE, CredentialTypeMetadata.Category.PASSWORD.toString(),
"password", "password-help-text", "kcAuthenticatorPasswordClass",
assertCredentialContainerExpected(password, PasswordCredentialModel.TYPE, CredentialTypeMetadata.Category.BASIC_AUTHENTICATION.toString(),
"password-display-name", "password-help-text", "kcAuthenticatorPasswordClass",
null, UserModel.RequiredAction.UPDATE_PASSWORD.toString(), false, 1);
CredentialRepresentation password1 = password.getUserCredentials().get(0);
@ -443,6 +443,32 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
}
}
@Test
public void testCredentialsForUserWithoutPassword() throws IOException {
// This is just to call REST to ensure tokenUtil will authenticate user and create the tokens.
// We won't be able to authenticate later as user won't have password
List<AccountCredentialResource.CredentialContainer> credentials = getCredentials();
// Remove password from the user now
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost");
for (CredentialRepresentation credential : user.credentials()) {
if (PasswordCredentialModel.TYPE.equals(credential.getType())) {
user.removeCredential(credential.getId());
}
}
// Get credentials. Ensure user doesn't have password credential and create action is UPDATE_PASSWORD
credentials = getCredentials();
AccountCredentialResource.CredentialContainer password = credentials.get(0);
assertCredentialContainerExpected(password, PasswordCredentialModel.TYPE, CredentialTypeMetadata.Category.BASIC_AUTHENTICATION.toString(),
"password-display-name", "password-help-text", "kcAuthenticatorPasswordClass",
UserModel.RequiredAction.UPDATE_PASSWORD.toString(), null, false, 0);
// Re-add the password to the user
ApiUtil.resetUserPassword(user, "password", false);
}
// Sets new requirement and returns current requirement
private AuthenticationExecutionModel.Requirement setExecutionRequirement(String flowAlias, String executionDisplayName, AuthenticationExecutionModel.Requirement newRequirement) {
List<AuthenticationExecutionInfoRepresentation> executionInfos = testRealm().flows().getExecutions(flowAlias);

View file

@ -0,0 +1,136 @@
/*
* 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.ldap;
import java.io.IOException;
import java.util.List;
import com.fasterxml.jackson.core.type.TypeReference;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.FixMethodOrder;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runners.MethodSorters;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.models.RealmModel;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.representations.account.UserRepresentation;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.services.resources.account.AccountCredentialResource;
import org.keycloak.storage.ldap.idm.model.LDAPObject;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.util.LDAPRule;
import org.keycloak.testsuite.util.LDAPTestUtils;
import org.keycloak.testsuite.util.TokenUtil;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.keycloak.common.Profile.Feature.ACCOUNT_API;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@EnableFeature(value = ACCOUNT_API, skipRestart = true)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class LDAPAccountRestApiTest extends AbstractLDAPTest {
@Rule
public TokenUtil tokenUtil = new TokenUtil("johnkeycloak", "Password1");
@ClassRule
public static LDAPRule ldapRule = new LDAPRule();
protected CloseableHttpClient httpClient;
@Before
public void before() {
httpClient = HttpClientBuilder.create().build();
}
@After
public void after() {
try {
httpClient.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
protected LDAPRule getLDAPRule() {
return ldapRule;
}
@Override
protected void afterImportTestRealm() {
testingClient.server().run(session -> {
LDAPTestContext ctx = LDAPTestContext.init(session);
RealmModel appRealm = ctx.getRealm();
// Delete all LDAP users and add some new for testing
LDAPTestUtils.removeAllLDAPUsers(ctx.getLdapProvider(), appRealm);
LDAPObject john = LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "johnkeycloak", "John", "Doe", "john@email.org", null, "1234");
LDAPTestUtils.updateLDAPPassword(ctx.getLdapProvider(), john, "Password1");
});
}
@Test
public void testGetProfile() throws IOException {
UserRepresentation user = SimpleHttp.doGet(getAccountUrl(null), httpClient).auth(tokenUtil.getToken()).asJson(UserRepresentation.class);
assertEquals("John", user.getFirstName());
assertEquals("Doe", user.getLastName());
assertEquals("john@email.org", user.getEmail());
assertFalse(user.isEmailVerified());
}
@Test
public void testGetCredentials() throws IOException {
List<AccountCredentialResource.CredentialContainer> credentials = getCredentials();
AccountCredentialResource.CredentialContainer password = credentials.get(0);
Assert.assertEquals(PasswordCredentialModel.TYPE, password.getType());
Assert.assertEquals(1, password.getUserCredentials().size());
CredentialRepresentation userPassword = password.getUserCredentials().get(0);
// Password won't have createdDate and any metadata set
Assert.assertEquals(PasswordCredentialModel.TYPE, userPassword.getType());
Assert.assertEquals(userPassword.getCreatedDate(), new Long(-1L));
Assert.assertNull(userPassword.getCredentialData());
Assert.assertNull(userPassword.getSecretData());
}
private String getAccountUrl(String resource) {
return suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/test/account" + (resource != null ? "/" + resource : "");
}
// Send REST request to get all credential containers and credentials of current user
private List<AccountCredentialResource.CredentialContainer> getCredentials() throws IOException {
return SimpleHttp.doGet(getAccountUrl("credentials"), httpClient)
.auth(tokenUtil.getToken()).asJson(new TypeReference<List<AccountCredentialResource.CredentialContainer>>() {});
}
}

View file

@ -36,6 +36,7 @@ import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
import org.keycloak.representations.idm.RequiredActionProviderSimpleRepresentation;
import org.keycloak.testsuite.WebAuthnAssume;
import org.keycloak.testsuite.admin.Users;
import org.keycloak.testsuite.auth.page.login.OTPSetup;
import org.keycloak.testsuite.auth.page.login.UpdatePassword;
import org.keycloak.testsuite.pages.webauthn.WebAuthnRegisterPage;
@ -62,7 +63,7 @@ import static org.keycloak.testsuite.util.WaitUtils.waitForPageToLoad;
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public class SigningInTest extends BaseAccountPageTest {
public static final String PASSWORD_LABEL = "Password";
public static final String PASSWORD_LABEL = "My Password";
public static final String WEBAUTHN_FLOW_ID = "75e2390e-f296-49e6-acf8-6d21071d7e10";
@Page
@ -148,7 +149,7 @@ public class SigningInTest extends BaseAccountPageTest {
assertEquals(3, signingInPage.getCategoriesCount());
assertEquals("Password", signingInPage.getCategoryTitle("password"));
assertEquals("Basic Authentication", signingInPage.getCategoryTitle("basic-authentication"));
assertEquals("Two-Factor Authentication", signingInPage.getCategoryTitle("two-factor"));
assertEquals("Passwordless", signingInPage.getCategoryTitle("passwordless"));
@ -180,8 +181,35 @@ public class SigningInTest extends BaseAccountPageTest {
assertUserCredential(PASSWORD_LABEL, false, passwordCred);
assertNotEquals(previousCreatedAt, passwordCred.getCreatedAt());
}
// TODO KEYCLOAK-12875 try to update/set up password when user has no password configured
@Test
public void updatePasswordTestForUserWithoutPassword() {
// Remove password from the user through admin REST API
String passwordId = testUserResource().credentials().get(0).getId();
testUserResource().removeCredential(passwordId);
// Refresh the page
refreshPageAndWaitForLoad();
// Test user doesn't have password set
assertTrue(passwordCredentialType.isSetUpLinkVisible());
assertFalse(passwordCredentialType.isSetUp());
// Set password
passwordCredentialType.clickSetUpLink();
updatePasswordPage.assertCurrent();
String originalPassword = Users.getPasswordOf(testUser);
updatePasswordPage.updatePasswords(originalPassword, originalPassword);
// TODO uncomment this once KEYCLOAK-12852 is resolved
// signingInPage.assertCurrent();
// Credential set-up now
assertFalse(passwordCredentialType.isSetUpLinkVisible());
assertTrue(passwordCredentialType.isSetUp());
SigningInPage.UserCredential passwordCred =
passwordCredentialType.getUserCredential(testUserResource().credentials().get(0).getId());
assertUserCredential(PASSWORD_LABEL, false, passwordCred);
}
@Test

View file

@ -342,6 +342,7 @@ saml.post-form.js-disabled=JavaScript is disabled. We strongly recommend to enab
#authenticators
otp-display-name=Authenticator Application
otp-help-text=Enter a verification code from authenticator application.
password-display-name=Password
password-help-text=Log in by entering your password.
auth-username-form-display-name=Username
auth-username-form-help-text=Start log in by entering your username

View file

@ -68,6 +68,9 @@ notSetUp={0} is not set up.
two-factor=Two-Factor Authentication
passwordless=Passwordless
unknown=Unknown
password-display-name=Password
password-help-text=Log in by entering your password.
password=My Password
otp-display-name=Authenticator Application
otp-help-text=Enter a verification code from authenticator application.
webauthn-display-name=Security Key
@ -75,7 +78,6 @@ webauthn-help-text=Use your security key to log in.
webauthn-passwordless-display-name=Security Key
webauthn-passwordless-help-text=Use your security key for passwordless log in.
basic-authentication=Basic Authentication
basic-auth-help-text=Sign in with username and password.
# Applications page
applicationsPageTitle=Applications