From de8d2eafa3321d325dc92d762d05df500ac6f79f Mon Sep 17 00:00:00 2001 From: Daniel Fesenmeyer Date: Thu, 30 Jul 2020 03:30:40 -0700 Subject: [PATCH] KEYCLOAK-14781 Extend Admin REST API with search by federated identity - Add parameters idpAlias and idpUserId to the resource /{realm}/users and allow it to be combined with the other search parameters like username, email and so on - Add attribute "federatedIdentities" to UserEntity to allow joining on this field - extend integration test "UserTest" --- .../admin/client/resource/UsersResource.java | 32 +++++ .../keycloak/models/jpa/JpaUserProvider.java | 21 ++- .../models/jpa/entities/UserEntity.java | 16 +++ .../java/org/keycloak/models/UserModel.java | 2 + .../resources/admin/UsersResource.java | 90 +++++++----- .../keycloak/testsuite/admin/UserTest.java | 136 +++++++++++++++++- 6 files changed, 254 insertions(+), 43 deletions(-) diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UsersResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UsersResource.java index ebd211413d..5d117a313a 100755 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UsersResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UsersResource.java @@ -53,6 +53,38 @@ public interface UsersResource { @QueryParam("enabled") Boolean enabled, @QueryParam("briefRepresentation") Boolean briefRepresentation); + /** + * Search for users based on the given filters. + * + * @param username a value contained in username + * @param firstName a value contained in first name + * @param lastName a value contained in last name + * @param email a value contained in email + * @param emailVerified whether the email has been verified + * @param idpAlias the alias of the Identity Provider + * @param idpUserId the userId at the Identity Provider + * @param firstResult the position of the first result to retrieve + * @param maxResults the maximum number of results to retrieve + * @param enabled only return enabled or disabled users + * @param briefRepresentation Only return basic information (only guaranteed to return id, username, created, first + * and last name, email, enabled state, email verification state, federation link, and access. + * Note that it means that namely user attributes, required actions, and not before are not returned.) + * @return a list of {@link UserRepresentation} + */ + @GET + @Produces(MediaType.APPLICATION_JSON) + List search(@QueryParam("username") String username, + @QueryParam("firstName") String firstName, + @QueryParam("lastName") String lastName, + @QueryParam("email") String email, + @QueryParam("emailVerified") Boolean emailVerified, + @QueryParam("idpAlias") String idpAlias, + @QueryParam("idpUserId") String idpUserId, + @QueryParam("first") Integer firstResult, + @QueryParam("max") Integer maxResults, + @QueryParam("enabled") Boolean enabled, + @QueryParam("briefRepresentation") Boolean briefRepresentation); + @GET @Produces(MediaType.APPLICATION_JSON) List search(@QueryParam("username") String username, diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java index 7d2aae036c..656c3ec9b9 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java @@ -54,6 +54,7 @@ import javax.persistence.TypedQuery; import javax.persistence.criteria.CriteriaBuilder; import javax.persistence.criteria.CriteriaQuery; import javax.persistence.criteria.Expression; +import javax.persistence.criteria.Join; import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Root; import javax.persistence.criteria.Subquery; @@ -840,6 +841,8 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore { predicates.add(root.get("serviceAccountClientLink").isNull()); } + Join federatedIdentitiesJoin = null; + for (Map.Entry entry : attributes.entrySet()) { String key = entry.getKey(); String value = entry.getValue(); @@ -852,7 +855,8 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore { case UserModel.SEARCH: List orPredicates = new ArrayList(); - orPredicates.add(builder.like(builder.lower(root.get(USERNAME)), "%" + value.toLowerCase() + "%")); + orPredicates + .add(builder.like(builder.lower(root.get(USERNAME)), "%" + value.toLowerCase() + "%")); orPredicates.add(builder.like(builder.lower(root.get(EMAIL)), "%" + value.toLowerCase() + "%")); orPredicates.add(builder.like( builder.lower(builder.concat(builder.concat( @@ -878,7 +882,20 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore { predicates.add(builder.equal(root.get(key), Boolean.parseBoolean(value.toLowerCase()))); break; case UserModel.ENABLED: - predicates.add(builder.equal(builder.lower(root.get(key)), Boolean.parseBoolean(value.toLowerCase()))); + predicates.add(builder.equal(root.get(key), Boolean.parseBoolean(value))); + break; + case UserModel.IDP_ALIAS: + if (federatedIdentitiesJoin == null) { + federatedIdentitiesJoin = root.join("federatedIdentities"); + } + predicates.add(builder.equal(federatedIdentitiesJoin.get("identityProvider"), value)); + break; + case UserModel.IDP_USER_ID: + if (federatedIdentitiesJoin == null) { + federatedIdentitiesJoin = root.join("federatedIdentities"); + } + predicates.add(builder.equal(federatedIdentitiesJoin.get("userId"), value)); + break; } } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserEntity.java index 9e109aa931..f41b46b29d 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserEntity.java @@ -110,6 +110,11 @@ public class UserEntity { @BatchSize(size = 20) protected Collection credentials; + @OneToMany(mappedBy="user") + @Fetch(FetchMode.SELECT) + @BatchSize(size = 20) + protected Collection federatedIdentities; + @Column(name="FEDERATION_LINK") protected String federationLink; @@ -233,6 +238,17 @@ public class UserEntity { this.credentials = credentials; } + public Collection getFederatedIdentities() { + if (federatedIdentities == null) { + federatedIdentities = new LinkedList<>(); + } + return federatedIdentities; + } + + public void setFederatedIdentities(Collection federatedIdentities) { + this.federatedIdentities = federatedIdentities; + } + public String getFederationLink() { return federationLink; } diff --git a/server-spi/src/main/java/org/keycloak/models/UserModel.java b/server-spi/src/main/java/org/keycloak/models/UserModel.java index 0782a88a7d..f2e3281084 100755 --- a/server-spi/src/main/java/org/keycloak/models/UserModel.java +++ b/server-spi/src/main/java/org/keycloak/models/UserModel.java @@ -38,6 +38,8 @@ public interface UserModel extends RoleMapperModel { String EMAIL_VERIFIED = "emailVerified"; String LOCALE = "locale"; String ENABLED = "enabled"; + String IDP_ALIAS = "keycloak.session.realm.users.query.idp_alias"; + String IDP_USER_ID = "keycloak.session.realm.users.query.idp_user_id"; String INCLUDE_SERVICE_ACCOUNT = "keycloak.session.realm.users.query.include_service_account"; String GROUPS = "keycloak.session.realm.users.query.groups"; String SEARCH = "keycloak.session.realm.users.query.search"; diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java index 7a60b4025e..5d0e15755c 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java @@ -208,14 +208,19 @@ public class UsersResource { * Returns a list of users, filtered according to query parameters * * @param search A String contained in username, first or last name, or email - * @param last - * @param first - * @param email - * @param username - * @param enabled Boolean representing if user is enabled or not - * @param first Pagination offset + * @param last A String contained in lastName, or the complete lastName, if param "exact" is true + * @param first A String contained in firstName, or the complete firstName, if param "exact" is true + * @param email A String contained in email, or the complete email, if param "exact" is true + * @param username A String contained in username, or the complete username, if param "exact" is true + * @param emailVerified whether the email has been verified + * @param idpAlias The alias of an Identity Provider linked to the user + * @param idpUserId The userId at an Identity Provider linked to the user + * @param firstResult Pagination offset * @param maxResults Maximum results size (defaults to 100) - * @return + * @param enabled Boolean representing if user is enabled or not + * @param briefRepresentation Boolean which defines whether brief representations are returned (default: false) + * @param exact Boolean which defines whether the params "last", "first", "email" and "username" must match exactly + * @return the list of users */ @GET @NoCache @@ -226,6 +231,8 @@ public class UsersResource { @QueryParam("email") String email, @QueryParam("username") String username, @QueryParam("emailVerified") Boolean emailVerified, + @QueryParam("idpAlias") String idpAlias, + @QueryParam("idpUserId") String idpUserId, @QueryParam("first") Integer firstResult, @QueryParam("max") Integer maxResults, @QueryParam("enabled") Boolean enabled, @@ -241,7 +248,8 @@ public class UsersResource { List userModels = Collections.emptyList(); if (search != null) { if (search.startsWith(SEARCH_ID_PARAMETER)) { - UserModel userModel = session.users().getUserById(search.substring(SEARCH_ID_PARAMETER.length()).trim(), realm); + UserModel userModel = + session.users().getUserById(search.substring(SEARCH_ID_PARAMETER.length()).trim(), realm); if (userModel != null) { userModels = Collections.singletonList(userModel); } @@ -251,35 +259,45 @@ public class UsersResource { if (enabled != null) { attributes.put(UserModel.ENABLED, enabled.toString()); } - return searchForUser(attributes, realm, userPermissionEvaluator, briefRepresentation, firstResult, maxResults, false); + return searchForUser(attributes, realm, userPermissionEvaluator, briefRepresentation, firstResult, + maxResults, false); } - } else if (last != null || first != null || email != null || username != null || emailVerified != null || enabled != null || exact != null) { - Map attributes = new HashMap<>(); - if (last != null) { - attributes.put(UserModel.LAST_NAME, last); - } - if (first != null) { - attributes.put(UserModel.FIRST_NAME, first); - } - if (email != null) { - attributes.put(UserModel.EMAIL, email); - } - if (username != null) { - attributes.put(UserModel.USERNAME, username); - } - if (enabled != null) { - attributes.put(UserModel.ENABLED, enabled.toString()); - } - if (exact != null) { - attributes.put(UserModel.EXACT, exact.toString()); - } - if (emailVerified != null) { - attributes.put(UserModel.EMAIL_VERIFIED, emailVerified.toString()); - } - return searchForUser(attributes, realm, userPermissionEvaluator, briefRepresentation, firstResult, maxResults, true); - } else { - return searchForUser(new HashMap<>(), realm, userPermissionEvaluator, briefRepresentation, firstResult, maxResults, false); - } + } else if (last != null || first != null || email != null || username != null || emailVerified != null + || idpAlias != null || idpUserId != null || enabled != null || exact != null) { + Map attributes = new HashMap<>(); + if (last != null) { + attributes.put(UserModel.LAST_NAME, last); + } + if (first != null) { + attributes.put(UserModel.FIRST_NAME, first); + } + if (email != null) { + attributes.put(UserModel.EMAIL, email); + } + if (username != null) { + attributes.put(UserModel.USERNAME, username); + } + if (emailVerified != null) { + attributes.put(UserModel.EMAIL_VERIFIED, emailVerified.toString()); + } + if (idpAlias != null) { + attributes.put(UserModel.IDP_ALIAS, idpAlias); + } + if (idpUserId != null) { + attributes.put(UserModel.IDP_USER_ID, idpUserId); + } + if (enabled != null) { + attributes.put(UserModel.ENABLED, enabled.toString()); + } + if (exact != null) { + attributes.put(UserModel.EXACT, exact.toString()); + } + return searchForUser(attributes, realm, userPermissionEvaluator, briefRepresentation, firstResult, + maxResults, true); + } else { + return searchForUser(new HashMap<>(), realm, userPermissionEvaluator, briefRepresentation, + firstResult, maxResults, false); + } return toRepresentation(realm, userPermissionEvaluator, briefRepresentation, userModels); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java index 7006383bd8..1faa763801 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java @@ -718,6 +718,130 @@ public class UserTest extends AbstractAdminTest { assertEquals(0, searchInvalidSizeAndDisabled.size()); } + @Test + public void searchByIdp() { + // Add user without IDP + createUser(); + + // add sample Identity Providers + final String identityProviderAlias1 = "identity-provider-alias1"; + addSampleIdentityProvider(identityProviderAlias1, 0); + final String identityProviderAlias2 = "identity-provider-alias2"; + addSampleIdentityProvider(identityProviderAlias2, 1); + + final String commonIdpUserId = "commonIdpUserId"; + + // create first IDP1 User with link + final String idp1User1Username = "idp1user1"; + final String idp1User1KeycloakId = createUser(idp1User1Username, "idp1user1@localhost"); + final String idp1User1UserId = "idp1user1Id"; + FederatedIdentityRepresentation link1_1 = new FederatedIdentityRepresentation(); + link1_1.setUserId(idp1User1UserId); + link1_1.setUserName(idp1User1Username); + addFederatedIdentity(idp1User1KeycloakId, identityProviderAlias1, link1_1); + + // create second IDP1 User with link + final String idp1User2Username = "idp1user2"; + final String idp1User2KeycloakId = createUser(idp1User2Username, "idp1user2@localhost"); + FederatedIdentityRepresentation link1_2 = new FederatedIdentityRepresentation(); + link1_2.setUserId(commonIdpUserId); + link1_2.setUserName(idp1User2Username); + addFederatedIdentity(idp1User2KeycloakId, identityProviderAlias1, link1_2); + + // create IDP2 user with link + final String idp2UserUsername = "idp2user"; + final String idp2UserKeycloakId = createUser(idp2UserUsername, "idp2user@localhost"); + FederatedIdentityRepresentation link2 = new FederatedIdentityRepresentation(); + link2.setUserId(commonIdpUserId); + link2.setUserName(idp2UserUsername); + addFederatedIdentity(idp2UserKeycloakId, identityProviderAlias2, link2); + + // run search tests + List searchForAllUsers = + realm.users().search(null, null, null, null, null, null, null, null, null, null, null); + assertEquals(4, searchForAllUsers.size()); + + List searchByIdpAlias = + realm.users().search(null, null, null, null, null, identityProviderAlias1, null, null, null, null, + null); + assertEquals(2, searchByIdpAlias.size()); + assertEquals(idp1User1Username, searchByIdpAlias.get(0).getUsername()); + assertEquals(idp1User2Username, searchByIdpAlias.get(1).getUsername()); + + List searchByIdpUserId = + realm.users().search(null, null, null, null, null, null, commonIdpUserId, null, null, null, null); + assertEquals(2, searchByIdpUserId.size()); + assertEquals(idp1User2Username, searchByIdpUserId.get(0).getUsername()); + assertEquals(idp2UserUsername, searchByIdpUserId.get(1).getUsername()); + + List searchByIdpAliasAndUserId = + realm.users().search(null, null, null, null, null, identityProviderAlias1, idp1User1UserId, null, null, + null, + null); + assertEquals(1, searchByIdpAliasAndUserId.size()); + assertEquals(idp1User1Username, searchByIdpAliasAndUserId.get(0).getUsername()); + } + + private void addFederatedIdentity(String keycloakUserId, String identityProviderAlias1, + FederatedIdentityRepresentation link) { + Response response1 = realm.users().get(keycloakUserId).addFederatedIdentity(identityProviderAlias1, link); + assertAdminEvents.assertEvent(realmId, OperationType.CREATE, + AdminEventPaths.userFederatedIdentityLink(keycloakUserId, identityProviderAlias1), link, + ResourceType.USER); + assertEquals(204, response1.getStatus()); + } + + @Test + public void searchByIdpAndEnabled() { + // add sample Identity Provider + final String identityProviderAlias = "identity-provider-alias"; + addSampleIdentityProvider(identityProviderAlias, 0); + + // add disabled user with IDP link + UserRepresentation disabledUser = new UserRepresentation(); + final String disabledUsername = "disabled_username"; + disabledUser.setUsername(disabledUsername); + disabledUser.setEmail("disabled@localhost"); + disabledUser.setEnabled(false); + final String disabledUserKeycloakId = createUser(disabledUser); + FederatedIdentityRepresentation disabledUserLink = new FederatedIdentityRepresentation(); + final String disabledUserId = "disabledUserId"; + disabledUserLink.setUserId(disabledUserId); + disabledUserLink.setUserName(disabledUsername); + addFederatedIdentity(disabledUserKeycloakId, identityProviderAlias, disabledUserLink); + + // add enabled user with IDP link + UserRepresentation enabledUser = new UserRepresentation(); + final String enabledUsername = "enabled_username"; + enabledUser.setUsername(enabledUsername); + enabledUser.setEmail("enabled@localhost"); + enabledUser.setEnabled(true); + final String enabledUserKeycloakId = createUser(enabledUser); + FederatedIdentityRepresentation enabledUserLink = new FederatedIdentityRepresentation(); + final String enabledUserId = "enabledUserId"; + enabledUserLink.setUserId(enabledUserId); + enabledUserLink.setUserName(enabledUsername); + addFederatedIdentity(enabledUserKeycloakId, identityProviderAlias, enabledUserLink); + + // run search tests + List searchByIdpAliasAndEnabled = + realm.users().search(null, null, null, null, null, identityProviderAlias, null, null, null, true, null); + assertEquals(1, searchByIdpAliasAndEnabled.size()); + assertEquals(enabledUsername, searchByIdpAliasAndEnabled.get(0).getUsername()); + + List searchByIdpAliasAndDisabled = + realm.users().search(null, null, null, null, null, identityProviderAlias, null, null, null, false, + null); + assertEquals(1, searchByIdpAliasAndDisabled.size()); + assertEquals(disabledUsername, searchByIdpAliasAndDisabled.get(0).getUsername()); + + List searchByIdpAliasWithoutEnabledFlag = + realm.users().search(null, null, null, null, null, identityProviderAlias, null, null, null, null, null); + assertEquals(2, searchByIdpAliasWithoutEnabledFlag.size()); + assertEquals(disabledUsername, searchByIdpAliasWithoutEnabledFlag.get(0).getUsername()); + assertEquals(enabledUsername, searchByIdpAliasWithoutEnabledFlag.get(1).getUsername()); + } + @Test public void searchById() { String expectedUserId = createUsers().get(0); @@ -829,9 +953,7 @@ public class UserTest extends AbstractAdminTest { FederatedIdentityRepresentation link = new FederatedIdentityRepresentation(); link.setUserId("social-user-id"); link.setUserName("social-username"); - Response response = user.addFederatedIdentity("social-provider-id", link); - assertEquals(204, response.getStatus()); - assertAdminEvents.assertEvent(realmId, OperationType.CREATE, AdminEventPaths.userFederatedIdentityLink(id, "social-provider-id"), link, ResourceType.USER); + addFederatedIdentity(id, "social-provider-id", link); // Verify social link is here user = realm.users().get(id); @@ -851,11 +973,15 @@ public class UserTest extends AbstractAdminTest { } private void addSampleIdentityProvider() { + addSampleIdentityProvider("social-provider-id", 0); + } + + private void addSampleIdentityProvider(final String alias, final int expectedInitialIdpCount) { List providers = realm.identityProviders().findAll(); - Assert.assertEquals(0, providers.size()); + Assert.assertEquals(expectedInitialIdpCount, providers.size()); IdentityProviderRepresentation rep = new IdentityProviderRepresentation(); - rep.setAlias("social-provider-id"); + rep.setAlias(alias); rep.setProviderId("oidc"); realm.identityProviders().create(rep);