Enable MapUserProvider storing username with the letter case significance

Closes #10245
Closes #11602
This commit is contained in:
vramik 2022-07-14 13:33:13 +02:00 committed by Hynek Mlnařík
parent fb33cbc2bd
commit 869ccc82b2
30 changed files with 375 additions and 57 deletions

View file

@ -68,6 +68,8 @@ public class IckleQueryMapModelCriteriaBuilder<E extends AbstractHotRodEntity, M
INFINISPAN_NAME_OVERRIDES.put(RoleModel.SearchableFields.IS_CLIENT_ROLE, "clientRole");
INFINISPAN_NAME_OVERRIDES.put(UserModel.SearchableFields.USERNAME_CASE_INSENSITIVE, "usernameLowercase");
INFINISPAN_NAME_OVERRIDES.put(UserModel.SearchableFields.USERNAME, "username");
INFINISPAN_NAME_OVERRIDES.put(UserModel.SearchableFields.SERVICE_ACCOUNT_CLIENT, "serviceAccountClientLink");
INFINISPAN_NAME_OVERRIDES.put(UserModel.SearchableFields.CONSENT_FOR_CLIENT, "userConsents.clientId");
INFINISPAN_NAME_OVERRIDES.put(UserModel.SearchableFields.CONSENT_WITH_CLIENT_SCOPE, "userConsents.grantedClientScopesIds");

View file

@ -25,6 +25,7 @@ import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.map.storage.CriterionNotSupportedException;
import org.keycloak.models.map.storage.ModelCriteriaBuilder;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.storage.SearchableModelField;
import org.keycloak.storage.StorageId;
import org.keycloak.util.EnumWithStableIndex;
@ -62,6 +63,7 @@ public class IckleQueryWhereClauses {
WHERE_CLAUSE_PRODUCER_OVERRIDES.put(UserModel.SearchableFields.ATTRIBUTE, IckleQueryWhereClauses::whereClauseForAttributes);
WHERE_CLAUSE_PRODUCER_OVERRIDES.put(UserModel.SearchableFields.IDP_AND_USER, IckleQueryWhereClauses::whereClauseForUserIdpAlias);
WHERE_CLAUSE_PRODUCER_OVERRIDES.put(UserModel.SearchableFields.CONSENT_CLIENT_FEDERATION_LINK, IckleQueryWhereClauses::whereClauseForConsentClientFederationLink);
WHERE_CLAUSE_PRODUCER_OVERRIDES.put(UserModel.SearchableFields.USERNAME_CASE_INSENSITIVE, IckleQueryWhereClauses::whereClauseForUsernameCaseInsensitive);
WHERE_CLAUSE_PRODUCER_OVERRIDES.put(UserSessionModel.SearchableFields.CORRESPONDING_SESSION_ID, IckleQueryWhereClauses::whereClauseForCorrespondingSessionId);
WHERE_CLAUSE_PRODUCER_OVERRIDES.put(Policy.SearchableFields.CONFIG, IckleQueryWhereClauses::whereClauseForPolicyConfig);
WHERE_CLAUSE_PRODUCER_OVERRIDES.put(Event.SearchableFields.EVENT_TYPE, IckleQueryWhereClauses::whereClauseForEnumWithStableIndex);
@ -226,4 +228,14 @@ public class IckleQueryWhereClauses {
return produceWhereClause(modelFieldName, op, values, parameters);
}
private static String whereClauseForUsernameCaseInsensitive(String modelFieldName, ModelCriteriaBuilder.Operator op, Object[] values, Map<String, Object> parameters) {
for (int i = 0; i < values.length; i++) {
if (values[i] instanceof String) {
values[i] = KeycloakModelUtils.toLowerCaseSafe((String) values[i]);
}
}
return produceWhereClause(modelFieldName, op == ModelCriteriaBuilder.Operator.ILIKE ? ModelCriteriaBuilder.Operator.LIKE : op, values, parameters);
}
}

View file

