Added emailVerified filtering for users endpoint; updated user count endpoint with logic to support enabled, emailVerified, idpAlias, idpUserId, and exact field query parameters

Closes #38556
Closes #29295

Signed-off-by: Barathwaja S <sbarathwaj4@gmail.com>
This commit is contained in:
Barathwaja S 2025-06-05 00:49:57 -05:00 committed by Pedro Igor
parent 89af7fe56d
commit 81a7f38a76
3 changed files with 230 additions and 58 deletions

View File

@ -250,6 +250,29 @@ public interface UsersResource {
@QueryParam("first") Integer firstResult,
@QueryParam("max") Integer maxResults);
/**
* Returns the users that can be viewed and match the given filters.
*
* @param search arbitrary search string for all the fields below
* @param last last name field of a user
* @param first first name field of a user
* @param email email field of a user
* @param emailVerified emailVerified field of a user
* @param username username field of a user
* @param enabled Boolean representing if user is enabled or not
* @return the list of users matching the given filters
*/
@GET
@Produces(MediaType.APPLICATION_JSON)
List<UserRepresentation> search(@QueryParam("search") String search,
@QueryParam("lastName") String last,
@QueryParam("firstName") String first,
@QueryParam("email") String email,
@QueryParam("emailVerified") Boolean emailVerified,
@QueryParam("username") String username,
@QueryParam("enabled") Boolean enabled,
@QueryParam("q") String searchQuery);
@GET
@Produces(MediaType.APPLICATION_JSON)
List<UserRepresentation> list(@QueryParam("first") Integer firstResult,
@ -348,6 +371,33 @@ public interface UsersResource {
@QueryParam("enabled") Boolean enabled,
@QueryParam("q") String searchQuery);
/**
* Returns the number of users that can be viewed and match the given filters.
* If none of the filters is specified this is equivalent to {{@link #count()}}.
*
* @param search arbitrary search string for all the fields below
* @param last last name field of a user
* @param first first name field of a user
* @param email email field of a user
* @param emailVerified emailVerified field of a user
* @param username username field of a user
* @param enabled Boolean representing if user is enabled or not
* @return number of users matching the given filters
*/
@Path("count")
@GET
@Produces(MediaType.APPLICATION_JSON)
Integer count(@QueryParam("search") String search,
@QueryParam("lastName") String last,
@QueryParam("firstName") String first,
@QueryParam("email") String email,
@QueryParam("emailVerified") Boolean emailVerified,
@QueryParam("username") String username,
@QueryParam("enabled") Boolean enabled,
@QueryParam("idpAlias") String idpAlias,
@QueryParam("idpUserId") String idpUserId,
@QueryParam("q") String searchQuery);
/**
* Returns the number of users with the given status for emailVerified.
* If none of the filters is specified this is equivalent to {{@link #count()}}.

View File

@ -312,6 +312,10 @@ public class UsersResource {
if (enabled != null) {
attributes.put(UserModel.ENABLED, enabled.toString());
}
if (emailVerified != null) {
attributes.put(UserModel.EMAIL_VERIFIED, emailVerified.toString());
}
return searchForUser(attributes, realm, userPermissionEvaluator, briefRepresentation, firstResult,
maxResults, false);
}
@ -373,12 +377,17 @@ public class UsersResource {
* {@code email} or {@code username} those criteria are matched against their
* respective fields on a user entity. Combined with a logical and.
*
* @param search arbitrary search string for all the fields below. Default search behavior is prefix-based (e.g., <code>foo</code> or <code>foo*</code>). Use <code>*foo*</code> for infix search and <code>"foo"</code> for exact search.
* @param last last name filter
* @param first first name filter
* @param email email filter
* @param username username filter
* @param search A String contained in username, first or last name, or email. Default search behavior is prefix-based (e.g., <code>foo</code> or <code>foo*</code>). Use <code>*foo*</code> for infix search and <code>"foo"</code> for exact search.
* @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 enabled Boolean representing if user is enabled or not
* @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 the number of users that match the given criteria
*/
@Path("count")
@ -397,32 +406,46 @@ public class UsersResource {
"2. If {@code search} is specified other criteria such as {@code last} will be ignored even though you set them. The {@code search} string will be matched against the first and last name, the username and the email of a user. <p> " +
"3. If {@code search} is unspecified but any of {@code last}, {@code first}, {@code email} or {@code username} those criteria are matched against their respective fields on a user entity. Combined with a logical and.")
public Integer getUsersCount(
@Parameter(description = "arbitrary search string for all the fields below. Default search behavior is prefix-based (e.g., foo or foo*). Use *foo* for infix search and \"foo\" for exact search.") @QueryParam("search") String search,
@Parameter(description = "last name filter") @QueryParam("lastName") String last,
@Parameter(description = "first name filter") @QueryParam("firstName") String first,
@Parameter(description = "email filter") @QueryParam("email") String email,
@QueryParam("emailVerified") Boolean emailVerified,
@Parameter(description = "username filter") @QueryParam("username") String username,
@Parameter(description = "A String contained in username, first or last name, or email. Default search behavior is prefix-based (e.g., foo or foo*). Use *foo* for infix search and \"foo\" for exact search.") @QueryParam("search") String search,
@Parameter(description = "A String contained in lastName, or the complete lastName, if param \"exact\" is true") @QueryParam("lastName") String last,
@Parameter(description = "A String contained in firstName, or the complete firstName, if param \"exact\" is true") @QueryParam("firstName") String first,
@Parameter(description = "A String contained in email, or the complete email, if param \"exact\" is true") @QueryParam("email") String email,
@Parameter(description = "A String contained in username, or the complete username, if param \"exact\" is true") @QueryParam("username") String username,
@Parameter(description = "whether the email has been verified") @QueryParam("emailVerified") Boolean emailVerified,
@Parameter(description = "The alias of an Identity Provider linked to the user") @QueryParam("idpAlias") String idpAlias,
@Parameter(description = "The userId at an Identity Provider linked to the user") @QueryParam("idpUserId") String idpUserId,
@Parameter(description = "Boolean representing if user is enabled or not") @QueryParam("enabled") Boolean enabled,
@QueryParam("q") String searchQuery) {
@Parameter(description = "Boolean which defines whether the params \"last\", \"first\", \"email\" and \"username\" must match exactly") @QueryParam("exact") Boolean exact,
@Parameter(description = "A query to search for custom attributes, in the format 'key1:value2 key2:value2'") @QueryParam("q") String searchQuery) {
UserPermissionEvaluator userPermissionEvaluator = auth.users();
userPermissionEvaluator.requireQuery();
Map<String, String> searchAttributes = searchQuery == null
? 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;
} else if (userPermissionEvaluator.canView()) {
return session.users().getUsersCount(realm, search.trim());
}
Map<String, String> parameters = new HashMap<>();
parameters.put(UserModel.SEARCH, search.trim());
if (enabled != null) {
parameters.put(UserModel.ENABLED, enabled.toString());
}
if (emailVerified != null) {
parameters.put(UserModel.EMAIL_VERIFIED, emailVerified.toString());
}
if (userPermissionEvaluator.canView()) {
return session.users().getUsersCount(realm, parameters);
} else {
if (AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) {
return session.users().getUsersCount(realm, search.trim());
return session.users().getUsersCount(realm, parameters);
} else {
return session.users().getUsersCount(realm, search.trim(), auth.groups().getGroupIdsWithViewPermission());
return session.users().getUsersCount(realm, parameters, auth.groups().getGroupIdsWithViewPermission());
}
}
} else if (last != null || first != null || email != null || username != null || emailVerified != null || enabled != null || !searchAttributes.isEmpty()) {
@ -442,9 +465,18 @@ public class UsersResource {
if (emailVerified != null) {
parameters.put(UserModel.EMAIL_VERIFIED, emailVerified.toString());
}
if (idpAlias != null) {
parameters.put(UserModel.IDP_ALIAS, idpAlias);
}
if (idpUserId != null) {
parameters.put(UserModel.IDP_USER_ID, idpUserId);
}
if (enabled != null) {
parameters.put(UserModel.ENABLED, enabled.toString());
}
if (exact != null) {
parameters.put(UserModel.EXACT, exact.toString());
}
parameters.putAll(searchAttributes);
if (userPermissionEvaluator.canView()) {

View File

@ -19,12 +19,17 @@ package org.keycloak.tests.admin;
import org.junit.jupiter.api.Test;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserProvider;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.injection.LifeCycle;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.UserConfigBuilder;
import org.keycloak.testframework.remote.runonserver.InjectRunOnServer;
import org.keycloak.testframework.remote.runonserver.RunOnServerClient;
import java.util.List;
@ -37,23 +42,17 @@ import static org.hamcrest.Matchers.hasSize;
@KeycloakIntegrationTest
public class UsersTest {
private static final String realmName = "default";
@InjectRealm(lifecycle = LifeCycle.METHOD)
ManagedRealm realm;
private void createUser(String username, String password, String firstName, String lastName, String email) {
UserRepresentation user = UserConfigBuilder.create()
.username(username)
.password(password)
.name(firstName, lastName)
.email(email)
.enabled(true)
.build();
realm.admin().users().create(user);
}
@InjectRunOnServer(permittedPackages = {"org.keycloak.tests", "org.keycloak.admin"})
RunOnServerClient runOnServer;
@Test
public void searchUserWithWildcards() {
createUser("User", "password", "firstName", "lastName", "user@example.com");
createUser("User", "firstName", "lastName", "user@example.com");
assertThat(realm.admin().users().search("Use%", null, null), hasSize(0));
assertThat(realm.admin().users().search("Use_", null, null), hasSize(0));
@ -65,14 +64,14 @@ public class UsersTest {
@Test
public void searchUserDefaultSettings() throws Exception {
createUser("User", "password", "firstName", "lastName", "user@example.com");
createUser("User", "firstName", "lastName", "user@example.com");
assertCaseInsensitiveSearch();
}
@Test
public void searchUserMatchUsersCount() {
createUser("john.doe", "password", "John", "Doe Smith", "john.doe@keycloak.org");
createUser("john.doe", "John", "Doe Smith", "john.doe@keycloak.org");
String search = "jo do";
assertThat(realm.admin().users().count(search), is(1));
@ -86,24 +85,22 @@ public class UsersTest {
*/
@Test
public void findUsersByEmailVerifiedStatus() {
UserRepresentation user1 = UserConfigBuilder.create()
createUser(UserConfigBuilder.create()
.username("user1")
.password("password")
.name("user1FirstName", "user1LastName")
.email("user1@example.com")
.emailVerified()
.enabled(true)
.build();
realm.admin().users().create(user1);
.build());
UserRepresentation user2 = UserConfigBuilder.create()
createUser(UserConfigBuilder.create()
.username("user2")
.password("password")
.name("user2FirstName", "user2LastName")
.email("user2@example.com")
.enabled(true)
.build();
realm.admin().users().create(user2);
.build());
boolean emailVerified;
emailVerified = true;
@ -111,10 +108,95 @@ public class UsersTest {
assertThat(usersEmailVerified, is(not(empty())));
assertThat(usersEmailVerified.get(0).getUsername(), is("user1"));
createUser(UserConfigBuilder.create()
.username("testuser2")
.password("password")
.name("testuser2", "testuser2")
.email("testuser2@example.com")
.emailVerified()
.enabled(true)
.build());
usersEmailVerified = realm.admin().users().search("user", null, null, null, emailVerified, null, null, null);
assertThat(usersEmailVerified, is(not(empty())));
assertThat(usersEmailVerified.size(), is(1));
assertThat(usersEmailVerified.get(0).getUsername(), is("user1"));
assertThat(realm.admin().users().count("user", null, null, null, emailVerified, null, null, null), is(1));
emailVerified = false;
List<UserRepresentation> usersEmailNotVerified = realm.admin().users().search(null, null, null, null, emailVerified, null, null, null, true);
assertThat(usersEmailNotVerified, is(not(empty())));
assertThat(usersEmailNotVerified.get(0).getUsername(), is("user2"));
createUser(UserConfigBuilder.create()
.username("testuser3")
.password("password")
.name("testuser3", "testuser3")
.email("testuser3@example.com")
.enabled(true)
.build());
usersEmailVerified = realm.admin().users().search("user", null, null, null, emailVerified, null, null, null);
assertThat(usersEmailVerified, is(not(empty())));
assertThat(usersEmailVerified.size(), is(1));
assertThat(usersEmailVerified.get(0).getUsername(), is("user2"));
assertThat(realm.admin().users().count("user", null, null, null, emailVerified, null, null, null), is(1));
}
@Test
public void testCountUsersByEnabledStatus() {
createUser(UserConfigBuilder.create()
.username("user1")
.password("password")
.name("user1FirstName", "user1LastName")
.email("user1@example.com")
.emailVerified()
.enabled(true)
.build());
createUser(UserConfigBuilder.create()
.username("user2")
.password("password")
.name("user2FirstName", "user2LastName")
.email("user2@example.com")
.enabled(false)
.build());
assertThat(realm.admin().users().count("user", null, null, null, null, null, true, null), is(1));
assertThat(realm.admin().users().count("user", null, null, null, null, null, false, null), is(1));
}
@Test
public void testCountUsersByFederatedIdentity() {
createUser(UserConfigBuilder.create()
.username("user1")
.password("password")
.name("user1FirstName", "user1LastName")
.email("user1@example.com")
.emailVerified()
.enabled(true)
.build());
createUser(UserConfigBuilder.create()
.username("user2")
.password("password")
.name("user2FirstName", "user2LastName")
.email("user2@example.com")
.enabled(false)
.build());
runOnServer.run((session -> {
RealmModel realm = session.realms().getRealmByName(realmName);
session.getContext().setRealm(realm);
UserProvider users = session.users();
users.addFederatedIdentity(realm, users.getUserById(realm, users.getUserByUsername(realm, "user1").getId()), new FederatedIdentityModel("user1Broker", "user1BrokerId", "user1BrokerUsername"));
users.addFederatedIdentity(realm, users.getUserById(realm, users.getUserByUsername(realm, "user2").getId()), new FederatedIdentityModel("user2Broker", "user2BrokerId", "user2BrokerUsername"));
}));
assertThat(realm.admin().users().count(null, "user", null, null, null, null, null, "user1Broker", null, null), is(1));
assertThat(realm.admin().users().count(null, "user", null, null, null, null, null, "user1Broker", "user1BrokerId", null), is(1));
assertThat(realm.admin().users().count(null, "user", null, null, null, null, null, "user1Broker", "invalidId", null), is(0));
assertThat(realm.admin().users().count(null, "user", null, null, null, null, false, "user2Broker", null, null), is(1));
assertThat(realm.admin().users().count(null, "user", null, null, null, null, false, "user2Broker", "user1BrokerId", null), is(0));
}
/**
@ -122,34 +204,31 @@ public class UsersTest {
*/
@Test
public void countUsersByEmailVerifiedStatus() {
UserRepresentation user1 = UserConfigBuilder.create()
createUser(UserConfigBuilder.create()
.username("user1")
.password("password")
.name("user1FirstName", "user1LastName")
.email("user1@example.com")
.emailVerified()
.enabled(true)
.build();
realm.admin().users().create(user1);
.build());
UserRepresentation user2 = UserConfigBuilder.create()
createUser(UserConfigBuilder.create()
.username("user2")
.password("password")
.name("user2FirstName", "user2LastName")
.email("user2@example.com")
.enabled(true)
.build();
realm.admin().users().create(user2);
.build());
UserRepresentation user3 = UserConfigBuilder.create()
createUser(UserConfigBuilder.create()
.username("user3")
.password("password")
.name("user3FirstName", "user3LastName")
.email("user3@example.com")
.emailVerified()
.enabled(true)
.build();
realm.admin().users().create(user3);
.build());
boolean emailVerified;
emailVerified = true;
@ -163,41 +242,38 @@ public class UsersTest {
@Test
public void countUsersWithViewPermission() {
createUser("user1", "password", "user1FirstName", "user1LastName", "user1@example.com");
createUser("user2", "password", "user2FirstName", "user2LastName", "user2@example.com");
createUser("user1", "user1FirstName", "user1LastName", "user1@example.com");
createUser("user2", "user2FirstName", "user2LastName", "user2@example.com");
assertThat(realm.admin().users().count(), is(2));
}
@Test
public void countUsersBySearchWithViewPermission() {
UserRepresentation user1 = UserConfigBuilder.create()
createUser(UserConfigBuilder.create()
.username("user1")
.password("password")
.name("user1FirstName", "user1LastName")
.email("user1@example.com")
.emailVerified()
.enabled(true)
.build();
realm.admin().users().create(user1);
.build());
UserRepresentation user2 = UserConfigBuilder.create()
createUser(UserConfigBuilder.create()
.username("user2")
.password("password")
.name("user2FirstName", "user2LastName")
.email("user2@example.com")
.enabled(true)
.build();
realm.admin().users().create(user2);
.build());
UserRepresentation user3 = UserConfigBuilder.create()
createUser(UserConfigBuilder.create()
.username("user3")
.password("password")
.name("user3FirstName", "user3LastName")
.email("user3@example.com")
.emailVerified()
.enabled(true)
.build();
realm.admin().users().create(user3);
.build());
// Prefix search count
assertSearchMatchesCount(realm.admin(), "user", 3);
@ -231,8 +307,8 @@ public class UsersTest {
@Test
public void countUsersByFiltersWithViewPermission() {
createUser("user1", "password", "user1FirstName", "user1LastName", "user1@example.com");
createUser("user2", "password", "user2FirstName", "user2LastName", "user2@example.com");
createUser("user1", "user1FirstName", "user1LastName", "user1@example.com");
createUser("user2", "user2FirstName", "user2LastName", "user2@example.com");
//search username
assertThat(realm.admin().users().count(null, null, null, "user"), is(2));
assertThat(realm.admin().users().count(null, null, null, "user1"), is(1));
@ -284,4 +360,18 @@ public class UsersTest {
assertThat(realm.admin().users().search("USER", true), hasSize(1));
assertThat(realm.admin().users().search("Use", true), hasSize(0));
}
private void createUser(UserRepresentation user) {
realm.admin().users().create(user).close();
}
private void createUser(String username, String firstName, String lastName, String email) {
createUser(UserConfigBuilder.create()
.username(username)
.password("password")
.name(firstName, lastName)
.email(email)
.enabled(true)
.build());
}
}