8000 Add support for explicit user search by username and email in Admin UI by thomasdarimont · Pull Request #13185 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8000
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -39,7 +41,7 @@

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;
Expand Down Expand Up @@ -90,12 +92,11 @@ public final Stream<BruteUser> searchUser(@QueryParam("search") String search,

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());
Expand Down
8000
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@
public class UsersResource {

private static final Logger logger = Logger.getLogger(UsersResource.class);
private static final String SEARCH_ID_PARAMETER = "id:";

protected final RealmModel realm;

Expand Down Expand Up @@ -306,9 +305,11 @@ public Stream<UserRepresentation> getUsers(

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);
}
Expand Down Expand Up @@ -435,9 +436,13 @@ public Integer getUsersCount(
? 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<>();
Expand Down
53 changes: 53 additions & 0 deletions services/src/main/java/org/keycloak/utils/SearchQueryUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,65 @@

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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,92 @@ public void searchById() {
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() {
Expand Down
Loading
0