@ -35,7 +35,7 @@ public interface Constants {
public static final Integer CURRENT_SCHEMA_VERSION_ROOT_AUTH_SESSION = 1;
public static final Integer CURRENT_SCHEMA_VERSION_SINGLE_USE_OBJECT = 1;
public static final Integer CURRENT_SCHEMA_VERSION_USER_LOGIN_FAILURE = 1;
public static final Integer CURRENT_SCHEMA_VERSION_USER = 1;
public static final Integer CURRENT_SCHEMA_VERSION_USER = 2;
public static final Integer CURRENT_SCHEMA_VERSION_USER_CONSENT = 1;
public static final Integer CURRENT_SCHEMA_VERSION_USER_FEDERATED_IDENTITY = 1;
public static final Integer CURRENT_SCHEMA_VERSION_USER_SESSION = 1;

View file

@ -17,8 +17,6 @@
package org.keycloak.models.map.storage.jpa;
import org.keycloak.models.map.storage.jpa.JpaSubqueryProvider;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;

View file

@ -16,10 +16,12 @@
*/
package org.keycloak.models.map.storage.jpa.hibernate.jsonb.migration;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import org.keycloak.models.utils.KeycloakModelUtils;
/**
* Migration functions for users.
@ -29,6 +31,13 @@ import java.util.function.Function;
public class JpaUserMigration {
public static final List<Function<ObjectNode, ObjectNode>> MIGRATORS = Arrays.asList(
o -> o // no migration yet
o -> o,
JpaUserMigration::migrateTreeFrom1To2
);
// adds lower-case variant of username into json
private static ObjectNode migrateTreeFrom1To2(ObjectNode node) {
JsonNode usernameNode = node.path("fUsername");
return node.put("usernameLowerCase", KeycloakModelUtils.toLowerCaseSafe(usernameNode.asText()));
}
}

View file

@ -54,8 +54,31 @@ public class JpaUserModelCriteriaBuilder extends JpaModelCriteriaBuilder<JpaUser
public JpaUserModelCriteriaBuilder compare(SearchableModelField<? super UserModel> modelField, Operator op, Object... value) {
switch(op) {
case EQ:
if (modelField == UserModel.SearchableFields.REALM_ID ||
modelField == UserModel.SearchableFields.USERNAME ||
if (modelField == UserModel.SearchableFields.USERNAME_CASE_INSENSITIVE) {
validateValue(value, modelField, op, String.class);
return new JpaUserModelCriteriaBuilder((cb, query, root) ->
cb.or(
cb.and(
cb.equal(root.get("usernameLowerCase"), value[0].toString().toLowerCase()),
cb.ge(root.get("entityVersion"), 2)
),
cb.and(
cb.equal(root.get("username"), value[0].toString().toLowerCase()),
cb.le(root.get("entityVersion"), 1)
)
)
);
} else if (modelField == UserModel.SearchableFields.USERNAME) {
validateValue(value, modelField, op, String.class);
return new JpaUserModelCriteriaBuilder((cb, query, root) ->
cb.equal(root.get("username"), value[0])
);
} else if (modelField == UserModel.SearchableFields.REALM_ID ||
modelField == UserModel.SearchableFields.EMAIL ||
modelField == UserModel.SearchableFields.FEDERATION_LINK) {
@ -152,14 +175,43 @@ public class JpaUserModelCriteriaBuilder extends JpaModelCriteriaBuilder<JpaUser
throw new CriterionNotSupportedException(modelField, op);
}
case ILIKE:
if (modelField == UserModel.SearchableFields.USERNAME ||
modelField == UserModel.SearchableFields.FIRST_NAME ||
if (modelField == UserModel.SearchableFields.FIRST_NAME ||
modelField == UserModel.SearchableFields.LAST_NAME ||
modelField == UserModel.SearchableFields.EMAIL) {
validateValue(value, modelField, op, String.class);
return new JpaUserModelCriteriaBuilder((cb, query, root) ->
cb.like(cb.lower(root.get(modelField.getName())), value[0].toString().toLowerCase()));
} else if (modelField == UserModel.SearchableFields.USERNAME_CASE_INSENSITIVE) {
validateValue(value, modelField, op, String.class);
return new JpaUserModelCriteriaBuilder((cb, query, root) ->
cb.or(
cb.and(
cb.like(root.get("usernameLowerCase"), value[0].toString().toLowerCase()),
cb.ge(root.get("entityVersion"), 2)
),
cb.and(
cb.like(root.get("username"), value[0].toString().toLowerCase()),
cb.le(root.get("entityVersion"), 1)
)
)
);
} else {
throw new CriterionNotSupportedException(modelField, op);
}
case LIKE:
if (modelField == UserModel.SearchableFields.USERNAME) {
validateValue(value, modelField, op, String.class);
return new JpaUserModelCriteriaBuilder((cb, query, root) ->
cb.like(root.get("username"), value[0].toString())
);
} else {
throw new CriterionNotSupportedException(modelField, op);
}

View file

@ -96,6 +96,10 @@ public class JpaUserEntity extends MapUserEntity.AbstractUserEntity implements J
@Basic(fetch = FetchType.LAZY)
private String username;
@Column(insertable = false, updatable = false)
@Basic(fetch = FetchType.LAZY)
private String usernameLowerCase;
@Column(insertable = false, updatable = false)
@Basic(fetch = FetchType.LAZY)
private String firstName;
@ -237,6 +241,7 @@ public class JpaUserEntity extends MapUserEntity.AbstractUserEntity implements J
@Override
public void setUsername(String username) {
this.metadata.setUsername(username);
this.metadata.setUsernameLowerCase(username);
}
@Override

View file

@ -20,6 +20,7 @@ import java.io.Serializable;
import org.keycloak.models.map.common.DeepCloner;
import org.keycloak.models.map.user.MapUserEntityImpl;
import org.keycloak.models.utils.KeycloakModelUtils;
/**
* Class that contains all the user metadata that is written as JSON into the database.
@ -37,6 +38,7 @@ public class JpaUserMetadata extends MapUserEntityImpl implements Serializable {
}
private Integer entityVersion;
private String usernameLowerCase;
public Integer getEntityVersion() {
return entityVersion;
@ -45,4 +47,8 @@ public class JpaUserMetadata extends MapUserEntityImpl implements Serializable {
public void setEntityVersion(Integer entityVersion) {
this.entityVersion = entityVersion;
}
public void setUsernameLowerCase(String username) {
this.usernameLowerCase = KeycloakModelUtils.toLowerCaseSafe(username);
}
}

View file

@ -19,4 +19,5 @@ limitations under the License.
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
<include file="META-INF/users/jpa-users-changelog-1.xml"/>
<include file="META-INF/users/jpa-users-changelog-2.xml"/>
</databaseChangeLog>

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2022 Red Hat, Inc. and/or its affiliates
and other contributors as indicated by the @author tags.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd
http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd">
<changeSet author="keycloak" id="users-10245">
<ext:addGeneratedColumn tableName="kc_user">
<ext:column name="usernamelowercase" type="VARCHAR(255)" jsonColumn="metadata" jsonProperty="usernameLowerCase"/>
</ext:addGeneratedColumn>
<createIndex tableName="kc_user" indexName="user_username_lower_case_realmid">
<column name="usernamelowercase"/>
<column name="realmid"/>
</createIndex>
</changeSet>
</databaseChangeLog>

View file

@ -24,7 +24,6 @@ import org.keycloak.authorization.model.Scope;
import org.keycloak.events.Event;
import org.keycloak.events.admin.AdminEvent;
import org.keycloak.models.ActionTokenValueModel;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.GroupModel;
@ -51,6 +50,7 @@ import org.keycloak.models.map.role.MapRoleEntity;
import org.keycloak.models.map.singleUseObject.MapSingleUseObjectEntity;
import org.keycloak.models.map.storage.QueryParameters;
import org.keycloak.models.map.user.MapUserConsentEntity;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.storage.SearchableModelField;
import java.util.Comparator;
@ -59,7 +59,6 @@ import java.util.Map;
import org.keycloak.models.map.storage.chm.MapModelCriteriaBuilder.UpdatePredicatesFunc;
import org.keycloak.models.map.storage.ModelCriteriaBuilder.Operator;
import org.keycloak.models.map.user.MapUserEntity;
import org.keycloak.models.map.userSession.MapAuthenticatedClientSessionEntity;
import org.keycloak.models.map.userSession.MapUserSessionEntity;
import org.keycloak.sessions.RootAuthenticationSessionModel;
import org.keycloak.storage.StorageId;
@ -134,6 +133,7 @@ public class MapFieldPredicates {
put(USER_PREDICATES, UserModel.SearchableFields.REALM_ID, MapUserEntity::getRealmId);
put(USER_PREDICATES, UserModel.SearchableFields.USERNAME, MapUserEntity::getUsername);
put(USER_PREDICATES, UserModel.SearchableFields.USERNAME_CASE_INSENSITIVE, MapFieldPredicates::usernameCaseInsensitive);
put(USER_PREDICATES, UserModel.SearchableFields.FIRST_NAME, MapUserEntity::getFirstName);
put(USER_PREDICATES, UserModel.SearchableFields.LAST_NAME, MapUserEntity::getLastName);
put(USER_PREDICATES, UserModel.SearchableFields.EMAIL, MapUserEntity::getEmail);
@ -306,6 +306,18 @@ public class MapFieldPredicates {
return mcb.fieldCompare(Boolean.TRUE::equals, getter);
}
private static MapModelCriteriaBuilder<Object, MapUserEntity, UserModel> usernameCaseInsensitive(MapModelCriteriaBuilder<Object, MapUserEntity, UserModel> mcb, Operator op, Object[] values) {
for (int i = 0; i < values.length; i++) {
if (values[i] instanceof String) {
values[i] = KeycloakModelUtils.toLowerCaseSafe((String) values[i]);
}
}
Predicate<Object> valueComparator = CriteriaOperator.predicateFor(op, values);
Function<MapUserEntity, ?> getter = ue -> valueComparator.test(KeycloakModelUtils.toLowerCaseSafe(ue.getUsername()));
return mcb.fieldCompare(Boolean.TRUE::equals, getter);
}
private static MapModelCriteriaBuilder<Object, MapUserEntity, UserModel> getUserConsentClientFederationLink(MapModelCriteriaBuilder<Object, MapUserEntity, UserModel> mcb, Operator op, Object[] values) {
String providerId = ensureEqSingleValue(UserModel.SearchableFields.CONSENT_CLIENT_FEDERATION_LINK, "provider_id", op, values);
String providerIdS = new StorageId((String) providerId, "").getId();

View file

@ -54,7 +54,6 @@ public abstract class MapUserAdapter extends AbstractUserModel<MapUserEntity> {
@Override
public void setUsername(String username) {
username = KeycloakModelUtils.toLowerCaseSafe(username);
// Do not continue if current username of entity is the requested username
if (username != null && username.equals(entity.getUsername())) return;

View file

@ -80,6 +80,8 @@ import static org.keycloak.models.map.common.AbstractMapProviderFactory.MapProvi
import static org.keycloak.models.map.storage.QueryParameters.Order.ASCENDING;
import static org.keycloak.models.map.storage.QueryParameters.withCriteria;
import static org.keycloak.models.map.storage.criteria.DefaultModelCriteria.criteria;
import static org.keycloak.models.map.user.MapUserProviderFactory.REALM_ATTR_USERNAME_CASE_SENSITIVE;
import static org.keycloak.models.map.user.MapUserProviderFactory.REALM_ATTR_USERNAME_CASE_SENSITIVE_DEFAULT;
public class MapUserProvider implements UserProvider.Streams {
@ -93,6 +95,10 @@ public class MapUserProvider implements UserProvider.Streams {
session.getTransactionManager().enlist(tx);
}
private Boolean getUsernameCaseSensitiveAttribute(RealmModel realm) {
return realm.getAttribute(REALM_ATTR_USERNAME_CASE_SENSITIVE, REALM_ATTR_USERNAME_CASE_SENSITIVE_DEFAULT);
}
private Function<MapUserEntity, UserModel> entityToAdapterFunc(RealmModel realm) {
// Clone entity before returning back, to avoid giving away a reference to the live object to the caller
return origEntity -> new MapUserAdapter(session, realm, origEntity) {
@ -330,12 +336,14 @@ public class MapUserProvider implements UserProvider.Streams {
LOG.tracef("addUser(%s, %s, %s, %s, %s)%s", realm, id, username, addDefaultRoles, addDefaultRequiredActions, getShortStackTrace());
DefaultModelCriteria<UserModel> mcb = criteria();
mcb = mcb.compare(SearchableFields.REALM_ID, Operator.EQ, realm.getId())
.compare(SearchableFields.USERNAME, Operator.EQ, username);
.compare(getUsernameCaseSensitiveAttribute(realm) ?
SearchableFields.USERNAME :
SearchableFields.USERNAME_CASE_INSENSITIVE, Operator.EQ, username);
if (tx.getCount(withCriteria(mcb)) > 0) {
throw new ModelDuplicateException("User with username '" + username + "' in realm " + realm.getName() + " already exists" );
}
if (id != null && tx.read(id) != null) {
throw new ModelDuplicateException("User exists: " + id);
}
@ -344,7 +352,7 @@ public class MapUserProvider implements UserProvider.Streams {
entity.setId(id);
entity.setRealmId(realm.getId());
entity.setEmailConstraint(KeycloakModelUtils.generateId());
entity.setUsername(username.toLowerCase());
entity.setUsername(username);
entity.setCreatedTimestamp(Time.currentTimeMillis());
entity = tx.create(entity);
@ -488,11 +496,19 @@ public class MapUserProvider implements UserProvider.Streams {
LOG.tracef("getUserByUsername(%s, %s)%s", realm, username, getShortStackTrace());
DefaultModelCriteria<UserModel> mcb = criteria();
mcb = mcb.compare(SearchableFields.REALM_ID, Operator.EQ, realm.getId())
.compare(SearchableFields.USERNAME, Operator.ILIKE, username);
.compare(getUsernameCaseSensitiveAttribute(realm) ?
SearchableFields.USERNAME :
SearchableFields.USERNAME_CASE_INSENSITIVE, Operator.EQ, username);
try (Stream<MapUserEntity> s = tx.read(withCriteria(mcb))) {
return s.findFirst()
.map(entityToAdapterFunc(realm)).orElse(null);
// there is orderBy used to always return the same user in case multiple users are returned from the store
try (Stream<MapUserEntity> s = tx.read(withCriteria(mcb).orderBy(SearchableFields.USERNAME, ASCENDING))) {
List<MapUserEntity> users = s.collect(Collectors.toList());
if (users.isEmpty()) return null;
if (users.size() != 1) {
LOG.warnf("There are colliding usernames for users with usernames and ids: %s",
users.stream().collect(Collectors.toMap(MapUserEntity::getUsername, MapUserEntity::getId)));
}
return entityToAdapterFunc(realm).apply(users.get(0));
}
}
@ -503,11 +519,9 @@ public class MapUserProvider implements UserProvider.Streams {
mcb = mcb.compare(SearchableFields.REALM_ID, Operator.EQ, realm.getId())
.compare(SearchableFields.EMAIL, Operator.EQ, email);
List<MapUserEntity> usersWithEmail = tx.read(withCriteria(mcb))
.filter(userEntity -> Objects.equals(userEntity.getEmail(), email))
.collect(Collectors.toList());
List<MapUserEntity> usersWithEmail = tx.read(withCriteria(mcb)).collect(Collectors.toList());
if (usersWithEmail.isEmpty()) return null;
if (usersWithEmail.size() > 1) {
// Realm settings have been changed from allowing duplicate emails to not allowing them
// but duplicates haven't been removed.
@ -515,7 +529,7 @@ public class MapUserProvider implements UserProvider.Streams {
}
MapUserEntity userEntity = usersWithEmail.get(0);
if (!realm.isDuplicateEmailsAllowed()) {
if (userEntity.getEmail() != null && !userEntity.getEmail().equals(userEntity.getEmailConstraint())) {
// Realm settings have been changed from allowing duplicate emails to not allowing them.
@ -523,7 +537,7 @@ public class MapUserProvider implements UserProvider.Streams {
userEntity.setEmailConstraint(userEntity.getEmail());
}
}
return entityToAdapterFunc(realm).apply(userEntity);
}
@ -573,16 +587,18 @@ public class MapUserProvider implements UserProvider.Streams {
DefaultModelCriteria<UserModel> searchCriteria = null;
for (String stringToSearch : value.split("\\s+")) {
if (searchCriteria == null) {
searchCriteria = addSearchToModelCriteria(stringToSearch, mcb);
searchCriteria = addSearchToModelCriteria(realm, stringToSearch, mcb);
} else {
searchCriteria = mcb.and(searchCriteria, addSearchToModelCriteria(stringToSearch, mcb));
searchCriteria = mcb.and(searchCriteria, addSearchToModelCriteria(realm, stringToSearch, mcb));
}
}
criteria = mcb.and(criteria, searchCriteria);
break;
case USERNAME:
criteria = criteria.compare(SearchableFields.USERNAME, Operator.ILIKE, searchedString);
criteria = getUsernameCaseSensitiveAttribute(realm) ?
criteria.compare(SearchableFields.USERNAME, Operator.LIKE, searchedString) :
criteria.compare(SearchableFields.USERNAME_CASE_INSENSITIVE, Operator.ILIKE, searchedString);
break;
case FIRST_NAME:
criteria = criteria.compare(SearchableFields.FIRST_NAME, Operator.ILIKE, searchedString);
@ -682,7 +698,7 @@ public class MapUserProvider implements UserProvider.Streams {
@Override
public UserModel addUser(RealmModel realm, String username) {
return addUser(realm, null, username.toLowerCase(), true, true);
return addUser(realm, null, username, true, true);
}
@Override
@ -747,7 +763,7 @@ public class MapUserProvider implements UserProvider.Streams {
return r;
}
private DefaultModelCriteria<UserModel> addSearchToModelCriteria(String value,
private DefaultModelCriteria<UserModel> addSearchToModelCriteria(RealmModel realm, String value,
DefaultModelCriteria<UserModel> mcb) {
if (value.length() >= 2 && value.charAt(0) == '"' && value.charAt(value.length() - 1) == '"') {
@ -767,7 +783,9 @@ public class MapUserProvider implements UserProvider.Streams {
}
return mcb.or(
mcb.compare(SearchableFields.USERNAME, Operator.ILIKE, value),
getUsernameCaseSensitiveAttribute(realm) ?
mcb.compare(SearchableFields.USERNAME, Operator.LIKE, value) :
mcb.compare(SearchableFields.USERNAME_CASE_INSENSITIVE, Operator.ILIKE, value),
mcb.compare(SearchableFields.EMAIL, Operator.ILIKE, value),
mcb.compare(SearchableFields.FIRST_NAME, Operator.ILIKE, value),
mcb.compare(SearchableFields.LAST_NAME, Operator.ILIKE, value));

View file

@ -40,6 +40,9 @@ import static org.keycloak.models.map.common.AbstractMapProviderFactory.MapProvi
*/
public class MapUserProviderFactory extends AbstractMapProviderFactory<MapUserProvider, MapUserEntity, UserModel> implements UserProviderFactory<MapUserProvider>, InvalidationHandler {
public static final String REALM_ATTR_USERNAME_CASE_SENSITIVE = "keycloak.username-search.case-sensitive";
public static final Boolean REALM_ATTR_USERNAME_CASE_SENSITIVE_DEFAULT = Boolean.FALSE;
public MapUserProviderFactory() {
super(UserModel.class, MapUserProvider.class);
}

View file

@ -271,10 +271,6 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
values = (List<String>) value;
}
if (key.equals(UserModel.USERNAME)) {
values = Collections.singletonList(values.get(0).toLowerCase());
}
newAttributes.put(key, Collections.unmodifiableList(values));
}
}

