Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@
*/
public class IpatuuraUserStorageProvider implements UserStorageProvider, UserLookupProvider, CredentialInputValidator,
CredentialAuthentication, UserRegistrationProvider, UserQueryProvider, ImportedUserValidation {
private static final Set<String> SUPPORTED_USER_QUERY_PARAMETERS = Set.of(
UserModel.SEARCH,
UserModel.INCLUDE_SERVICE_ACCOUNT);

protected KeycloakSession session;
protected ComponentModel model;
protected Ipatuura ipatuura;
Expand Down Expand Up @@ -287,7 +291,7 @@ public Stream<UserModel> searchForUserStream(RealmModel realm, Map<String, Strin
Integer maxResults) {
String search = params.get(UserModel.SEARCH);
/* only supports searching by username */
if (search == null)
if (search == null || params.keySet().stream().anyMatch(key -> !SUPPORTED_USER_QUERY_PARAMETERS.contains(key)))
return Stream.empty();
return performSearch(realm, search);
Comment thread
matlipowski marked this conversation as resolved.
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -398,10 +398,7 @@ public UserModel getUserById(RealmModel realm, String id) {
*/
@Override
public Stream<UserModel> searchForUserStream(RealmModel realm, Map<String, String> params, Integer firstResult, Integer maxResults) {
String search = params.get(UserModel.SEARCH);
Stream<LDAPObject> result = search != null ?
searchLDAP(realm, search, firstResult, maxResults) :
searchLDAPByAttributes(realm, params, firstResult, maxResults);
Stream<LDAPObject> result = searchLDAPByAttributes(realm, params, firstResult, maxResults);

if (model.isImportEnabled()) {
result = result.filter(filterLocalUsers(realm));
Expand Down Expand Up @@ -554,7 +551,9 @@ private Stream<LDAPObject> searchLDAPByAttributes(RealmModel realm, Map<String,

for (Map.Entry<String, String> entry : attributes.entrySet()) {
String attrName = entry.getKey();
if (LDAPConstants.LDAP_ID.equals(attrName)) {
if (UserModel.SEARCH.equals(attrName)) {
addSearchConditions(ldapQuery, conditionsBuilder, entry.getValue());
} else if (LDAPConstants.LDAP_ID.equals(attrName)) {
String uuidLDAPAttributeName = this.ldapIdentityStore.getConfig().getUuidLDAPAttributeName();
Condition usernameCondition = conditionsBuilder.equal(uuidLDAPAttributeName, entry.getValue());
ldapQuery.addWhereCondition(usernameCondition);
Expand Down Expand Up @@ -593,45 +592,25 @@ private Stream<LDAPObject> searchLDAPByAttributes(RealmModel realm, Map<String,
}
}

/**
* Searches LDAP using logical disjunction of params. It supports
* <ul>
* <li>{@link UserModel#FIRST_NAME}</li>
* <li>{@link UserModel#LAST_NAME}</li>
* <li>{@link UserModel#EMAIL}</li>
* <li>{@link UserModel#USERNAME}</li>
* </ul>
*
* It uses multiple LDAP calls and results are combined together with respect to firstResult and maxResults
*
* This method serves for {@code search} param of {@link org.keycloak.services.resources.admin.UsersResource#getUsers}
*/
private Stream<LDAPObject> searchLDAP(RealmModel realm, String search, Integer firstResult, Integer maxResults) {

try (LDAPQuery ldapQuery = LDAPUtils.createQueryForUserSearch(this, realm)) {
LDAPQueryConditionsBuilder conditionsBuilder = new LDAPQueryConditionsBuilder();

for (String s : search.split("\\s+")) {
boolean equals = false;
List<Condition> conditions = new LinkedList<>();
if (s.startsWith("\"") && s.endsWith("\"")) {
// exact search
s = s.substring(1, s.length() - 1);
equals = true;
} else if (!s.endsWith("*")) {
// default to prefix search
s += "*";
}

conditions.add(createSearchCondition(conditionsBuilder, UserModel.USERNAME, equals, s));
conditions.add(createSearchCondition(conditionsBuilder, UserModel.EMAIL, equals, s));
conditions.add(createSearchCondition(conditionsBuilder, UserModel.FIRST_NAME, equals, s));
conditions.add(createSearchCondition(conditionsBuilder, UserModel.LAST_NAME, equals, s));

ldapQuery.addWhereCondition(conditionsBuilder.orCondition(conditions.toArray(Condition[]::new)));
private void addSearchConditions(LDAPQuery ldapQuery, LDAPQueryConditionsBuilder conditionsBuilder, String search) {
for (String s : search.split("\\s+")) {
boolean equals = false;
List<Condition> conditions = new LinkedList<>();
if (s.startsWith("\"") && s.endsWith("\"")) {
// exact search
s = s.substring(1, s.length() - 1);
equals = true;
} else if (!s.endsWith("*")) {
// default to prefix search
s += "*";
}

return paginatedSearchLDAP(ldapQuery, firstResult, maxResults);
conditions.add(createSearchCondition(conditionsBuilder, UserModel.USERNAME, equals, s));
conditions.add(createSearchCondition(conditionsBuilder, UserModel.EMAIL, equals, s));
conditions.add(createSearchCondition(conditionsBuilder, UserModel.FIRST_NAME, equals, s));
conditions.add(createSearchCondition(conditionsBuilder, UserModel.LAST_NAME, equals, s));

ldapQuery.addWhereCondition(conditionsBuilder.orCondition(conditions.toArray(Condition[]::new)));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ default Stream<UserModel> searchForUserStream(RealmModel realm, String search, I
* <p/>
* Valid parameters are:
* <ul>
* <li>{@link UserModel#SEARCH} - search for users whose username, email, first name or last name contain any of the strings in {@code search} separated by whitespace, when {@code SEARCH} is set all other params are ignored</li>
* <li>{@link UserModel#SEARCH} - search for users whose username, email, first name or last name contain any of the strings in {@code search} separated by whitespace; custom user attributes are combined with {@code SEARCH} using a logical AND</li>
* <li>{@link UserModel#FIRST_NAME} - first name (case insensitive string)</li>
* <li>{@link UserModel#LAST_NAME} - last name (case insensitive string)</li>
* <li>{@link UserModel#EMAIL} - email (case insensitive string)</li>
Expand Down Expand Up @@ -116,7 +116,7 @@ default Stream<UserModel> searchForUserStream(RealmModel realm, Map<String, Stri
* <p/>
* Valid parameters are:
* <ul>
* <li>{@link UserModel#SEARCH} - search for users whose username, email, first name or last name contain any of the strings in {@code search} separated by whitespace, when {@code SEARCH} is set all other params are ignored</li>
* <li>{@link UserModel#SEARCH} - search for users whose username, email, first name or last name contain any of the strings in {@code search} separated by whitespace; custom user attributes are combined with {@code SEARCH} using a logical AND</li>
Comment thread
matlipowski marked this conversation as resolved.
* <li>{@link UserModel#FIRST_NAME} - first name (case insensitive string)</li>
* <li>{@link UserModel#LAST_NAME} - last name (case insensitive string)</li>
* <li>{@link UserModel#EMAIL} - email (case insensitive string)</li>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,22 +304,19 @@ public Stream<UserRepresentation> getUsers(
if (search != null) {
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);
userModels = searchForUsersByPrefix(search, prefix, realm, searchAttributes);
if (AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) {
userModels = userModels.filter(userPermissionEvaluator::canView);
}
} else {
Map<String, String> attributes = new HashMap<>();
attributes.put(UserModel.SEARCH, search.trim());
if (enabled != null) {
attributes.put(UserModel.ENABLED, enabled.toString());
if (firstResult > 0) {
userModels = userModels.skip(firstResult);
}
if (emailVerified != null) {
attributes.put(UserModel.EMAIL_VERIFIED, emailVerified.toString());
if (maxResults >= 0) {
userModels = userModels.limit(maxResults);
}
addCreatedTimestampConditions(attributes, createdAfter, createdBefore);
} else {
Map<String, String> attributes = new HashMap<>(searchAttributes);
attributes.put(UserModel.SEARCH, search.trim());

return searchForUser(attributes, realm, userPermissionEvaluator, briefRepresentation, firstResult,
maxResults, false);
Expand Down Expand Up @@ -375,10 +372,10 @@ public Stream<UserRepresentation> getUsers(
* 1. Don't specify any criteria and pass {@code null}. The number of all
* users within that realm will be returned.
* <p>
* 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.
* 2. If {@code search} is specified, it is combined with {@code q} using a
* logical AND. 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
Expand Down Expand Up @@ -410,7 +407,7 @@ public Stream<UserRepresentation> getUsers(
summary = "Returns the number of users that match the given criteria.",
description = "It can be called in three different ways. " +
"1. Don’t specify any criteria and pass {@code null}. The number of all users within that realm will be returned. <p> " +
"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> " +
"2. If {@code search} is specified, it is combined with {@code q} using a logical AND. 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 = "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,
Expand All @@ -435,23 +432,13 @@ public Integer getUsersCount(
if (search != null) {
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)
return (int) searchForUsersByPrefix(search, prefix, realm, searchAttributes)
.filter(userPermissionEvaluator::canView)
.count();
}

Map<String, String> parameters = new HashMap<>();
Map<String, String> parameters = new HashMap<>(searchAttributes);
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());
}
addCreatedTimestampConditions(parameters, createdAfter, createdBefore);
// search /users equivalent to this doesn't include service-accounts so counting shouldn't as well
parameters.put(UserModel.INCLUDE_SERVICE_ACCOUNT, "false");
if (userPermissionEvaluator.canView()) {
Expand Down Expand Up @@ -534,6 +521,24 @@ public UserProfileResource userProfile() {
return new UserProfileResource(session, auth, adminEvent);
}

private Stream<UserModel> searchForUsersByPrefix(String search, SearchQueryUtils.UserSearchPrefix prefix,
RealmModel realm, Map<String, String> searchAttributes) {
List<UserModel> candidates = Arrays.stream(prefix.splitTerms(search))
.map(term -> prefix.lookup(session.users(), realm, term))
.filter(Objects::nonNull)
.toList();
if (candidates.isEmpty() || searchAttributes.isEmpty()) {
return candidates.stream();
}

Set<String> candidateIds = candidates.stream().map(UserModel::getId).collect(Collectors.toSet());
Set<String> matchingIds = session.users().searchForUserStream(realm, searchAttributes)
.map(UserModel::getId)
.filter(candidateIds::contains)
.collect(Collectors.toSet());
return candidates.stream().filter(user -> matchingIds.contains(user.getId()));
}

private static void addCreatedTimestampConditions(Map<String, String> attributes, String createdAfter, String createdBefore) {
if (createdAfter != null) {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,45 @@ public void searchByAttribute() {
assertEquals(0, users.size());
}

@Test
public void searchUserWithQueryParameter() {
createUsers();

String query = mapToSearchQuery(Map.of("test", "test1"));
assertThat(managedRealm.admin().users().searchByAttributes(query), hasSize(1));
List<UserRepresentation> users = managedRealm.admin().users().search("username", null, null, null, null, null, null, query);
assertThat(users, hasSize(1));
assertThat(users.get(0).getUsername(), is("username1"));
assertThat(managedRealm.admin().users().count("username", null, null, null, null, null, null, query), is(1));

users = managedRealm.admin().users().search("username", null, null, null, true, null, false, query);
assertThat(users, hasSize(1));
assertThat(users.get(0).getUsername(), is("username1"));
assertThat(managedRealm.admin().users().count("username", null, null, null, true, null, false, query), is(1));

assertThat(managedRealm.admin().users().search("username2", null, null, null, null, null, null, query), hasSize(0));
assertThat(managedRealm.admin().users().count("username2", null, null, null, null, null, null, query), is(0));

users = managedRealm.admin().users().search("username:username1", null, null, null, null, null, null, query);
assertThat(users, hasSize(1));
assertThat(users.get(0).getUsername(), is("username1"));
assertThat(managedRealm.admin().users().count("username:username1", null, null, null, null, null, null, query), is(1));
assertThat(managedRealm.admin().users().search("username:username2", null, null, null, null, null, null, query), hasSize(0));
assertThat(managedRealm.admin().users().count("username:username2", null, null, null, null, null, null, query), is(0));

String standardParameterQuery = mapToSearchQuery(Map.of(UserModel.EMAIL_VERIFIED, "false"));
assertThat(managedRealm.admin().users().search("username:username1", null, null, null, null, null, null,
standardParameterQuery), hasSize(1));
assertThat(managedRealm.admin().users().count("username:username1", null, null, null, null, null, null,
standardParameterQuery), is(1));

standardParameterQuery = mapToSearchQuery(Map.of(UserModel.EMAIL_VERIFIED, "true"));
assertThat(managedRealm.admin().users().search("username:username1", null, null, null, null, null, null,
standardParameterQuery), hasSize(0));
assertThat(managedRealm.admin().users().count("username:username1", null, null, null, null, null, null,
standardParameterQuery), is(0));
}

@Test
public void searchByMultipleAttributes() {
createUsers();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1132,6 +1132,23 @@ public void testSearch() {
});
}

@Test
public void testSearchWithCustomAttributes() {
testingClient.server().run(session -> {
LDAPTestContext ctx = LDAPTestContext.init(session);
RealmModel appRealm = ctx.getRealm();

LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "combinedsearch1", "Combined1", "Search1", "combined1@email.org", null, "13101");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "combinedsearch2", "Combined2", "Search2", "combined2@email.org", null, "13102");

Map<String, String> matching = Map.of(UserModel.SEARCH, "combinedsearch", "postal_code", "13101");
Assertions.assertEquals(1, session.users().searchForUserStream(appRealm, matching).count());

Map<String, String> disjoint = Map.of(UserModel.SEARCH, "combinedsearch2", "postal_code", "13101");
Assertions.assertEquals(0, session.users().searchForUserStream(appRealm, disjoint).count());
});
}

@Test
public void testSearchWithCustomLDAPFilter() {
// Add custom filter for searching users
Expand Down