mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-26 13:50:48 +00:00
Generalize user search prefix lookups via UserSearchPrefix enum (#26602)
- Introduce UserSearchPrefix enum in SearchQueryUtils pairing each prefix (id:, username:, email:) with its UserProvider lookup, plus a splitTerms helper backed by a precompiled "\\s+" pattern - Collapse duplicate prefix branches in UsersResource#getUsers, UsersResource#getUsersCount and BruteForceUsersResource#searchUser - BruteForceUsersResource: support multi-term lookups (e.g. "username:foo bar"), aligning with UsersResource - Tests: add searchByUsernameSearch / searchByEmailSearch covering single-term, multi-term and whitespace-tolerant variants - Docs: add "Search by fields" section to proc-searching-user.adoc Fixes #26602 Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
This commit is contained in:
committed by
Pedro Igor
parent
2e7a63bc68
commit
8b357d610a
@@ -22,6 +22,16 @@ The criteria used to match users depends on the syntax used on the search box:
|
||||
.. `\*somevalue*` -> performs infix search, akin to a `LIKE '%somevalue%'` DB query;
|
||||
.. `somevalue*` or `somevalue` -> performs prefix search, akin to a `LIKE 'somevalue%'` DB query.
|
||||
|
||||
== Search by fields
|
||||
|
||||
{project_name} supports direct user lookups for selected fields by prefixing the search term in the search box:
|
||||
|
||||
* `id:myUUID` -> performs an exact user lookup by id for `"myUUID"`;
|
||||
* `username:myuser` -> performs an exact user lookup by username for `"myuser"`;
|
||||
* `email:myuser@domain.org` -> performs an exact user lookup by email for `"myuser@domain.org"`.
|
||||
|
||||
Multiple values can be supplied separated by whitespace (for example `id:uuid1 uuid2`) to look up several users in a single request.
|
||||
|
||||
== Attribute search
|
||||
|
||||
.Procedure
|
||||
|
||||
+8
-7
@@ -1,8 +1,10 @@
|
||||
package org.keycloak.admin.ui.rest;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -39,7 +41,7 @@ import org.jboss.logging.Logger;
|
||||
|
||||
public class BruteForceUsersResource {
|
||||
private static final Logger logger = Logger.getLogger(BruteForceUsersResource.class);
|
||||
private static final String SEARCH_ID_PARAMETER = "id:";
|
||||
|
||||
private final KeycloakSession session;
|
||||
private final RealmModel realm;
|
||||
private final AdminPermissionEvaluator auth;
|
||||
@@ -90,12 +92,11 @@ public class BruteForceUsersResource {
|
||||
|
||||
Stream<UserModel> userModels = Stream.empty();
|
||||
if (search != null) {
|
||||
if (search.startsWith(SEARCH_ID_PARAMETER)) {
|
||||
UserModel userModel =
|
||||
session.users().getUserById(realm, search.substring(SEARCH_ID_PARAMETER.length()).trim());
|
||||
if (userModel != null) {
|
||||
userModels = Stream.of(userModel);
|
||||
}
|
||||
SearchQueryUtils.UserSearchPrefix prefix = SearchQueryUtils.UserSearchPrefix.matching(search);
|
||||
if (prefix != null) {
|
||||
userModels = Arrays.stream(prefix.splitTerms(search))
|
||||
.map(term -> prefix.lookup(session.users(), realm, term))
|
||||
.filter(Objects::nonNull);
|
||||
} else {
|
||||
Map<String, String> attributes = new HashMap<>();
|
||||
attributes.put(UserModel.SEARCH, search.trim());
|
||||
|
||||
@@ -100,7 +100,6 @@ import static org.keycloak.userprofile.UserProfileContext.USER_API;
|
||||
public class UsersResource {
|
||||
|
||||
private static final Logger logger = Logger.getLogger(UsersResource.class);
|
||||
private static final String SEARCH_ID_PARAMETER = "id:";
|
||||
|
||||
protected final RealmModel realm;
|
||||
|
||||
@@ -306,9 +305,11 @@ public class UsersResource {
|
||||
|
||||
Stream<UserModel> userModels = Stream.empty();
|
||||
if (search != null) {
|
||||
if (search.startsWith(SEARCH_ID_PARAMETER)) {
|
||||
String[] userIds = search.substring(SEARCH_ID_PARAMETER.length()).trim().split("\\s+");
|
||||
userModels = Arrays.stream(userIds).map(id -> session.users().getUserById(realm, id)).filter(Objects::nonNull);
|
||||
SearchQueryUtils.UserSearchPrefix prefix = SearchQueryUtils.UserSearchPrefix.matching(search);
|
||||
if (prefix != null) {
|
||||
userModels = Arrays.stream(prefix.splitTerms(search))
|
||||
.map(term -> prefix.lookup(session.users(), realm, term))
|
||||
.filter(Objects::nonNull);
|
||||
if (AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) {
|
||||
userModels = userModels.filter(userPermissionEvaluator::canView);
|
||||
}
|
||||
@@ -435,9 +436,13 @@ public class UsersResource {
|
||||
? Collections.emptyMap()
|
||||
: SearchQueryUtils.getFields(searchQuery);
|
||||
if (search != null) {
|
||||
if (search.startsWith(SEARCH_ID_PARAMETER)) {
|
||||
UserModel userModel = session.users().getUserById(realm, search.substring(SEARCH_ID_PARAMETER.length()).trim());
|
||||
return userModel != null && userPermissionEvaluator.canView(userModel) ? 1 : 0;
|
||||
SearchQueryUtils.UserSearchPrefix prefix = SearchQueryUtils.UserSearchPrefix.matching(search);
|
||||
if (prefix != null) {
|
||||
return (int) Arrays.stream(prefix.splitTerms(search))
|
||||
.map(term -> prefix.lookup(session.users(), realm, term))
|
||||
.filter(Objects::nonNull)
|
||||
.filter(userPermissionEvaluator::canView)
|
||||
.count();
|
||||
}
|
||||
|
||||
Map<String, String> parameters = new HashMap<>();
|
||||
|
||||
@@ -19,12 +19,65 @@ package org.keycloak.utils;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.UserProvider;
|
||||
|
||||
/**
|
||||
* @author Vaclav Muzikar <vmuzikar@redhat.com>
|
||||
*/
|
||||
public class SearchQueryUtils {
|
||||
|
||||
public static final String SEARCH_ID_PREFIX = "id:";
|
||||
|
||||
public static final String SEARCH_USERNAME_PREFIX = "username:";
|
||||
|
||||
public static final String SEARCH_EMAIL_PREFIX = "email:";
|
||||
|
||||
private static final Pattern WHITESPACE = Pattern.compile("\\s+");
|
||||
|
||||
public enum UserSearchPrefix {
|
||||
ID(SEARCH_ID_PREFIX, UserProvider::getUserById),
|
||||
USERNAME(SEARCH_USERNAME_PREFIX, UserProvider::getUserByUsername),
|
||||
EMAIL(SEARCH_EMAIL_PREFIX, UserProvider::getUserByEmail);
|
||||
|
||||
private final String prefix;
|
||||
private final UserLookup lookup;
|
||||
|
||||
UserSearchPrefix(String prefix, UserLookup lookup) {
|
||||
this.prefix = prefix;
|
||||
this.lookup = lookup;
|
||||
}
|
||||
|
||||
public String getPrefix() {
|
||||
return prefix;
|
||||
}
|
||||
|
||||
public UserModel lookup(UserProvider users, RealmModel realm, String term) {
|
||||
return lookup.apply(users, realm, term);
|
||||
}
|
||||
|
||||
public String[] splitTerms(String search) {
|
||||
return WHITESPACE.split(search.substring(prefix.length()).trim());
|
||||
}
|
||||
|
||||
public static UserSearchPrefix matching(String search) {
|
||||
for (UserSearchPrefix p : values()) {
|
||||
if (search.startsWith(p.prefix)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface UserLookup {
|
||||
UserModel apply(UserProvider users, RealmModel realm, String term);
|
||||
}
|
||||
}
|
||||
|
||||
public static Map<String, String> getFields(final String query) {
|
||||
Map<String, String> ret = new HashMap<>();
|
||||
char[] chars = query.trim().toCharArray();
|
||||
|
||||
@@ -520,6 +520,92 @@ public class UserSearchTest extends AbstractUserTest {
|
||||
assertThat(multipleUsers2.get(1).getId(), is(expectedUserId2));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DatabaseTest
|
||||
public void searchByUsernameSearch() {
|
||||
List<String> userIds = createUsers();
|
||||
String expectedUserId = userIds.get(0);
|
||||
UserRepresentation expectedUserRep = managedRealm.admin().users().get(expectedUserId).toRepresentation();
|
||||
String expectedUsername = expectedUserRep.getUsername();
|
||||
|
||||
List<UserRepresentation> users = managedRealm.admin().users().search("username:" + expectedUsername, null, null);
|
||||
|
||||
assertEquals(1, users.size());
|
||||
assertEquals(expectedUserId, users.get(0).getId());
|
||||
assertEquals(expectedUsername, users.get(0).getUsername());
|
||||
assertThat(managedRealm.admin().users().count("username:" + expectedUsername), is(1));
|
||||
|
||||
// ensure spaces are ignored
|
||||
users = managedRealm.admin().users().search("username: " + expectedUsername + " ", null, null);
|
||||
|
||||
assertEquals(1, users.size());
|
||||
assertEquals(expectedUserId, users.get(0).getId());
|
||||
assertEquals(expectedUsername, users.get(0).getUsername());
|
||||
assertThat(managedRealm.admin().users().count("username: " + expectedUsername + " "), is(1));
|
||||
|
||||
// Should allow searching for multiple users
|
||||
String expectedUserId2 = userIds.get(1);
|
||||
String expectedUsername2 = managedRealm.admin().users().get(expectedUserId2).toRepresentation().getUsername();
|
||||
List<UserRepresentation> multipleUsers = managedRealm.admin().users().search(String.format("username:%s %s", expectedUsername, expectedUsername2), 0, 10);
|
||||
assertThat(multipleUsers, hasSize(2));
|
||||
assertThat(multipleUsers.get(0).getId(), is(expectedUserId));
|
||||
assertThat(multipleUsers.get(1).getId(), is(expectedUserId2));
|
||||
assertThat(managedRealm.admin().users().count(String.format("username:%s %s", expectedUsername, expectedUsername2)), is(2));
|
||||
|
||||
// Should take arbitrary amount of spaces in between usernames
|
||||
List<UserRepresentation> multipleUsers2 = managedRealm.admin().users().search(String.format("username: %s %s ", expectedUsername, expectedUsername2), 0, 10);
|
||||
assertThat(multipleUsers2, hasSize(2));
|
||||
assertThat(multipleUsers2.get(0).getId(), is(expectedUserId));
|
||||
assertThat(multipleUsers2.get(1).getId(), is(expectedUserId2));
|
||||
assertThat(managedRealm.admin().users().count(String.format("username: %s %s ", expectedUsername, expectedUsername2)), is(2));
|
||||
|
||||
// Unknown username yields a count of zero
|
||||
assertThat(managedRealm.admin().users().count("username:does-not-exist"), is(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DatabaseTest
|
||||
public void searchByEmailSearch() {
|
||||
List<String> userIds = createUsers();
|
||||
String expectedUserId = userIds.get(0);
|
||||
UserRepresentation expectedUserRep = managedRealm.admin().users().get(expectedUserId).toRepresentation();
|
||||
String expectedEmail = expectedUserRep.getEmail();
|
||||
|
||||
List<UserRepresentation> users = managedRealm.admin().users().search("email:" + expectedEmail, null, null);
|
||||
|
||||
assertEquals(1, users.size());
|
||||
assertEquals(expectedUserId, users.get(0).getId());
|
||||
assertEquals(expectedEmail, users.get(0).getEmail());
|
||||
assertThat(managedRealm.admin().users().count("email:" + expectedEmail), is(1));
|
||||
|
||||
// ensure spaces are ignored
|
||||
users = managedRealm.admin().users().search("email: " + expectedEmail + " ", null, null);
|
||||
|
||||
assertEquals(1, users.size());
|
||||
assertEquals(expectedUserId, users.get(0).getId());
|
||||
assertEquals(expectedEmail, users.get(0).getEmail());
|
||||
assertThat(managedRealm.admin().users().count("email: " + expectedEmail + " "), is(1));
|
||||
|
||||
// Should allow searching for multiple users
|
||||
String expectedUserId2 = userIds.get(1);
|
||||
String expectedEmail2 = managedRealm.admin().users().get(expectedUserId2).toRepresentation().getEmail();
|
||||
List<UserRepresentation> multipleUsers = managedRealm.admin().users().search(String.format("email:%s %s", expectedEmail, expectedEmail2), 0, 10);
|
||||
assertThat(multipleUsers, hasSize(2));
|
||||
assertThat(multipleUsers.get(0).getId(), is(expectedUserId));
|
||||
assertThat(multipleUsers.get(1).getId(), is(expectedUserId2));
|
||||
assertThat(managedRealm.admin().users().count(String.format("email:%s %s", expectedEmail, expectedEmail2)), is(2));
|
||||
|
||||
// Should take arbitrary amount of spaces in between emails
|
||||
List<UserRepresentation> multipleUsers2 = managedRealm.admin().users().search(String.format("email: %s %s ", expectedEmail, expectedEmail2), 0, 10);
|
||||
assertThat(multipleUsers2, hasSize(2));
|
||||
assertThat(multipleUsers2.get(0).getId(), is(expectedUserId));
|
||||
assertThat(multipleUsers2.get(1).getId(), is(expectedUserId2));
|
||||
assertThat(managedRealm.admin().users().count(String.format("email: %s %s ", expectedEmail, expectedEmail2)), is(2));
|
||||
|
||||
// Unknown email yields a count of zero
|
||||
assertThat(managedRealm.admin().users().count("email:does-not-exist@nowhere.test"), is(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DatabaseTest
|
||||
public void infixSearch() {
|
||||
|
||||
Reference in New Issue
Block a user