From 5b0986e490090433658e9a1e607c0e248f4cc756 Mon Sep 17 00:00:00 2001 From: Bart Monhemius Date: Thu, 29 Jul 2021 10:20:07 +0200 Subject: [PATCH] [KEYCLOAK-18891] Add support for searching users by custom user attributes Users can now be searched by custom attributes using 'q' in the query parameters. The implementation is roughly the same as search clients by custom attributes. --- .../admin/client/resource/UsersResource.java | 16 +++- .../keycloak/models/jpa/JpaUserProvider.java | 28 ++++-- .../models/map/user/MapUserProvider.java | 5 + .../storage/user/UserQueryProvider.java | 2 + .../resources/admin/UsersResource.java | 21 ++++- .../keycloak/testsuite/admin/UserTest.java | 91 +++++++++++++++---- 6 files changed, 133 insertions(+), 30 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 86dce41692..f4b662a4da 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 @@ -109,6 +109,20 @@ public interface UsersResource { @Produces(MediaType.APPLICATION_JSON) List search(@QueryParam("username") String username); + @GET + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + List searchByAttributes(@QueryParam("q") String searchQuery); + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + List searchByAttributes(@QueryParam("first") Integer firstResult, + @QueryParam("max") Integer maxResults, + @QueryParam("enabled") Boolean enabled, + @QueryParam("briefRepresentation") Boolean briefRepresentation, + @QueryParam("q") String searchQuery); + @GET @Produces(MediaType.APPLICATION_JSON) List search(@QueryParam("username") String username, @QueryParam("exact") Boolean exact); @@ -246,7 +260,7 @@ public interface UsersResource { @Path("{id}") @DELETE Response delete(@PathParam("id") String id); - + @Path("profile") UserProfileResource userProfile(); 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 1f90a7e26f..2e164222cb 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 @@ -39,6 +39,7 @@ import org.keycloak.models.UserModel; import org.keycloak.models.UserProvider; import org.keycloak.models.jpa.entities.CredentialEntity; import org.keycloak.models.jpa.entities.FederatedIdentityEntity; +import org.keycloak.models.jpa.entities.UserAttributeEntity; import org.keycloak.models.jpa.entities.UserConsentClientScopeEntity; import org.keycloak.models.jpa.entities.UserConsentEntity; import org.keycloak.models.jpa.entities.UserEntity; @@ -49,15 +50,16 @@ import org.keycloak.storage.UserStorageProvider; import org.keycloak.storage.client.ClientStorageProvider; import javax.persistence.EntityManager; +import javax.persistence.LockModeType; 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.JoinType; import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Root; import javax.persistence.criteria.Subquery; - import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; @@ -68,8 +70,6 @@ import java.util.Map; import java.util.Set; import java.util.stream.Stream; -import javax.persistence.LockModeType; - import static org.keycloak.models.jpa.PaginationUtils.paginateQuery; import static org.keycloak.utils.StreamsUtil.closing; @@ -768,7 +768,8 @@ public class JpaUserProvider implements UserProvider.Streams, UserCredentialStor CriteriaQuery queryBuilder = builder.createQuery(UserEntity.class); Root root = queryBuilder.from(UserEntity.class); - List predicates = new ArrayList(); + List predicates = new ArrayList<>(); + List attributePredicates = new ArrayList<>(); predicates.add(builder.equal(root.get("realmId"), realm.getId())); @@ -788,7 +789,7 @@ public class JpaUserProvider implements UserProvider.Streams, UserCredentialStor switch (key) { case UserModel.SEARCH: - List orPredicates = new ArrayList(); + List orPredicates = new ArrayList<>(); orPredicates .add(builder.like(builder.lower(root.get(USERNAME)), "%" + value.toLowerCase() + "%")); @@ -799,7 +800,7 @@ public class JpaUserProvider implements UserProvider.Streams, UserCredentialStor builder.coalesce(root.get(LAST_NAME), builder.literal("")))), "%" + value.toLowerCase() + "%")); - predicates.add(builder.or(orPredicates.toArray(new Predicate[orPredicates.size()]))); + predicates.add(builder.or(orPredicates.toArray(new Predicate[0]))); break; @@ -831,9 +832,24 @@ public class JpaUserProvider implements UserProvider.Streams, UserCredentialStor } predicates.add(builder.equal(federatedIdentitiesJoin.get("userId"), value)); break; + case UserModel.EXACT: + break; + // All unknown attributes will be assumed as custom attributes + default: + Join attributesJoin = root.join("attributes", JoinType.LEFT); + + attributePredicates.add(builder.and( + builder.equal(builder.lower(attributesJoin.get("name")), key.toLowerCase()), + builder.equal(builder.lower(attributesJoin.get("value")), value.toLowerCase()))); + + break; } } + if (!attributePredicates.isEmpty()) { + predicates.add(builder.and(attributePredicates.toArray(new Predicate[0]))); + } + Set userGroups = (Set) session.getAttribute(UserModel.GROUPS); if (userGroups != null) { diff --git a/model/map/src/main/java/org/keycloak/models/map/user/MapUserProvider.java b/model/map/src/main/java/org/keycloak/models/map/user/MapUserProvider.java index 363c03c9fd..1b8a22557e 100644 --- a/model/map/src/main/java/org/keycloak/models/map/user/MapUserProvider.java +++ b/model/map/src/main/java/org/keycloak/models/map/user/MapUserProvider.java @@ -637,6 +637,11 @@ public class MapUserProvider implements UserProvider.Streams, UserCredentialStor mcb = mcb.compare(SearchableFields.IDP_AND_USER, Operator.EQ, attributes.get(UserModel.IDP_ALIAS), value); break; } + case UserModel.EXACT: + break; + default: + mcb = mcb.compare(SearchableFields.ATTRIBUTE, Operator.EQ, key, value); + break; } } diff --git a/server-spi/src/main/java/org/keycloak/storage/user/UserQueryProvider.java b/server-spi/src/main/java/org/keycloak/storage/user/UserQueryProvider.java index 0920b51140..7ab5fcb03f 100644 --- a/server-spi/src/main/java/org/keycloak/storage/user/UserQueryProvider.java +++ b/server-spi/src/main/java/org/keycloak/storage/user/UserQueryProvider.java @@ -389,6 +389,8 @@ public interface UserQueryProvider { * the given userId (case sensitive string) * * + * Any other parameters will be treated as custom user attributes. + * * This method is used by the REST API when querying users. * * @param realm a reference to the realm. 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 06d2da903b..a05599a0cb 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 @@ -16,8 +16,6 @@ */ package org.keycloak.services.resources.admin; -import static org.keycloak.userprofile.UserProfileContext.USER_API; - import org.jboss.logging.Logger; import org.jboss.resteasy.annotations.cache.NoCache; import org.jboss.resteasy.spi.ResteasyProviderFactory; @@ -43,6 +41,7 @@ import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluato import org.keycloak.services.resources.admin.permissions.UserPermissionEvaluator; import org.keycloak.userprofile.UserProfile; import org.keycloak.userprofile.UserProfileProvider; +import org.keycloak.utils.SearchQueryUtils; import javax.ws.rs.Consumes; import javax.ws.rs.GET; @@ -56,11 +55,14 @@ import javax.ws.rs.core.Context; import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Set; import java.util.stream.Stream; +import static org.keycloak.userprofile.UserProfileContext.USER_API; + /** * Base resource for managing users * @@ -215,7 +217,7 @@ public class UsersResource { /** * Get users * - * Returns a stream of users, filtered according to query parameters + * Returns a stream of users, filtered according to query parameters. * * @param search A String contained in username, first or last name, or email * @param last A String contained in lastName, or the complete lastName, if param "exact" is true @@ -230,6 +232,7 @@ public class UsersResource { * @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 + * @param searchQuery A query to search for custom attributes, in the format 'key1:value2 key2:value2' * @return a non-null {@code Stream} of users */ @GET @@ -247,7 +250,8 @@ public class UsersResource { @QueryParam("max") Integer maxResults, @QueryParam("enabled") Boolean enabled, @QueryParam("briefRepresentation") Boolean briefRepresentation, - @QueryParam("exact") Boolean exact) { + @QueryParam("exact") Boolean exact, + @QueryParam("q") String searchQuery) { UserPermissionEvaluator userPermissionEvaluator = auth.users(); userPermissionEvaluator.requireQuery(); @@ -255,6 +259,10 @@ public class UsersResource { firstResult = firstResult != null ? firstResult : -1; maxResults = maxResults != null ? maxResults : Constants.DEFAULT_MAX_RESULTS; + Map searchAttributes = searchQuery == null + ? Collections.emptyMap() + : SearchQueryUtils.getFields(searchQuery); + Stream userModels = Stream.empty(); if (search != null) { if (search.startsWith(SEARCH_ID_PARAMETER)) { @@ -273,7 +281,7 @@ public class UsersResource { maxResults, false); } } else if (last != null || first != null || email != null || username != null || emailVerified != null - || idpAlias != null || idpUserId != null || enabled != null || exact != null) { + || idpAlias != null || idpUserId != null || enabled != null || exact != null || !searchAttributes.isEmpty()) { Map attributes = new HashMap<>(); if (last != null) { attributes.put(UserModel.LAST_NAME, last); @@ -302,6 +310,9 @@ public class UsersResource { if (exact != null) { attributes.put(UserModel.EXACT, exact.toString()); } + + attributes.putAll(searchAttributes); + return searchForUser(attributes, realm, userPermissionEvaluator, briefRepresentation, firstResult, maxResults, true); } else { 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 4fdcfa3530..6b9cc62e7e 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 @@ -61,6 +61,8 @@ import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.services.resources.RealmsResource; import org.keycloak.storage.UserStorageProvider; +import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; +import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer; import org.keycloak.testsuite.arquillian.annotation.DisableFeature; import org.keycloak.testsuite.federation.DummyUserFederationProviderFactory; import org.keycloak.testsuite.page.LoginPasswordUpdatePage; @@ -101,6 +103,7 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; @@ -116,9 +119,6 @@ import static org.junit.Assert.fail; import static org.keycloak.testsuite.Assert.assertNames; import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE; -import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; -import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer; - /** * @author Stian Thorgersen */ @@ -595,6 +595,12 @@ public class UserTest extends AbstractAdminTest { user.setFirstName("First" + i); user.setLastName("Last" + i); + HashMap> attributes = new HashMap<>(); + attributes.put("test", Collections.singletonList("test" + i)); + attributes.put("test" + i, Collections.singletonList("test" + i)); + attributes.put("attr", Collections.singletonList("common")); + user.setAttributes(attributes); + ids.add(createUser(user)); } @@ -623,15 +629,64 @@ public class UserTest extends AbstractAdminTest { assertEquals(9, users.size()); } + private String mapToSearchQuery(Map search) { + return search.entrySet() + .stream() + .map(e -> String.format("%s:%s", e.getKey(), e.getValue())) + .collect(Collectors.joining(" ")); + } + + @Test + public void searchByAttribute() { + createUsers(); + + Map attributes = new HashMap<>(); + attributes.put("test", "test1"); + List users = realm.users().searchByAttributes(mapToSearchQuery(attributes)); + assertEquals(1, users.size()); + + attributes.clear(); + attributes.put("attr", "common"); + + users = realm.users().searchByAttributes(mapToSearchQuery(attributes)); + assertEquals(9, users.size()); + } + + @Test + public void searchByMultipleAttributes() { + createUsers(); + + Map attributes = new HashMap<>(); + attributes.put("test", "test1"); + attributes.put("attr", "common"); + attributes.put("test1", "test1"); + + List users = realm.users().searchByAttributes(mapToSearchQuery(attributes)); + assertEquals(1, users.size()); + } + + @Test + public void searchByAttributesWithPagination() { + createUsers(); + + Map attributes = new HashMap<>(); + attributes.put("attr", "common"); + for (int i = 1; i < 10; i++) { + List users = realm.users().searchByAttributes(i - 1, 1, null, false, mapToSearchQuery(attributes)); + assertEquals(1, users.size()); + assertTrue(users.get(0).getAttributes().keySet().stream().anyMatch(attributes::containsKey)); + } + } + @Test public void searchByUsernameExactMatch() { createUsers(); UserRepresentation user = new UserRepresentation(); user.setUsername("username11"); - + createUser(user); - + List users = realm.users().search("username1", true); assertEquals(1, users.size()); @@ -2022,14 +2077,14 @@ public class UserTest extends AbstractAdminTest { realm.flows().updateRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD.toString(), updatePasswordReqAction); assertAdminEvents.assertEvent(realmId, OperationType.UPDATE, AdminEventPaths.authRequiredActionPath(UserModel.RequiredAction.UPDATE_PASSWORD.toString()), updatePasswordReqAction, ResourceType.REQUIRED_ACTION); } - + private RoleRepresentation getRoleByName(String name, List roles) { for(RoleRepresentation role : roles) { if(role.getName().equalsIgnoreCase(name)) { return role; } } - + return null; } @@ -2042,7 +2097,7 @@ public class UserTest extends AbstractAdminTest { realm.update(realmRep); RoleRepresentation realmCompositeRole = RoleBuilder.create().name("realm-composite").singleAttribute("attribute1", "value1").build(); - + realm.roles().create(RoleBuilder.create().name("realm-role").build()); realm.roles().create(realmCompositeRole); realm.roles().create(RoleBuilder.create().name("realm-child").build()); @@ -2054,8 +2109,8 @@ public class UserTest extends AbstractAdminTest { response.close(); RoleRepresentation clientCompositeRole = RoleBuilder.create().name("client-composite").singleAttribute("attribute1", "value1").build(); - - + + realm.clients().get(clientUuid).roles().create(RoleBuilder.create().name("client-role").build()); realm.clients().get(clientUuid).roles().create(RoleBuilder.create().name("client-role2").build()); realm.clients().get(clientUuid).roles().create(clientCompositeRole); @@ -2099,12 +2154,12 @@ public class UserTest extends AbstractAdminTest { RoleRepresentation realmCompositeRoleFromList = getRoleByName("realm-composite", realmRolesFullRepresentations); assertNotNull(realmCompositeRoleFromList); assertTrue(realmCompositeRoleFromList.getAttributes().containsKey("attribute1")); - + // List client roles assertNames(roles.clientLevel(clientUuid).listAll(), "client-role", "client-composite"); assertNames(roles.clientLevel(clientUuid).listAvailable(), "client-role2", "client-child"); assertNames(roles.clientLevel(clientUuid).listEffective(), "client-role", "client-composite", "client-child"); - + // List client effective role with full representation List rolesFullRepresentations = roles.clientLevel(clientUuid).listEffective(false); RoleRepresentation clientCompositeRoleFromList = getRoleByName("client-composite", rolesFullRepresentations); @@ -2455,11 +2510,11 @@ public class UserTest extends AbstractAdminTest { Assert.assertTrue(ObjectUtil.isEqualOrBothNull(otpCredential.getUserLabel(), otpCredentialLoaded.getUserLabel())); Assert.assertTrue(ObjectUtil.isEqualOrBothNull(otpCredential.getPriority(), otpCredentialLoaded.getPriority())); } - + @Test public void testGetGroupsForUserFullRepresentation() { RealmResource realm = adminClient.realms().realm("test"); - + String userName = "averagejoe"; String groupName = "groupWithAttribute"; Map> attributes = new HashMap>(); @@ -2469,16 +2524,16 @@ public class UserTest extends AbstractAdminTest { .edit(createUserRepresentation(userName, "joe@average.com", "average", "joe", true)) .addPassword("password") .build(); - + try (Creator u = Creator.create(realm, userRepresentation); Creator g = Creator.create(realm, GroupBuilder.create().name(groupName).attributes(attributes).build())) { - + String groupId = g.id(); UserResource user = u.resource(); user.joinGroup(groupId); - + List userGroups = user.groups(0, 100, false); - + assertFalse(userGroups.isEmpty()); assertTrue(userGroups.get(0).getAttributes().containsKey("attribute1")); }