Respect the username value format when processing federated users

Closes #31240

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2024-07-25 11:54:56 -03:00 committed by Marek Posolda
parent b20123dcdc
commit 87c279d645
9 changed files with 120 additions and 15 deletions

View file

@ -459,7 +459,8 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
Stream<String> valuesStream = Optional.ofNullable(values).orElse(EMPTY_VALUE).stream().filter(Objects::nonNull); Stream<String> valuesStream = Optional.ofNullable(values).orElse(EMPTY_VALUE).stream().filter(Objects::nonNull);
if (UserModel.USERNAME.equals(name) || UserModel.EMAIL.equals(name)) { // do not normalize the username if a federated user because we need to respect the format from the external identity store
if ((UserModel.USERNAME.equals(name) && isLocalUser()) || UserModel.EMAIL.equals(name)) {
valuesStream = valuesStream.map(KeycloakModelUtils::toLowerCaseSafe); valuesStream = valuesStream.map(KeycloakModelUtils::toLowerCaseSafe);
} }
@ -569,4 +570,8 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
} }
}; };
} }
private boolean isLocalUser() {
return user == null || user.isLocal();
}
} }

View file

@ -17,7 +17,11 @@
package org.keycloak.models; package org.keycloak.models;
import static org.keycloak.utils.StringUtil.isBlank;
import org.keycloak.provider.ProviderEvent; import org.keycloak.provider.ProviderEvent;
import org.keycloak.storage.StorageId;
import org.keycloak.utils.StringUtil;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
@ -207,6 +211,16 @@ public interface UserModel extends RoleMapperModel {
String getServiceAccountClientLink(); String getServiceAccountClientLink();
void setServiceAccountClientLink(String clientInternalId); void setServiceAccountClientLink(String clientInternalId);
/**
* Indicates if this {@link UserModel} maps to a local account or an account
* federated from an external user storage.
*
* @return {@code true} if a local account. Otherwise, {@code false}.
*/
default boolean isLocal() {
return isBlank(getFederationLink()) && StorageId.isLocalStorage(getId());
}
/** /**
* Instance of a user credential manager to validate and update the credentials of this user. * Instance of a user credential manager to validate and update the credentials of this user.
*/ */

View file

@ -22,6 +22,7 @@ import static org.keycloak.validate.BuiltinValidators.notBlankValidator;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
@ -34,7 +35,7 @@ import org.keycloak.validate.ValidatorConfig;
/** /**
* A validator that fails when the attribute is marked as read only and its value has changed. * A validator that fails when the attribute is marked as read only and its value has changed.
* *
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a> * @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/ */
public class ImmutableAttributeValidator implements SimpleValidator { public class ImmutableAttributeValidator implements SimpleValidator {
@ -58,7 +59,14 @@ public class ImmutableAttributeValidator implements SimpleValidator {
return context; return context;
} }
List<String> currentValue = user.getAttributeStream(inputHint).filter(Objects::nonNull).collect(Collectors.toList()); Stream<String> rawValues = user.getAttributeStream(inputHint).filter(Objects::nonNull);
// force usernames to lower-case to avoid validation errors if the external storage is using a different format
if (user.isLocal() && UserModel.USERNAME.equals(inputHint)) {
rawValues = rawValues.map(String::toLowerCase);
}
List<String> currentValue = rawValues.collect(Collectors.toList());
List<String> values = (List<String>) input; List<String> values = (List<String>) input;
if (!collectionEquals(currentValue, values) && isReadOnly(attributeContext)) { if (!collectionEquals(currentValue, values) && isReadOnly(attributeContext)) {

View file

@ -61,7 +61,7 @@ public class UserPropertyFileStorage implements UserLookupProvider, UserStorageP
protected ComponentModel model; protected ComponentModel model;
protected KeycloakSession session; protected KeycloakSession session;
protected boolean federatedStorageEnabled; protected boolean federatedStorageEnabled;
public static Map<String, List<UserPropertyFileStorageCall>> storageCalls = new HashMap<>(); public static Map<String, List<UserPropertyFileStorageCall>> storageCalls = new HashMap<>();
public static class UserPropertyFileStorageCall implements Serializable { public static class UserPropertyFileStorageCall implements Serializable {
@ -87,7 +87,7 @@ public class UserPropertyFileStorage implements UserLookupProvider, UserStorageP
return max; return max;
} }
} }
public UserPropertyFileStorage(KeycloakSession session, ComponentModel model, Properties userPasswords) { public UserPropertyFileStorage(KeycloakSession session, ComponentModel model, Properties userPasswords) {
this.session = session; this.session = session;
this.model = model; this.model = model;
@ -109,14 +109,17 @@ public class UserPropertyFileStorage implements UserLookupProvider, UserStorageP
@Override @Override
public int getUsersCount(RealmModel realm, Map<String, String> params) { public int getUsersCount(RealmModel realm, Map<String, String> params) {
addCall(COUNT_SEARCH_METHOD); addCall(COUNT_SEARCH_METHOD);
return (int) searchForUser(realm, params.get(UserModel.SEARCH), null, null, username -> username.contains(params.get(UserModel.SEARCH))).count(); return (int) searchForUser(realm, params.get(UserModel.SEARCH), null, null, username -> username.contains(params.get(UserModel.SEARCH))).count();
} }
@Override @Override
public UserModel getUserById(RealmModel realm, String id) { public UserModel getUserById(RealmModel realm, String id) {
StorageId storageId = new StorageId(id); StorageId storageId = new StorageId(id);
final String username = storageId.getExternalId(); String username = storageId.getExternalId();
if ("uppercase".equalsIgnoreCase(username)) {
username = username.toLowerCase();
}
if (!userPasswords.containsKey(username)) return null; if (!userPasswords.containsKey(username)) return null;
return createUser(realm, username); return createUser(realm, username);
@ -127,6 +130,9 @@ public class UserPropertyFileStorage implements UserLookupProvider, UserStorageP
return new AbstractUserAdapterFederatedStorage.Streams(session, realm, model) { return new AbstractUserAdapterFederatedStorage.Streams(session, realm, model) {
@Override @Override
public String getUsername() { public String getUsername() {
if ("uppercase".equalsIgnoreCase(username)) {
return username.toUpperCase();
}
return username; return username;
} }
@ -139,6 +145,9 @@ public class UserPropertyFileStorage implements UserLookupProvider, UserStorageP
return new AbstractUserAdapter.Streams(session, realm, model) { return new AbstractUserAdapter.Streams(session, realm, model) {
@Override @Override
public String getUsername() { public String getUsername() {
if ("uppercase".equalsIgnoreCase(username)) {
return username.toUpperCase();
}
return username; return username;
} }
@ -191,7 +200,11 @@ public class UserPropertyFileStorage implements UserLookupProvider, UserStorageP
public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) { public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
if (!(input instanceof UserCredentialModel)) return false; if (!(input instanceof UserCredentialModel)) return false;
if (input.getType().equals(PasswordCredentialModel.TYPE)) { if (input.getType().equals(PasswordCredentialModel.TYPE)) {
String pw = (String)userPasswords.get(user.getUsername()); String username = user.getUsername();
if ("uppercase".equalsIgnoreCase(username)) {
username = user.getUsername().toLowerCase();
}
String pw = (String)userPasswords.get(username);
return pw != null && pw.equals(input.getChallengeResponse()); return pw != null && pw.equals(input.getChallengeResponse());
} else { } else {
return false; return false;

View file

@ -93,6 +93,12 @@ public class LDAPTestUtils {
return addLDAPUser(ldapProvider, realm, username, firstName, lastName, email, street, new MultivaluedHashMap<>(), postalCode); return addLDAPUser(ldapProvider, realm, username, firstName, lastName, email, street, new MultivaluedHashMap<>(), postalCode);
} }
public static LDAPObject addLDAPUser(LDAPStorageProvider ldapProvider, RealmModel realm, final String username,
final String firstName, final String lastName, final String email,
final MultivaluedHashMap<String, String> otherAttrs) {
return addLDAPUser(ldapProvider, realm, username, firstName, lastName, email, null, otherAttrs);
}
public static LDAPObject addLDAPUser(LDAPStorageProvider ldapProvider, RealmModel realm, final String username, public static LDAPObject addLDAPUser(LDAPStorageProvider ldapProvider, RealmModel realm, final String username,
final String firstName, final String lastName, final String email, final String street, final String firstName, final String lastName, final String email, final String street,
final MultivaluedHashMap<String, String> otherAttrs, final String... postalCode) { final MultivaluedHashMap<String, String> otherAttrs, final String... postalCode) {
@ -355,7 +361,7 @@ public class LDAPTestUtils {
} }
} }
} }
public static void removeLDAPUserByUsername(LDAPStorageProvider ldapProvider, RealmModel realm, LDAPConfig config, String username) { public static void removeLDAPUserByUsername(LDAPStorageProvider ldapProvider, RealmModel realm, LDAPConfig config, String username) {
LDAPIdentityStore ldapStore = ldapProvider.getLdapIdentityStore(); LDAPIdentityStore ldapStore = ldapProvider.getLdapIdentityStore();
try (LDAPQuery ldapQuery = LDAPUtils.createQueryForUserSearch(ldapProvider, realm)) { try (LDAPQuery ldapQuery = LDAPUtils.createQueryForUserSearch(ldapProvider, realm)) {
@ -369,7 +375,7 @@ public class LDAPTestUtils {
} }
} }
} }
public static void removeAllLDAPRoles(KeycloakSession session, RealmModel appRealm, ComponentModel ldapModel, String mapperName) { public static void removeAllLDAPRoles(KeycloakSession session, RealmModel appRealm, ComponentModel ldapModel, String mapperName) {
ComponentModel mapperModel = getSubcomponentByName(appRealm, ldapModel, mapperName); ComponentModel mapperModel = getSubcomponentByName(appRealm, ldapModel, mapperName);
LDAPStorageProvider ldapProvider = LDAPTestUtils.getLdapProvider(session, ldapModel); LDAPStorageProvider ldapProvider = LDAPTestUtils.getLdapProvider(session, ldapModel);

View file

@ -20,6 +20,7 @@
package org.keycloak.testsuite.federation.ldap; package org.keycloak.testsuite.federation.ldap;
import java.io.IOException; import java.io.IOException;
import java.util.List;
import java.util.Set; import java.util.Set;
import org.jboss.arquillian.graphene.page.Page; import org.jboss.arquillian.graphene.page.Page;
@ -29,6 +30,7 @@ import org.junit.FixMethodOrder;
import org.junit.Test; import org.junit.Test;
import org.junit.runners.MethodSorters; import org.junit.runners.MethodSorters;
import org.keycloak.admin.client.resource.UserResource; import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.component.ComponentModel; import org.keycloak.component.ComponentModel;
import org.keycloak.component.PrioritizedComponentModel; import org.keycloak.component.PrioritizedComponentModel;
import org.keycloak.models.LDAPConstants; import org.keycloak.models.LDAPConstants;
@ -43,6 +45,8 @@ import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.UserStorageProviderModel; import org.keycloak.storage.UserStorageProviderModel;
import org.keycloak.storage.ldap.LDAPStorageProvider; import org.keycloak.storage.ldap.LDAPStorageProvider;
import org.keycloak.storage.ldap.idm.model.LDAPObject; import org.keycloak.storage.ldap.idm.model.LDAPObject;
import org.keycloak.storage.ldap.mappers.UserAttributeLDAPStorageMapper;
import org.keycloak.storage.ldap.mappers.UserAttributeLDAPStorageMapperFactory;
import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.pages.LoginUpdateProfilePage; import org.keycloak.testsuite.pages.LoginUpdateProfilePage;
import org.keycloak.testsuite.util.LDAPRule; import org.keycloak.testsuite.util.LDAPRule;
@ -298,6 +302,51 @@ public class LDAPUserProfileTest extends AbstractLDAPTest {
} }
} }
@Test
public void testUsernameRespectFormatFromExternalStore() {
String upperCaseUsername = "JOHNKEYCLOAK3";
testingClient.server().run(session -> {
LDAPTestContext ctx = LDAPTestContext.init(session, "test-ldap");
RealmModel appRealm = ctx.getRealm();
ctx.getLdapModel().getConfig().put(LDAPConstants.USERNAME_LDAP_ATTRIBUTE, List.of(LDAPConstants.GIVENNAME));
ctx.getLdapModel().getConfig().put(LDAPConstants.RDN_LDAP_ATTRIBUTE, List.of(LDAPConstants.GIVENNAME));
ComponentModel ldapComponentMapper = LDAPTestUtils.addUserAttributeMapper(appRealm, ctx.getLdapModel(), "givename-mapper", "username", LDAPConstants.GIVENNAME);
ldapComponentMapper.put(UserAttributeLDAPStorageMapper.ALWAYS_READ_VALUE_FROM_LDAP, true);
appRealm.updateComponent(ldapComponentMapper);
appRealm.removeComponent(appRealm.getComponentsStream(ctx.getLdapModel().getId())
.filter(mapper -> UserAttributeLDAPStorageMapperFactory.PROVIDER_ID.equals(mapper.getProviderId()))
.filter((mapper) -> mapper.getName().equals(UserModel.USERNAME))
.findAny().orElse(null));
appRealm.updateComponent(ctx.getLdapModel());
MultivaluedHashMap<String, String> otherAttrs = new MultivaluedHashMap<>();
otherAttrs.put(LDAPConstants.GIVENNAME, List.of(upperCaseUsername));
LDAPObject john3 = LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, upperCaseUsername, "John", "Doe", "john3@email.org", otherAttrs);
LDAPTestUtils.updateLDAPPassword(ctx.getLdapProvider(), john3, "Password1");
});
UserResource johnResource = ApiUtil.findUserByUsernameId(testRealm(), upperCaseUsername);
UserRepresentation john = johnResource.toRepresentation(true);
Assert.assertEquals(upperCaseUsername, john.getUsername());
johnResource = ApiUtil.findUserByUsernameId(testRealm(), upperCaseUsername.toLowerCase());
john = johnResource.toRepresentation(true);
Assert.assertEquals(upperCaseUsername, john.getUsername());
loginPage.open();
loginPage.login(upperCaseUsername, "Password1");
appPage.assertCurrent();
testRealm().users().get(john.getId()).logout();
loginPage.open();
loginPage.login(upperCaseUsername.toLowerCase(), "Password1");
appPage.assertCurrent();
}
private void setLDAPReadOnly() { private void setLDAPReadOnly() {
testingClient.server().run(session -> { testingClient.server().run(session -> {
LDAPTestContext ctx = LDAPTestContext.init(session, "test-ldap"); LDAPTestContext ctx = LDAPTestContext.init(session, "test-ldap");

View file

@ -85,6 +85,7 @@ import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
@ -442,7 +443,7 @@ public class UserStorageTest extends AbstractAuthTest {
public void testQuery() { public void testQuery() {
Set<UserRepresentation> queried = new HashSet<>(); Set<UserRepresentation> queried = new HashSet<>();
int first = 0; int first = 0;
while (queried.size() < 8) { while (queried.size() < 10) {
List<UserRepresentation> results = testRealmResource().users().search("", first, 3); List<UserRepresentation> results = testRealmResource().users().search("", first, 3);
log.debugf("first=%s, results: %s", first, results.size()); log.debugf("first=%s, results: %s", first, results.size());
if (results.isEmpty()) { if (results.isEmpty()) {
@ -456,7 +457,7 @@ public class UserStorageTest extends AbstractAuthTest {
usernames.add(user.getUsername()); usernames.add(user.getUsername());
log.info(user.getUsername()); log.info(user.getUsername());
} }
Assert.assertEquals(9, queried.size()); Assert.assertEquals(10, queried.size());
Assert.assertTrue(usernames.contains("thor")); Assert.assertTrue(usernames.contains("thor"));
Assert.assertTrue(usernames.contains("zeus")); Assert.assertTrue(usernames.contains("zeus"));
Assert.assertTrue(usernames.contains("apollo")); Assert.assertTrue(usernames.contains("apollo"));
@ -466,6 +467,7 @@ public class UserStorageTest extends AbstractAuthTest {
Assert.assertTrue(usernames.contains("rob")); Assert.assertTrue(usernames.contains("rob"));
Assert.assertTrue(usernames.contains("jules")); Assert.assertTrue(usernames.contains("jules"));
Assert.assertTrue(usernames.contains("danny")); Assert.assertTrue(usernames.contains("danny"));
Assert.assertTrue(usernames.contains("UPPERCASE"));
// test searchForUser // test searchForUser
List<UserRepresentation> users = testRealmResource().users().search("tbrady", 0, -1); List<UserRepresentation> users = testRealmResource().users().search("tbrady", 0, -1);
@ -544,7 +546,7 @@ public class UserStorageTest extends AbstractAuthTest {
UserModel userModel = session.users().getUserByUsername(realm, "thor"); UserModel userModel = session.users().getUserByUsername(realm, "thor");
userModel.setSingleAttribute("weapon", longValue); userModel.setSingleAttribute("weapon", longValue);
assertThat(session.users().searchForUserByUserAttributeStream(realm, "weapon", longValue).map(UserModel::getUsername).collect(Collectors.toList()), assertThat(session.users().searchForUserByUserAttributeStream(realm, "weapon", longValue).map(UserModel::getUsername).collect(Collectors.toList()),
containsInAnyOrder("thor")); containsInAnyOrder("thor"));
// searching here is always case sensitive // searching here is always case sensitive
@ -779,7 +781,7 @@ public class UserStorageTest extends AbstractAuthTest {
}); });
setTimeOffset(1/2 * 60 * 60); // 1/2 hour in future setTimeOffset(1/2 * 60 * 60); // 1/2 hour in future
testingClient.server().run(session -> { testingClient.server().run(session -> {
RealmModel realm = session.realms().getRealmByName("test"); RealmModel realm = session.realms().getRealmByName("test");
UserModel user = session.users().getUserByUsername(realm, "thor"); UserModel user = session.users().getUserByUsername(realm, "thor");
@ -1158,6 +1160,13 @@ public class UserStorageTest extends AbstractAuthTest {
}); });
} }
@Test
public void testRespectUsernameFormatFromStorage() {
loginSuccessAndLogout("uppercase", "uppercase");
UserResource user = ApiUtil.findUserByUsernameId(testRealmResource(), "uppercase");
UserRepresentation rep = user.toRepresentation();
assertEquals("uppercase".toUpperCase(), rep.getUsername());
}
private void assertOrder(List<CredentialModel> creds, String... expectedIds) { private void assertOrder(List<CredentialModel> creds, String... expectedIds) {
org.keycloak.testsuite.Assert.assertEquals(expectedIds.length, creds.size()); org.keycloak.testsuite.Assert.assertEquals(expectedIds.length, creds.size());

View file

@ -2,3 +2,4 @@ tbrady=goat
rob=pw rob=pw
jules=pw jules=pw
danny=pw danny=pw
uppercase=uppercase

View file

@ -1,4 +1,4 @@
tbrady=goat tbrady=goat
rob=pw rob=pw
jules=pw jules=pw
danny=pw danny=pw