View file

@ -53,7 +53,6 @@ public interface UserModel extends RoleMapperModel {
public static class SearchableFields {
public static final SearchableModelField<UserModel> ID = new SearchableModelField<>("id", String.class);
public static final SearchableModelField<UserModel> REALM_ID = new SearchableModelField<>("realmId", String.class);
public static final SearchableModelField<UserModel> USERNAME = new SearchableModelField<>("username", String.class);
public static final SearchableModelField<UserModel> FIRST_NAME = new SearchableModelField<>("firstName", String.class);
public static final SearchableModelField<UserModel> LAST_NAME = new SearchableModelField<>("lastName", String.class);
public static final SearchableModelField<UserModel> EMAIL = new SearchableModelField<>("email", String.class);
@ -61,6 +60,15 @@ public interface UserModel extends RoleMapperModel {
public static final SearchableModelField<UserModel> EMAIL_VERIFIED = new SearchableModelField<>("emailVerified", Boolean.class);
public static final SearchableModelField<UserModel> FEDERATION_LINK = new SearchableModelField<>("federationLink", String.class);
/**
* Search for user's username in case sensitive mode.
*/
public static final SearchableModelField<UserModel> USERNAME = new SearchableModelField<>("username", String.class);
/**
* Search for user's username in case insensitive mode.
*/
public static final SearchableModelField<UserModel> USERNAME_CASE_INSENSITIVE = new SearchableModelField<>("usernameCaseInsensitive", String.class);
/**
* This field can only searched either for users coming from an IDP, then the operand is (idp_alias),
* or as user coming from a particular IDP with given username there, then the operand is a pair (idp_alias, idp_user_id).

View file

@ -52,11 +52,15 @@ public interface UserLookupProvider {
UserModel getUserById(String id, RealmModel realm);
/**
* Exact search for a user by its username.
* Returns a user with the given username belonging to the realm
*
* @param username case insensitive username (case-sensitivity is controlled by storage)
* @param username (case-sensitivity is controlled by storage)
* @param realm the realm model
* @return found user model, or {@code null} if no such user exists
* @throws org.keycloak.models.ModelDuplicateException when searched with case
* insensitive mode and there are more users with username which differs only
* by case
*/
default UserModel getUserByUsername(RealmModel realm, String username) {
return getUserByUsername(username, realm);
@ -75,7 +79,7 @@ public interface UserLookupProvider {
/**
* Returns a user with the given email belonging to the realm
*
* @param email case insensitive email address (case-sensitivity is controlled by storage)
* @param email email address
* @param realm the realm model
* @return found user model, or {@code null} if no such user exists
*

View file

@ -325,6 +325,8 @@ public class AssertEvents implements TestRule {
description.appendText("contains scope in any order");
}
});
} else if (key.equals(Details.USERNAME) && value != null) {
return detail(key, Matchers.equalToIgnoringCase(value));
} else {
return detail(key, CoreMatchers.equalTo(value));
}

View file

@ -17,6 +17,7 @@
package org.keycloak.testsuite.account;
import com.fasterxml.jackson.core.type.TypeReference;
import org.hamcrest.Matchers;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
@ -393,7 +394,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
user.setUsername("updatedUsername");
user = updateAndGet(user);
assertEquals("updatedusername", user.getUsername());
assertThat("updatedusername", Matchers.equalToIgnoringCase(user.getUsername()));
realmRep.setEditUsernameAllowed(false);

View file

@ -145,6 +145,7 @@ import org.keycloak.testsuite.auth.page.login.Login;
import org.keycloak.testsuite.auth.page.login.SAMLIDPInitiatedLogin;
import org.keycloak.testsuite.auth.page.login.SAMLPostLoginTenant1;
import org.keycloak.testsuite.auth.page.login.SAMLPostLoginTenant2;
import org.keycloak.testsuite.model.StoreProvider;
import org.keycloak.testsuite.page.AbstractPage;
import org.keycloak.testsuite.saml.AbstractSamlTest;
import org.keycloak.testsuite.updaters.ClientAttributeUpdater;
@ -803,7 +804,8 @@ public class SAMLServletAdapterTest extends AbstractSAMLServletAdapterTest {
UserRepresentation topGroupUser = createUserRepresentation("topGroupUser", "top@redhat.com", "", "", true);
setPasswordFor(topGroupUser, PASSWORD);
assertSuccessfulLogin(salesPostServletPage, topGroupUser, testRealmSAMLPostLoginPage, "principal=topgroupuser");
String expectedString = StoreProvider.getCurrentProvider().isMapStore() ? "principal=topGroupUser" : "principal=topgroupuser";
assertSuccessfulLogin(salesPostServletPage, topGroupUser, testRealmSAMLPostLoginPage, expectedString);
salesPostServletPage.logout();
checkLoggedOut(salesPostServletPage, testRealmSAMLPostLoginPage);

View file

@ -35,6 +35,8 @@ import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.keycloak.models.Constants.defaultClients;
import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.admin.client.resource.ClientResource;
@ -455,7 +457,7 @@ public class ClientTest extends AbstractAdminTest {
getCleanup().addClientUuid(id);
response.close();
UserRepresentation userRep = realm.clients().get(id).getServiceAccountUser();
assertEquals("service-account-serviceclient", userRep.getUsername());
MatcherAssert.assertThat("service-account-serviceclient", Matchers.equalToIgnoringCase(userRep.getUsername()));
// KEYCLOAK-11197 service accounts are no longer created with a placeholder e-mail.
assertNull(userRep.getEmail());
}

View file

@ -16,6 +16,7 @@
*/
package org.keycloak.testsuite.admin;
import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.junit.Assert;
import org.junit.Test;
@ -628,7 +629,7 @@ public class FineGrainAdminUnitTest extends AbstractKeycloakTest {
// Should only return the list of users that belong to "top" group
List<UserRepresentation> queryUsers = realmClient.realm(TEST).users().list();
Assert.assertEquals(queryUsers.size(), 1);
Assert.assertEquals("groupmember", queryUsers.get(0).getUsername());
MatcherAssert.assertThat("groupmember", Matchers.equalToIgnoringCase(queryUsers.get(0).getUsername()));
for (UserRepresentation user : queryUsers) {
System.out.println(user.getUsername());
}

View file

@ -17,6 +17,7 @@
package org.keycloak.testsuite.admin;
import org.junit.Assume;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.admin.client.Keycloak;
@ -26,6 +27,7 @@ import org.keycloak.common.Profile;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.ManagementPermissionRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.idm.authorization.DecisionStrategy;
@ -33,7 +35,9 @@ import org.keycloak.representations.idm.authorization.PolicyRepresentation;
import org.keycloak.representations.idm.authorization.ScopePermissionRepresentation;
import org.keycloak.representations.idm.authorization.UserPolicyRepresentation;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.AdminClientUtil;
import org.keycloak.testsuite.util.RealmBuilder;
import java.io.IOException;
import java.security.KeyManagementException;
@ -42,13 +46,17 @@ import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.hasSize;
import static org.keycloak.models.map.user.MapUserProviderFactory.REALM_ATTR_USERNAME_CASE_SENSITIVE;
public class UsersTest extends AbstractAdminTest {
@ -60,6 +68,57 @@ public class UsersTest extends AbstractAdminTest {
}
}
@Test
public void searchUserDefaultSettings() throws Exception {
createUser(REALM_NAME, "User", "password", "firstName", "lastName", "user@example.com");
assertCaseInsensitiveSearch();
}
@Test
public void searchUserCaseSensitiveFirst() throws Exception {
Assume.assumeFalse(isJpaRealmProvider());
Map<String, String> attributes = new HashMap<>();
attributes.put(REALM_ATTR_USERNAME_CASE_SENSITIVE, "true");
try (AutoCloseable c = new RealmAttributeUpdater(adminClient.realm(REALM_NAME))
.updateWith(r -> r.setAttributes(attributes))
.update()) {
createUser(REALM_NAME, "User", "password", "firstName", "lastName", "user@example.com");
assertCaseSensitiveSearch();
RealmRepresentation realmRep = adminClient.realm(REALM_NAME).toRepresentation();
RealmBuilder.edit(realmRep)
.attribute(REALM_ATTR_USERNAME_CASE_SENSITIVE, "false");
realm.update(realmRep);
assertCaseInsensitiveSearch();
}
}
@Test
public void searchUserCaseInSensitiveFirst() throws Exception {
Assume.assumeFalse(isJpaRealmProvider());
Map<String, String> attributes = new HashMap<>();
attributes.put(REALM_ATTR_USERNAME_CASE_SENSITIVE, "false");
try (AutoCloseable c = new RealmAttributeUpdater(adminClient.realm(REALM_NAME))
.updateWith(r -> r.setAttributes(attributes))
.update()) {
createUser(REALM_NAME, "User", "password", "firstName", "lastName", "user@example.com");
assertCaseInsensitiveSearch();
RealmRepresentation realmRep = adminClient.realm(REALM_NAME).toRepresentation();
RealmBuilder.edit(realmRep)
.attribute(REALM_ATTR_USERNAME_CASE_SENSITIVE, "true");
realm.update(realmRep);
assertCaseSensitiveSearch();
}
}
/**
* https://issues.redhat.com/browse/KEYCLOAK-15146
*/
@ -426,4 +485,32 @@ public class UsersTest extends AbstractAdminTest {
return grp;
}
private void assertCaseInsensitiveSearch() {
// not-exact case-insensitive search
assertThat(realm.users().search("user"), hasSize(1));
assertThat(realm.users().search("User"), hasSize(1));
assertThat(realm.users().search("USER"), hasSize(1));
assertThat(realm.users().search("Use"), hasSize(1));
// exact case-insensitive search
assertThat(realm.users().search("user", true), hasSize(1));
assertThat(realm.users().search("User", true), hasSize(1));
assertThat(realm.users().search("USER", true), hasSize(1));
assertThat(realm.users().search("Use", true), hasSize(0));
}
private void assertCaseSensitiveSearch() {
// not-exact case-sensitive search
assertThat(realm.users().search("user"), hasSize(0));
assertThat(realm.users().search("User"), hasSize(1));
assertThat(realm.users().search("USER"), hasSize(0));
assertThat(realm.users().search("Use"), hasSize(1));
// exact case-sensitive search
assertThat(realm.users().search("user", true), hasSize(0));
assertThat(realm.users().search("User", true), hasSize(1));
assertThat(realm.users().search("USER", true), hasSize(0));
assertThat(realm.users().search("Use", true), hasSize(0));
}
}

View file

@ -207,7 +207,7 @@ public class AccountLinkTest extends AbstractKeycloakTest {
String memProviderId = ApiUtil.getCreatedId(resp);
// Create federated user
String username = "fedUser1";
String username = "fed-user1";
UserRepresentation userRepresentation = new UserRepresentation();
userRepresentation.setUsername(username);
userRepresentation.setEmail("feduser1@mail.com");

View file

@ -371,7 +371,7 @@ public class LDAPProvidersIntegrationTest extends AbstractLDAPTest {
// KEYCLOAK-12340
@Test
public void ldapPasswordChangeWithAdminEndpointAndRequiredAction() throws Exception {
String username = "adminEndpointReqAct";
String username = "admin-endpoint-req-act";
String email = username + "@email.cz";
// Register new LDAP user with password, logout user
@ -505,13 +505,13 @@ public class LDAPProvidersIntegrationTest extends AbstractLDAPTest {
loginPage.clickRegister();
registerPage.assertCurrent();
registerPage.register("firstName", "lastName", "email2@check.cz", "registerUserSuccess2", "Password1", "Password1");
registerPage.register("firstName", "lastName", "email2@check.cz", "register-user-success2", "Password1", "Password1");
Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType());
UserRepresentation user = ApiUtil.findUserByUsername(testRealm(),"registerUserSuccess2");
UserRepresentation user = ApiUtil.findUserByUsername(testRealm(),"register-user-success2");
Assert.assertNotNull(user);
assertFederatedUserLink(user);
Assert.assertEquals("registerusersuccess2", user.getUsername());
Assert.assertEquals("register-user-success2", user.getUsername());
Assert.assertEquals("firstName", user.getFirstName());
Assert.assertEquals("lastName", user.getLastName());
Assert.assertTrue(user.isEnabled());
@ -812,17 +812,17 @@ public class LDAPProvidersIntegrationTest extends AbstractLDAPTest {
@Test
public void testRemoveFederatedUser() {
UserRepresentation user = ApiUtil.findUserByUsername(testRealm(), "registerusersuccess2");
UserRepresentation user = ApiUtil.findUserByUsername(testRealm(), "register-user-success2");
// Case when this test was executed "alone" (User "registerusersuccess2" is registered inside registerUserLdapSuccess)
if (user == null) {
registerUserLdapSuccess();
user = ApiUtil.findUserByUsername(testRealm(), "registerusersuccess2");
user = ApiUtil.findUserByUsername(testRealm(), "register-user-success2");
}
assertFederatedUserLink(user);
testRealm().users().get(user.getId()).remove();
user = ApiUtil.findUserByUsername(testRealm(), "registerusersuccess2");
user = ApiUtil.findUserByUsername(testRealm(), "register-user-success2");
Assert.assertNull(user);
}

View file

@ -361,7 +361,7 @@ public class UserStorageTest extends AbstractAuthTest {
testRealmAccountPage.navigateTo();
loginPage.clickRegister();
registerPage.register("firstName", "lastName", "email@mail.com", "verifyEmail", "password", "password");
registerPage.register("firstName", "lastName", "email@mail.com", "verify-email", "password", "password");
verifyEmailPage.assertCurrent();

View file

@ -16,6 +16,7 @@
*/
package org.keycloak.testsuite.forms;
import org.hamcrest.Matchers;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Assert;
import org.junit.Assume;
@ -696,7 +697,7 @@ public class RegisterTest extends AbstractTestRealmKeycloakTest {
assertThat(user, notNullValue());
if (username != null) {
assertThat(username.toLowerCase(), is(user.getUsername()));
assertThat(username, Matchers.equalToIgnoringCase(user.getUsername()));
}
assertThat(email.toLowerCase(), is(user.getEmail()));
assertThat(firstName, is(user.getFirstName()));

View file

@ -31,6 +31,7 @@ import java.util.Collections;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.hamcrest.Matchers;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
@ -613,7 +614,7 @@ public class RegisterWithUserProfileTest extends RegisterTest {
// test that timestamp is current with 10s tollerance
Assert.assertTrue((System.currentTimeMillis() - user.getCreatedTimestamp()) < 10000);
// test user info is set from form
assertEquals(username.toLowerCase(), user.getUsername());
assertThat(username, Matchers.equalToIgnoringCase(user.getUsername()));
assertEquals(email.toLowerCase(), user.getEmail());
assertEquals(firstName, user.getFirstName());

View file

@ -293,7 +293,7 @@ public abstract class AbstractX509AuthenticationTest extends AbstractTestRealmKe
UserRepresentation user = UserBuilder.create()
.id(KeycloakModelUtils.generateId())
.username("Keycloak")
.username("keycloak")
.email("localhost@localhost")
.enabled(true)
.password("password")

View file

@ -20,11 +20,13 @@ import org.keycloak.component.ComponentModel;
import org.keycloak.models.Constants;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelException;
import org.keycloak.models.ModelDuplicateException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RealmProvider;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserProvider;
import org.keycloak.models.map.realm.MapRealmProviderFactory;
import org.keycloak.models.map.user.MapUserProviderFactory;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.UserStorageProviderFactory;
import org.keycloak.storage.UserStorageProviderModel;
@ -36,21 +38,27 @@ import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import org.hamcrest.Matchers;
import org.junit.Test;
import javax.naming.NamingException;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.junit.Assume.assumeThat;
import org.keycloak.models.jpa.JpaUserProvider;
import static org.keycloak.models.map.user.MapUserProviderFactory.REALM_ATTR_USERNAME_CASE_SENSITIVE;
/**
*
@ -66,6 +74,8 @@ public class UserModelTest extends KeycloakModelTest {
private static final int DELETED_USER_COUNT = LAST_DELETED_USER_INDEX - FIRST_DELETED_USER_INDEX;
private String realmId;
private String realm1Id;
private String realm2Id;
private final List<String> groupIds = new ArrayList<>(NUM_GROUPS);
private String userFederationId;
@ -83,6 +93,8 @@ public class UserModelTest extends KeycloakModelTest {
@Override
public void cleanEnvironment(KeycloakSession s) {
s.realms().removeRealm(realmId);
if (realm1Id != null) s.realms().removeRealm(realm1Id);
if (realm2Id != null) s.realms().removeRealm(realm2Id);
}
@Override
@ -115,6 +127,53 @@ public class UserModelTest extends KeycloakModelTest {
return null;
}
@Test
@RequireProvider(value = UserProvider.class, only = {MapUserProviderFactory.PROVIDER_ID})
@RequireProvider(value = RealmProvider.class, only = {MapRealmProviderFactory.PROVIDER_ID})
public void testCaseSensitivityGetUserByUsername() {
realm1Id = inComittedTransaction((Function<KeycloakSession, String>) session -> {
RealmModel realm = session.realms().createRealm("realm1");
realm.setDefaultRole(session.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName()));
realm.setAttribute(REALM_ATTR_USERNAME_CASE_SENSITIVE, true);
return realm.getId();
});
withRealm(realm1Id, (session, realm) -> {
UserModel user1 = session.users().addUser(realm, "user");
UserModel user2 = session.users().addUser(realm, "USER");
assertThat(user1, not(nullValue()));
assertThat(user2, not(nullValue()));
assertThat(user1.getUsername(), equalTo("user"));
assertThat(user2.getUsername(), equalTo("USER"));
return null;
});
realm2Id = inComittedTransaction((Function<KeycloakSession, String>) session -> {
RealmModel realm = session.realms().createRealm("realm2");
realm.setDefaultRole(session.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName()));
realm.setAttribute(REALM_ATTR_USERNAME_CASE_SENSITIVE, false);
return realm.getId();
});
withRealm(realm2Id, (session, realm) -> {
UserModel user1 = session.users().addUser(realm, "user");
assertThat(user1, not(nullValue()));
try {
session.users().addUser(realm, "USER");
} catch (ModelDuplicateException e) {
return null; // expected
}
fail("ModelDuplicateException expected");
return null;
});
}
@Test
public void testAddRemoveUser() {
inRolledBackTransaction(1, this::addRemoveUser);