diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProvider.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProvider.java index 52a44f00b32..19273ba37e2 100755 --- a/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProvider.java +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProvider.java @@ -667,15 +667,8 @@ public class LDAPStorageProvider implements UserStorageProvider, private void doImportUser(final RealmModel realm, final UserModel user, final LDAPObject ldapUser) { user.setEnabled(true); - realm.getComponentsStream(model.getId(), LDAPStorageMapper.class.getName()) - .sorted(ldapMappersComparator.sortDesc()) - .forEachOrdered(mapperModel -> { - if (logger.isTraceEnabled()) { - logger.tracef("Using mapper %s during import user from LDAP", mapperModel); - } - LDAPStorageMapper ldapMapper = mapperManager.getMapper(mapperModel); - ldapMapper.onImportUserFromLDAP(ldapUser, user, realm, true); - }); + + importUserAttributes(realm, user, ldapUser); String userDN = ldapUser.getDn().toString(); if (model.isImportEnabled()) user.setFederationLink(model.getId()); @@ -784,6 +777,7 @@ public class LDAPStorageProvider implements UserStorageProvider, LDAPUtils.checkUuid(ldapUser, ldapIdentityStore.getConfig()); // If email attribute mapper is set to "Always Read Value From LDAP" the user may be in Keycloak DB with an old email address if (ldapUser.getUuid().equals(user.getFirstAttribute(LDAPConstants.LDAP_ID))) { + importUserAttributes(realm, user, ldapUser); return proxy(realm, user, ldapUser, false); } throw new ModelDuplicateException("User with username '" + ldapUsername + "' already exists in Keycloak. It conflicts with LDAP user with email '" + email + "'"); @@ -1240,4 +1234,16 @@ public class LDAPStorageProvider implements UserStorageProvider, } return LDAPUtils.generalizedTimeToDate(value).getTime(); } + + private void importUserAttributes(RealmModel realm, UserModel user, LDAPObject ldapUser) { + realm.getComponentsStream(model.getId(), LDAPStorageMapper.class.getName()) + .sorted(ldapMappersComparator.sortDesc()) + .forEachOrdered(mapperModel -> { + if (logger.isTraceEnabled()) { + logger.tracef("Using mapper %s during import user from LDAP", mapperModel); + } + LDAPStorageMapper ldapMapper = mapperManager.getMapper(mapperModel); + ldapMapper.onImportUserFromLDAP(ldapUser, user, realm, true); + }); + } } diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/UserAttributeLDAPStorageMapper.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/UserAttributeLDAPStorageMapper.java index a68d3a75226..6eda0797c4b 100644 --- a/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/UserAttributeLDAPStorageMapper.java +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/UserAttributeLDAPStorageMapper.java @@ -288,6 +288,22 @@ public class UserAttributeLDAPStorageMapper extends AbstractLDAPStorageMapper { super.setEmailVerified(verified); } + @Override + public String getUsername() { + if (UserModel.USERNAME.equals(userModelAttrName)) { + return ldapUser.getAttributeAsString(ldapAttrName); + } + return super.getUsername(); + } + + @Override + public String getEmail() { + if (UserModel.EMAIL.equals(userModelAttrName)) { + return ldapUser.getAttributeAsString(ldapAttrName); + } + return super.getEmail(); + } + protected boolean setLDAPAttribute(String modelAttrName, Object value) { if (modelAttrName.equalsIgnoreCase(userModelAttrName)) { if (UserAttributeLDAPStorageMapper.logger.isTraceEnabled()) { @@ -506,11 +522,18 @@ public class UserAttributeLDAPStorageMapper extends AbstractLDAPStorageMapper { userModelProperty.setValue(user, null); } else { Class clazz = userModelProperty.getJavaClass(); + Object currentValue = userModelProperty.getValue(user); if (String.class.equals(clazz)) { + if (ldapAttrValue.equals(currentValue)) { + return; + } userModelProperty.setValue(user, ldapAttrValue); } else if (Boolean.class.equals(clazz) || boolean.class.equals(clazz)) { Boolean boolVal = Boolean.valueOf(ldapAttrValue); + if (boolVal.equals(currentValue)) { + return; + } userModelProperty.setValue(user, boolVal); } else { logger.warnf("Don't know how to set the property '%s' on user '%s' . Value of LDAP attribute is '%s' ", userModelProperty.getName(), user.getUsername(), ldapAttrValue.toString()); diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/CacheManager.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/CacheManager.java index 3ce8cafd10d..a0fa8d3d7f9 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/CacheManager.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/CacheManager.java @@ -200,7 +200,7 @@ public abstract class CacheManager { private void put(String id, Revisioned object, long lifespan) { if (lifespan < 0) { cache.putForExternalRead(id, object); - } else { + } else if (lifespan > 0) { cache.putForExternalRead(id, object, lifespan, TimeUnit.MILLISECONDS); } } diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmCacheSession.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmCacheSession.java index b38eba10222..a1d5d1f0ec9 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmCacheSession.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmCacheSession.java @@ -1261,8 +1261,6 @@ public class RealmCacheSession implements CacheRealmProvider { } ClientStorageProviderModel model = new ClientStorageProviderModel(component); - // although we do set a timeout, Infinispan has no guarantees when the user will be evicted - // its also hard to test stuff if (model.shouldInvalidate(cached)) { registerClientInvalidation(cached.getId(), cached.getClientId(), realm.getId()); return getClientDelegate().getClientById(realm, cached.getId()); diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java index 0b8654492b5..b83215b2a39 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java @@ -17,6 +17,7 @@ package org.keycloak.models.cache.infinispan; +import static java.util.Optional.ofNullable; import static org.keycloak.organization.utils.Organizations.isReadOnlyOrganizationMember; import org.jboss.logging.Logger; @@ -254,9 +255,12 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC } @Override - public UserModel getUserByUsername(RealmModel realm, String username) { + public UserModel getUserByUsername(RealmModel realm, String rawUsername) { + if (rawUsername == null) { + return null; + } + String username = rawUsername.toLowerCase(); logger.tracev("getUserByUsername: {0}", username); - username = username.toLowerCase(); if (realmInvalidations.contains(realm.getId())) { logger.tracev("realmInvalidations"); return getDelegate().getUserByUsername(realm, username); @@ -268,7 +272,6 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC } UserListQuery query = cache.get(cacheKey, UserListQuery.class); - String userId = null; if (query == null) { logger.tracev("query null"); Long loaded = cache.getCurrentRevision(cacheKey); @@ -277,7 +280,7 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC logger.tracev("model from delegate null"); return null; } - userId = model.getId(); + String userId = model.getId(); if (invalidations.contains(userId)) return model; if (managedUsers.containsKey(userId)) { logger.tracev("return managed user"); @@ -287,20 +290,59 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC UserModel adapter = getUserAdapter(realm, userId, loaded, model); if (adapter instanceof UserAdapter) { // this was cached, so we can cache query too query = new UserListQuery(loaded, cacheKey, realm, model.getId()); - cache.addRevisioned(query, startupRevision); + cache.addRevisioned(query, startupRevision, getLifespan(realm, adapter)); } managedUsers.put(userId, adapter); return adapter; - } else { - userId = query.getUsers().iterator().next(); - if (invalidations.contains(userId)) { - logger.tracev("invalidated cache return delegate"); - return getDelegate().getUserByUsername(realm, username); - - } - logger.trace("return getUserById"); - return getUserById(realm, userId); } + + String userId = query.getUsers().iterator().next(); + if (invalidations.contains(userId)) { + logger.tracev("invalidated cache return delegate"); + return getDelegate().getUserByUsername(realm, username); + + } + logger.trace("return getUserById"); + return ofNullable(getUserById(realm, userId)) + // Validate for cases where the cached elements are not in sync. + // This might happen to changes in a federated store where caching is enabled and different items expire at different times, + // for example when they are evicted due to the limited size of the cache + .filter((u) -> username.equalsIgnoreCase(u.getUsername())) + .orElseGet(() -> { + registerInvalidation(cacheKey); + return getDelegate().getUserByUsername(realm, username); + }); + } + + private long getLifespan(RealmModel realm, UserModel user) { + if (!user.isFederated()) { + return -1; // cache infinite + } + + String providerId = user.getFederationLink(); + + if (providerId == null) { + providerId = StorageId.providerId(user.getId()); + } + + ComponentModel component = realm.getComponent(providerId); + UserStorageProviderModel model = new UserStorageProviderModel(component); + + if (model.isEnabled()) { + UserStorageProviderModel.CachePolicy policy = model.getCachePolicy(); + + if (policy == null) { + // no policy set, cache entries by default + return -1; + } + + if (!UserStorageProviderModel.CachePolicy.NO_CACHE.equals(policy)) { + long lifespan = model.getLifespan(); + return lifespan > 0 ? lifespan : -1; + } + } + + return 0; // do not cache } protected UserModel getUserAdapter(RealmModel realm, String userId, Long loaded, UserModel delegate) { @@ -327,8 +369,6 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC } CacheableStorageProviderModel model = new CacheableStorageProviderModel(component); - // although we do set a timeout, Infinispan has no guarantees when the user will be evicted - // its also hard to test stuff if (model.shouldInvalidate(cached)) { registerUserInvalidation(cached); return supplier.get(); @@ -354,8 +394,9 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC if (!model.isEnabled()) { return new ReadOnlyUserModelDelegate(delegate, false); } - UserStorageProviderModel.CachePolicy policy = model.getCachePolicy(); - if (policy != null && policy == UserStorageProviderModel.CachePolicy.NO_CACHE) { + + long lifespan = getLifespan(realm, delegate); + if (lifespan == 0) { return delegate; } @@ -363,12 +404,7 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC adapter = new UserAdapter(cached, this, session, realm); onCache(realm, adapter, delegate); - long lifespan = model.getLifespan(); - if (lifespan > 0) { - cache.addRevisioned(cached, startupRevision, lifespan); - } else { - cache.addRevisioned(cached, startupRevision); - } + cache.addRevisioned(cached, startupRevision, lifespan); } else { cached = new CachedUser(revision, realm, delegate, notBefore); adapter = new UserAdapter(cached, this, session, realm); @@ -384,9 +420,9 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC } @Override - public UserModel getUserByEmail(RealmModel realm, String email) { - if (email == null) return null; - email = email.toLowerCase(); + public UserModel getUserByEmail(RealmModel realm, String rawEmail) { + if (rawEmail == null) return null; + String email = rawEmail.toLowerCase(); if (realmInvalidations.contains(realm.getId())) { return getDelegate().getUserByEmail(realm, email); } @@ -396,30 +432,37 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC } UserListQuery query = cache.get(cacheKey, UserListQuery.class); - String userId = null; if (query == null) { Long loaded = cache.getCurrentRevision(cacheKey); UserModel model = getDelegate().getUserByEmail(realm, email); if (model == null) return null; - userId = model.getId(); + String userId = model.getId(); if (invalidations.contains(userId)) return model; if (managedUsers.containsKey(userId)) return managedUsers.get(userId); UserModel adapter = getUserAdapter(realm, userId, loaded, model); if (adapter instanceof UserAdapter) { query = new UserListQuery(loaded, cacheKey, realm, model.getId()); - cache.addRevisioned(query, startupRevision); + cache.addRevisioned(query, startupRevision, getLifespan(realm, adapter)); } managedUsers.put(userId, adapter); return adapter; - } else { - userId = query.getUsers().iterator().next(); - if (invalidations.contains(userId)) { - return getDelegate().getUserByEmail(realm, email); - - } - return getUserById(realm, userId); } + + String userId = query.getUsers().iterator().next(); + if (invalidations.contains(userId)) { + return getDelegate().getUserByEmail(realm, email); + + } + return ofNullable(getUserById(realm, userId)) + // Validate for cases where the cached elements are not in sync. + // This might happen to changes in a federated store where caching is enabled and different items expire at different times, + // for example when they are evicted due to the limited size of the cache + .filter((u) -> email.equalsIgnoreCase(u.getEmail())) + .orElseGet(() -> { + registerInvalidation(cacheKey); + return getDelegate().getUserByEmail(realm, email); + }); } @Override @@ -453,7 +496,7 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC UserModel adapter = getUserAdapter(realm, userId, loaded, model); if (adapter instanceof UserAdapter) { query = new UserListQuery(loaded, cacheKey, realm, model.getId()); - cache.addRevisioned(query, startupRevision); + cache.addRevisioned(query, startupRevision, getLifespan(realm, adapter)); } managedUsers.put(userId, adapter); @@ -540,7 +583,7 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC UserModel adapter = getUserAdapter(realm, userId, loaded, model); if (adapter instanceof UserAdapter) { // this was cached, so we can cache query too query = new UserListQuery(loaded, cacheKey, realm, model.getId()); - cache.addRevisioned(query, startupRevision); + cache.addRevisioned(query, startupRevision, getLifespan(realm, adapter)); } managedUsers.put(userId, adapter); return adapter; @@ -650,7 +693,7 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC Set federatedIdentities = getDelegate().getFederatedIdentitiesStream(realm, user) .collect(Collectors.toSet()); cachedLinks = new CachedFederatedIdentityLinks(loaded, cacheKey, realm, federatedIdentities); - cache.addRevisioned(cachedLinks, startupRevision); + cache.addRevisioned(cachedLinks, startupRevision); // this is Keycloak's internal store, cache indefinitely return federatedIdentities.stream(); } else { return cachedLinks.getFederatedIdentities().stream(); @@ -722,7 +765,7 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC Long loaded = cache.getCurrentRevision(cacheKey); cached = new CachedUserConsents(loaded, cacheKey, realm, consents, false); - cache.addRevisioned(cached, startupRevision); + cache.addRevisioned(cached, startupRevision); // this is from Keycloak's internal store, cache indefinitely } Map consents = cached.getConsents(); @@ -763,7 +806,7 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC Long loaded = cache.getCurrentRevision(cacheKey); List consents = getDelegate().getConsentsStream(realm, userId).collect(Collectors.toList()); cached = new CachedUserConsents(loaded, cacheKey, realm, consents.stream().map(CachedUserConsent::new).collect(Collectors.toList())); - cache.addRevisioned(cached, startupRevision); + cache.addRevisioned(cached, startupRevision); // this is from Keycloak's internal store, cache indefinitely return consents.stream(); } else { return cached.getConsents().values().stream().map(cachedConsent -> toConsentModel(realm, cachedConsent)) diff --git a/model/storage-private/src/main/java/org/keycloak/storage/UserStorageManager.java b/model/storage-private/src/main/java/org/keycloak/storage/UserStorageManager.java index d33a6965651..91257ada791 100755 --- a/model/storage-private/src/main/java/org/keycloak/storage/UserStorageManager.java +++ b/model/storage-private/src/main/java/org/keycloak/storage/UserStorageManager.java @@ -29,6 +29,8 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -113,7 +115,14 @@ public class UserStorageManager extends AbstractStorageManager Stream getCredentialProviders(KeycloakSession session, Class type) { return session.getKeycloakSessionFactory().getProviderFactoriesStream(CredentialProvider.class) .filter(f -> Types.supports(type, f, CredentialProviderFactory.class)) @@ -244,7 +292,7 @@ public class UserStorageManager extends AbstractStorageManager importValidation(RealmModel realm, Stream users) { - return users.map(user -> importValidation(realm, user)).filter(Objects::nonNull); + return users.map(user -> validateUser(realm, user)).filter(Objects::nonNull); } @FunctionalInterface @@ -409,7 +457,7 @@ public class UserStorageManager extends AbstractStorageManager provider.getUserByUsername(realm, username)).findFirst().orElse(null); + return getUserByAttribute(realm, + provider -> provider.getUserByUsername(realm, username), + u -> username.equalsIgnoreCase(u.getUsername())); } @Override public UserModel getUserByEmail(RealmModel realm, String email) { - UserModel user = localStorage().getUserByEmail(realm, email); - if (user != null) { - user = importValidation(realm, user); - // Case when email was changed directly in the userStorage and doesn't correspond anymore to the email from local DB - if (user != null && email.equalsIgnoreCase(user.getEmail())) { - return user; - } - } - - return mapEnabledStorageProvidersWithTimeout(realm, UserLookupProvider.class, - provider -> provider.getUserByEmail(realm, email)).findFirst().orElse(null); + return getUserByAttribute(realm, + provider -> provider.getUserByEmail(realm, email), + u -> email.equalsIgnoreCase(u.getEmail())); } /** {@link UserLookupProvider} methods implementations end here @@ -807,7 +843,7 @@ public class UserStorageManager extends AbstractStorageManager loader, Predicate attributeValidator) { + // first try the local storage + UserModel user = loader.apply(localStorage()); + + if (user != null) { + // run global user validations + UserModel validated = validateUser(realm, user); + + // make sure the attribute is valid + if (validated != null && attributeValidator.test(validated)) { + return validated; + } + + // user or attribute not valid, invalidate cache + deleteInvalidUserCache(realm, user); + } + + // try to resolve the user from the external storage + return tryResolveFederatedUser(realm, loader); + } + + private UserModel tryResolveFederatedUser(RealmModel realm, Function loader) { + return mapEnabledStorageProvidersWithTimeout(realm, UserLookupProvider.class, loader) + .findFirst() + .orElse(null); + } } diff --git a/model/storage/src/main/java/org/keycloak/storage/CacheableStorageProviderModel.java b/model/storage/src/main/java/org/keycloak/storage/CacheableStorageProviderModel.java index 5372d99be6b..c2f9dbdfa79 100644 --- a/model/storage/src/main/java/org/keycloak/storage/CacheableStorageProviderModel.java +++ b/model/storage/src/main/java/org/keycloak/storage/CacheableStorageProviderModel.java @@ -219,16 +219,13 @@ public class CacheableStorageProviderModel extends PrioritizedComponentModel { public static long dailyTimeout(int hour, int minute) { Calendar cal = Calendar.getInstance(); - Calendar cal2 = Calendar.getInstance(); cal.setTimeInMillis(Time.currentTimeMillis()); - cal2.setTimeInMillis(Time.currentTimeMillis()); - cal2.set(Calendar.HOUR_OF_DAY, hour); - cal2.set(Calendar.MINUTE, minute); - if (cal2.getTimeInMillis() < cal.getTimeInMillis()) { - int add = (24 * 60 * 60 * 1000); - cal.add(Calendar.MILLISECOND, add); - } else { - cal = cal2; + cal.set(Calendar.HOUR_OF_DAY, hour); + cal.set(Calendar.MINUTE, minute); + cal.set(Calendar.SECOND, 0); + cal.set(Calendar.MILLISECOND, 0); + if (cal.getTimeInMillis() < Time.currentTimeMillis()) { + cal.add(Calendar.DAY_OF_YEAR, 1); } return cal.getTimeInMillis(); } @@ -238,6 +235,8 @@ public class CacheableStorageProviderModel extends PrioritizedComponentModel { cal.setTimeInMillis(Time.currentTimeMillis()); cal.set(Calendar.HOUR_OF_DAY, hour); cal.set(Calendar.MINUTE, minute); + cal.set(Calendar.SECOND, 0); + cal.set(Calendar.MILLISECOND, 0); if (cal.getTimeInMillis() > Time.currentTimeMillis()) { // if daily evict for today hasn't happened yet set boundary // to yesterday's time of eviction @@ -248,18 +247,18 @@ public class CacheableStorageProviderModel extends PrioritizedComponentModel { public static long weeklyTimeout(int day, int hour, int minute) { Calendar cal = Calendar.getInstance(); - Calendar cal2 = Calendar.getInstance(); cal.setTimeInMillis(Time.currentTimeMillis()); - cal2.setTimeInMillis(Time.currentTimeMillis()); - cal2.set(Calendar.HOUR_OF_DAY, hour); - cal2.set(Calendar.MINUTE, minute); - cal2.set(Calendar.DAY_OF_WEEK, day); - if (cal2.getTimeInMillis() < cal.getTimeInMillis()) { + cal.set(Calendar.HOUR_OF_DAY, hour); + cal.set(Calendar.MINUTE, minute); + cal.set(Calendar.DAY_OF_WEEK, day); + cal.set(Calendar.SECOND, 0); + cal.set(Calendar.MILLISECOND, 0); + if (cal.getTimeInMillis() < Time.currentTimeMillis()) { int add = (7 * 24 * 60 * 60 * 1000); - cal2.add(Calendar.MILLISECOND, add); + cal.add(Calendar.MILLISECOND, add); } - return cal2.getTimeInMillis(); + return cal.getTimeInMillis(); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPExternalChangesTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPExternalChangesTest.java new file mode 100755 index 00000000000..1b8303c6ea0 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPExternalChangesTest.java @@ -0,0 +1,166 @@ +/* + * Copyright 2017 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. + */ + +package org.keycloak.testsuite.federation.ldap; + +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.FixMethodOrder; +import org.junit.Test; +import org.junit.runners.MethodSorters; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.RealmModel; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.storage.UserStorageProviderModel; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +import org.keycloak.testsuite.util.LDAPRule; +import org.keycloak.testsuite.util.LDAPTestUtils; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public class LDAPExternalChangesTest extends AbstractLDAPTest { + + @ClassRule + public static LDAPRule ldapRule = new LDAPRule(); + + @Override + protected LDAPRule getLDAPRule() { + return ldapRule; + } + + @Override + protected void afterImportTestRealm() { + } + + @Before + public void onBefore() { + testingClient.testing().setTestingInfinispanTimeService(); + } + + @After + public void onAfter() { + testingClient.testing().revertTestingInfinispanTimeService(); + } + + + @Test + public void failAuthenticationIfEmailDifferentThanExternalStorage() { + testingClient.server().run((session) -> { + LDAPTestContext ctx = LDAPTestContext.init(session); + ctx.getLdapModel().setCachePolicy(UserStorageProviderModel.CachePolicy.MAX_LIFESPAN); + ctx.getLdapModel().setMaxLifespan(600000); + RealmModel realm = ctx.getRealm(); + realm.updateComponent(ctx.getLdapModel()); + LDAPObject john = LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), realm, "johnkeycloak", "John", "Doe", "john@email.org", null, "1234"); + LDAPTestUtils.updateLDAPPassword(ctx.getLdapProvider(), john, "Password1"); + realm.getClientByClientId("test-app").setDirectAccessGrantsEnabled(true); + }); + String originalEmail = "john@email.org"; + + // import user from the ldap johnkeycloak and cache it reading it by id + List users = testRealm().users().search("johnkeycloak", true); + assertEquals(1, users.size()); + UserRepresentation user = users.get(0); + String userId = user.getId(); + AccessTokenResponse tokenResponse = oauth.doPasswordGrantRequest(originalEmail, "Password1"); + assertTrue(tokenResponse.isSuccess()); + + // modify the email of the user directly in ldap + String updatedEmail = "updatedjohnkeycloak@email.org"; + testingClient.server().run(session -> { + LDAPTestContext ctx = LDAPTestContext.init(session); + LDAPObject johnLdapObject = ctx.getLdapProvider().loadLDAPUserByUsername(ctx.getRealm(), "johnkeycloak"); + johnLdapObject.setSingleAttribute(LDAPConstants.EMAIL, updatedEmail); + ctx.getLdapProvider().getLdapIdentityStore().update(johnLdapObject); + }); + + tokenResponse = oauth.doPasswordGrantRequest(originalEmail, "Password1"); + assertTrue(tokenResponse.isSuccess()); + + setTimeOffset(610); + + tokenResponse = oauth.doPasswordGrantRequest(originalEmail, "Password1"); + assertFalse(tokenResponse.isSuccess()); + + tokenResponse = oauth.doPasswordGrantRequest(updatedEmail, "Password1"); + assertTrue(tokenResponse.isSuccess()); + + users = testRealm().users().search(originalEmail, true); + assertTrue(users.isEmpty()); + users = testRealm().users().search("johnkeycloak", true); + assertEquals(1, users.size()); + user = users.get(0); + assertEquals(userId, user.getId()); + assertEquals(user.getEmail(), updatedEmail); + } + + @Test + public void failAuthenticationIfUsernameDifferentThanExternalStorage() { + String originalUsername = "john"; + testingClient.server().run((session) -> { + LDAPTestContext ctx = LDAPTestContext.init(session); + ctx.getLdapModel().setCachePolicy(UserStorageProviderModel.CachePolicy.MAX_LIFESPAN); + ctx.getLdapModel().setMaxLifespan(600000); + RealmModel realm = ctx.getRealm(); + realm.updateComponent(ctx.getLdapModel()); + LDAPObject john = LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), realm, originalUsername, "John", "Doe", "john@email.org", null, "1234"); + LDAPTestUtils.updateLDAPPassword(ctx.getLdapProvider(), john, "Password1"); + realm.getClientByClientId("test-app").setDirectAccessGrantsEnabled(true); + }); + // import user from the ldap johnkeycloak and cache it reading it by id + List users = testRealm().users().search(originalUsername, true); + assertEquals(1, users.size()); + UserRepresentation user = users.get(0); + String userId = user.getId(); + AccessTokenResponse tokenResponse = oauth.doPasswordGrantRequest(originalUsername, "Password1"); + assertTrue(tokenResponse.isSuccess()); + + // modify the email of the user directly in ldap + String updatedUsername = "changed" + originalUsername; + testingClient.server().run(session -> { + LDAPTestContext ctx = LDAPTestContext.init(session); + LDAPObject johnLdapObject = ctx.getLdapProvider().loadLDAPUserByUsername(ctx.getRealm(), originalUsername); + johnLdapObject.setSingleAttribute(LDAPConstants.UID, updatedUsername); + ctx.getLdapProvider().getLdapIdentityStore().update(johnLdapObject); + }); + + tokenResponse = oauth.doPasswordGrantRequest(originalUsername, "Password1"); + assertTrue(tokenResponse.isSuccess()); + + setTimeOffset(610); + + tokenResponse = oauth.doPasswordGrantRequest(originalUsername, "Password1"); + assertFalse(tokenResponse.isSuccess()); + + tokenResponse = oauth.doPasswordGrantRequest(updatedUsername, "Password1"); + assertTrue(tokenResponse.isSuccess()); + + users = testRealm().users().search(originalUsername, true); + assertTrue(users.isEmpty()); + users = testRealm().users().search(updatedUsername, true); + user = users.get(0); + assertEquals(userId, user.getId()); + assertEquals(user.getUsername(), updatedUsername); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPProvidersIntegrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPProvidersIntegrationTest.java index 8acd44011c7..e9256f2f6e6 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPProvidersIntegrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPProvidersIntegrationTest.java @@ -1341,48 +1341,52 @@ public class LDAPProvidersIntegrationTest extends AbstractLDAPTest { @Test public void testLDAPUserRefreshCache() { - testingClient.server().run(session -> { - UserStorageUtil.userCache(session).clear(); - }); + try { + testingClient.testing().setTestingInfinispanTimeService(); + testingClient.server().run(session -> { + UserStorageUtil.userCache(session).clear(); + }); - testingClient.server().run(session -> { - LDAPTestContext ctx = LDAPTestContext.init(session); - RealmModel appRealm = ctx.getRealm(); - session.getContext().setRealm(appRealm); + testingClient.server().run(session -> { + LDAPTestContext ctx = LDAPTestContext.init(session); + RealmModel appRealm = ctx.getRealm(); + session.getContext().setRealm(appRealm); - LDAPStorageProvider ldapProvider = LDAPTestUtils.getLdapProvider(session, ctx.getLdapModel()); - LDAPTestUtils.addLDAPUser(ldapProvider, appRealm, "johndirect", "John", "Direct", "johndirect@email.org", null, "1234"); + LDAPStorageProvider ldapProvider = LDAPTestUtils.getLdapProvider(session, ctx.getLdapModel()); + LDAPTestUtils.addLDAPUser(ldapProvider, appRealm, "johndirect", "John", "Direct", "johndirect@email.org", null, "1234"); - // Fetch user from LDAP and check that postalCode is filled - UserModel user = session.users().getUserByUsername(appRealm, "johndirect"); - String postalCode = user.getFirstAttribute("postal_code"); - Assert.assertEquals("1234", postalCode); + // Fetch user from LDAP and check that postalCode is filled + UserModel user = session.users().getUserByUsername(appRealm, "johndirect"); + String postalCode = user.getFirstAttribute("postal_code"); + Assert.assertEquals("1234", postalCode); - LDAPTestUtils.removeLDAPUserByUsername(ldapProvider, appRealm, ldapProvider.getLdapIdentityStore().getConfig(), "johndirect"); - }); + LDAPTestUtils.removeLDAPUserByUsername(ldapProvider, appRealm, ldapProvider.getLdapIdentityStore().getConfig(), "johndirect"); + }); - setTimeOffset(60 * 5); // 5 minutes in future, user should be cached still + setTimeOffset(60 * 5); // 5 minutes in future, user should be cached still - testingClient.server().run(session -> { - RealmModel appRealm = new RealmManager(session).getRealmByName("test"); - session.getContext().setRealm(appRealm); - CachedUserModel user = (CachedUserModel) session.users().getUserByUsername(appRealm, "johndirect"); - String postalCode = user.getFirstAttribute("postal_code"); - String email = user.getEmail(); - Assert.assertEquals("1234", postalCode); - Assert.assertEquals("johndirect@email.org", email); - }); + testingClient.server().run(session -> { + RealmModel appRealm = new RealmManager(session).getRealmByName("test"); + session.getContext().setRealm(appRealm); + CachedUserModel user = (CachedUserModel) session.users().getUserByUsername(appRealm, "johndirect"); + String postalCode = user.getFirstAttribute("postal_code"); + String email = user.getEmail(); + Assert.assertEquals("1234", postalCode); + Assert.assertEquals("johndirect@email.org", email); + }); - setTimeOffset(60 * 20); // 20 minutes into future, cache will be invalidated + setTimeOffset(60 * 20); // 20 minutes into future, cache will be invalidated - testingClient.server().run(session -> { - RealmModel appRealm = new RealmManager(session).getRealmByName("test"); - session.getContext().setRealm(appRealm); - UserModel user = session.users().getUserByUsername(appRealm, "johndirect"); - Assert.assertNull(user); - }); - - setTimeOffset(0); + testingClient.server().run(session -> { + RealmModel appRealm = new RealmManager(session).getRealmByName("test"); + session.getContext().setRealm(appRealm); + UserModel user = session.users().getUserByUsername(appRealm, "johndirect"); + Assert.assertNull(user); + }); + } finally { + resetTimeOffset(); + testingClient.testing().revertTestingInfinispanTimeService(); + } } @Test @@ -1447,6 +1451,7 @@ public class LDAPProvidersIntegrationTest extends AbstractLDAPTest { @Test public void testAlwaysReadValueFromLdapCached() throws Exception { try { + testingClient.testing().setTestingInfinispanTimeService(); // import user from the ldap johnkeycloak and cache it reading it by id List users = testRealm().users().search("johnkeycloak", true); Assert.assertEquals(1, users.size()); @@ -1489,6 +1494,8 @@ public class LDAPProvidersIntegrationTest extends AbstractLDAPTest { johnLdapObject.setSingleAttribute(LDAPConstants.SN, "Doe"); ctx.getLdapProvider().getLdapIdentityStore().update(johnLdapObject); }); + resetTimeOffset(); + testingClient.testing().revertTestingInfinispanTimeService(); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/UserStorageTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/UserStorageTest.java index 6e398cdf160..c14a59ef429 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/UserStorageTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/UserStorageTest.java @@ -37,7 +37,9 @@ import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.storage.CacheableStorageProviderModel.CachePolicy; +import org.keycloak.storage.DatastoreProvider; import org.keycloak.storage.StorageId; +import org.keycloak.storage.StoreManagers; import org.keycloak.storage.UserStoragePrivateUtil; import org.keycloak.storage.UserStorageProvider; import org.keycloak.storage.UserStorageUtil; @@ -170,6 +172,7 @@ public class UserStorageTest extends AbstractAuthTest { UserProfileResource userProfileRes = testRealmResource().users().userProfile(); UserProfileUtil.enableUnmanagedAttributes(userProfileRes); + testingClient.testing().setTestingInfinispanTimeService(); } @After @@ -191,6 +194,8 @@ public class UserStorageTest extends AbstractAuthTest { Assert.assertNotNull(userMapStorageFactory); userMapStorageFactory.clear(); }); + resetTimeOffset(); + testingClient.testing().revertTestingInfinispanTimeService(); } protected ComponentRepresentation newPropProviderRW() { @@ -595,125 +600,64 @@ public class UserStorageTest extends AbstractAuthTest { // and they didn't use any time offset clock so they may have timestamps in the 'future' // let's clear cache - testingClient.server().run(session -> { - UserStorageUtil.userCache(session).clear(); - }); - - - testingClient.server().run(session -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserModel user = session.users().getUserByUsername(realm, "thor"); - Assert.assertTrue(user instanceof CachedUserModel); // should be newly cached - }); + clearUserCache(); + setFirstname("thor", "Thor0"); + // should be newly cached + validateFirstname("thor", "Thor0"); + setFirstname("thor", "Thor1"); setTimeOfDay(23, 40, 0); // lookup user again - make sure it's returned from cache - testingClient.server().run(session -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserModel user = session.users().getUserByUsername(realm, "thor"); - Assert.assertTrue(user instanceof CachedUserModel); // should be returned from cache - }); - + // should be returned from cache + validateFirstname("thor", "Thor0"); setTimeOfDay(23, 50, 0); - testingClient.server().run(session -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserModel user = session.users().getUserByUsername(realm, "thor"); - Assert.assertFalse(user instanceof CachedUserModel); // should have been invalidated - }); - - - testingClient.server().run(session -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserModel user = session.users().getUserByUsername(realm, "thor"); - Assert.assertTrue(user instanceof CachedUserModel); // should have been newly cached - }); - - - testingClient.server().run(session -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserModel user = session.users().getUserByUsername(realm, "thor"); - Assert.assertTrue(user instanceof CachedUserModel); // should be returned from cache - }); + // should have been invalidated + validateFirstname("thor", "Thor1"); + setFirstname("thor", "Thor2"); + validateFirstname("thor", "Thor1"); // should be returned from cache setTimeOfDay(23, 55, 0); - testingClient.server().run(session -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserModel user = session.users().getUserByUsername(realm, "thor"); - Assert.assertTrue(user instanceof CachedUserModel); // should be returned from cache - }); - + validateFirstname("thor", "Thor1"); // should be returned from cache // at 00:30 // it's next day now. the daily eviction time is now in the future setTimeOfDay(0, 30, 0, 24 * 60 * 60); - testingClient.server().run(session -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserModel user = session.users().getUserByUsername(realm, "thor"); - Assert.assertTrue(user instanceof CachedUserModel); // should be returned from cache - it's still good for almost the whole day - }); - + validateFirstname("thor", "Thor1"); // should be returned from cache - it's still good for almost the whole day // at 23:30 next day setTimeOfDay(23, 30, 0, 24 * 60 * 60); - testingClient.server().run(session -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserModel user = session.users().getUserByUsername(realm, "thor"); - Assert.assertTrue(user instanceof CachedUserModel); // should be returned from cache - it's still good until 23:45 - }); + validateFirstname("thor", "Thor1"); // should be returned from cache - it's still good until 23:45 // at 23:50 setTimeOfDay(23, 50, 0, 24 * 60 * 60); - testingClient.server().run(session -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserModel user = session.users().getUserByUsername(realm, "thor"); - Assert.assertFalse(user instanceof CachedUserModel); // should be invalidated - }); + validateFirstname("thor", "Thor2"); // should be invalidated + setFirstname("thor", "Thor3"); setTimeOfDay(23, 55, 0, 24 * 60 * 60); - testingClient.server().run(session -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserModel user = session.users().getUserByUsername(realm, "thor"); - Assert.assertTrue(user instanceof CachedUserModel); // should be newly cached - }); - + validateFirstname("thor", "Thor2"); // should be newly cached setTimeOfDay(23, 40, 0, 2 * 24 * 60 * 60); - testingClient.server().run(session -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserModel user = session.users().getUserByUsername(realm, "thor"); - Assert.assertTrue(user instanceof CachedUserModel); // should be returned from cache - }); + validateFirstname("thor", "Thor2"); // should be returned from cache setTimeOfDay(23, 50, 0, 2 * 24 * 60 * 60); - testingClient.server().run(session -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserModel user = session.users().getUserByUsername(realm, "thor"); - Assert.assertFalse(user instanceof CachedUserModel); // should be invalidated - }); + validateFirstname("thor", "Thor3"); // should be invalidated + setFirstname("thor", "Thor4"); - testingClient.server().run(session -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserModel user = session.users().getUserByUsername(realm, "thor"); - Assert.assertTrue(user instanceof CachedUserModel); // should be newly cached - }); + validateFirstname("thor", "Thor3"); // should be newly cached - testingClient.server().run(session -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserModel user = session.users().getUserByUsername(realm, "thor"); - Assert.assertTrue(user instanceof CachedUserModel); // should be returned from cache - }); + validateFirstname("thor", "Thor3"); // should be returned from cache } @Test @@ -730,32 +674,21 @@ public class UserStorageTest extends AbstractAuthTest { propProviderRW.getConfig().putSingle(EVICTION_MINUTE, Integer.toString(eviction.get(MINUTE))); testRealmResource().components().component(propProviderRWId).update(propProviderRW); + clearUserCache(); + setFirstname("thor", "Thor0"); + // now - testingClient.server().run(session -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserModel user = session.users().getUserByUsername(realm, "thor"); - System.out.println("User class: " + user.getClass()); - Assert.assertTrue(user instanceof CachedUserModel); // should still be cached - }); + validateFirstname("thor", "Thor0"); // should still be cached + + setFirstname("thor", "Thor1"); setTimeOffset(2 * 24 * 60 * 60); // 2 days in future - // now - testingClient.server().run(session -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserModel user = session.users().getUserByUsername(realm, "thor"); - System.out.println("User class: " + user.getClass()); - Assert.assertTrue(user instanceof CachedUserModel); // should still be cached - }); + validateFirstname("thor", "Thor0"); // should still be cached setTimeOffset(5 * 24 * 60 * 60); // 5 days in future - testingClient.server().run(session -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserModel user = session.users().getUserByUsername(realm, "thor"); - System.out.println("User class: " + user.getClass()); - Assert.assertFalse(user instanceof CachedUserModel); // should be evicted - }); + validateFirstname("thor", "Thor1"); // should be evicted } @@ -769,31 +702,23 @@ public class UserStorageTest extends AbstractAuthTest { propProviderRW.getConfig().putSingle(MAX_LIFESPAN, Long.toString(1 * 60 * 60 * 1000)); // 1 hour in milliseconds testRealmResource().components().component(propProviderRWId).update(propProviderRW); + clearUserCache(); + setFirstname("thor", "Thor0"); + // now - testingClient.server().run(session -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserModel user = session.users().getUserByUsername(realm, "thor"); - System.out.println("User class: " + user.getClass()); - Assert.assertTrue(user instanceof CachedUserModel); // should still be cached - }); + validateFirstname("thor", "Thor0"); // Initial caching - setTimeOffset(1/2 * 60 * 60); // 1/2 hour in future + setFirstname("thor", "Thor1"); - testingClient.server().run(session -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserModel user = session.users().getUserByUsername(realm, "thor"); - System.out.println("User class: " + user.getClass()); - Assert.assertTrue(user instanceof CachedUserModel); // should still be cached - }); + validateFirstname("thor", "Thor0"); // should still be cached + + setTimeOffset(30 * 60); // 1/2 hour in future + + validateFirstname("thor", "Thor0"); // should still be cached setTimeOffset(2 * 60 * 60); // 2 hours in future - testingClient.server().run(session -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserModel user = session.users().getUserByUsername(realm, "thor"); - System.out.println("User class: " + user.getClass()); - Assert.assertFalse(user instanceof CachedUserModel); // should be evicted - }); + validateFirstname("thor", "Thor1"); // should be evicted } @@ -829,8 +754,8 @@ public class UserStorageTest extends AbstractAuthTest { testingClient.server().run(session -> { RealmModel realm = session.realms().getRealmByName("test"); - UserModel thor = session.users().getUserByUsername(realm, "thor"); - System.out.println("Foo"); + UserModel user = session.users().getUserByUsername(realm, "thor"); + Assert.assertTrue(user instanceof CachedUserModel); }); } @@ -1182,4 +1107,27 @@ public class UserStorageTest extends AbstractAuthTest { } } + + private void validateFirstname(String username, String firstname) { + testingClient.server().run(session -> { + RealmModel realm = session.realms().getRealmByName("test"); + UserModel user = session.users().getUserByUsername(realm, username); + MatcherAssert.assertThat(user.getFirstName(), Matchers.equalTo(firstname)); + }); + } + + private void setFirstname(String username, String firstname) { + testingClient.server().run(session -> { + RealmModel realm = session.realms().getRealmByName("test"); + UserModel user = ((StoreManagers) session.getProvider(DatastoreProvider.class)).userStorageManager().getUserByUsername(realm, username); + user.setFirstName(firstname); + }); + } + + private void clearUserCache() { + testingClient.server().run(session -> { + UserStorageUtil.userCache(session).clear(); + }); + } + } diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/user/FederatedUserTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/user/FederatedUserTest.java new file mode 100644 index 00000000000..629cee895f7 --- /dev/null +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/user/FederatedUserTest.java @@ -0,0 +1,220 @@ +/* + * Copyright 2021 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. + */ + +package org.keycloak.testsuite.model.user; + +import org.junit.Test; +import org.keycloak.cluster.ClusterProvider; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RealmProvider; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserProvider; +import org.keycloak.storage.CacheableStorageProviderModel; +import org.keycloak.storage.UserStoragePrivateUtil; +import org.keycloak.storage.UserStorageProvider; +import org.keycloak.storage.UserStorageProviderFactory; +import org.keycloak.storage.UserStorageProviderModel; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.LDAPStorageProviderFactory; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +import org.keycloak.storage.user.ImportSynchronization; +import org.keycloak.testsuite.model.KeycloakModelTest; +import org.keycloak.testsuite.model.RequireProvider; +import org.keycloak.testsuite.util.LDAPTestUtils; + +import java.util.function.BiFunction; +import java.util.function.Consumer; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assume.assumeThat; +import static org.keycloak.models.LDAPConstants.LDAP_ID; + +@RequireProvider(UserProvider.class) +@RequireProvider(ClusterProvider.class) +@RequireProvider(RealmProvider.class) +@RequireProvider(value = UserStorageProvider.class, only = LDAPStorageProviderFactory.PROVIDER_NAME) +public class FederatedUserTest extends KeycloakModelTest { + + private String realmId; + private String userFederationId; + + @Override + protected boolean isUseSameKeycloakSessionFactoryForAllThreads() { + return true; + } + + @Override + public void createEnvironment(KeycloakSession s) { + inComittedTransaction(session -> { + RealmModel realm = session.realms().createRealm("realm"); + s.getContext().setRealm(realm); + realm.setDefaultRole(session.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName())); + this.realmId = realm.getId(); + }); + + getParameters(UserStorageProviderModel.class).forEach(fs -> inComittedTransaction(session -> { + if (userFederationId != null || !fs.isImportEnabled()) return; + RealmModel realm = session.realms().getRealm(realmId); + s.getContext().setRealm(realm); + + fs.setParentId(realmId); + + ComponentModel res = realm.addComponentModel(fs); + + // Check if the provider implements ImportSynchronization interface + UserStorageProviderFactory userStorageProviderFactory = (UserStorageProviderFactory) session.getKeycloakSessionFactory().getProviderFactory(UserStorageProvider.class, res.getProviderId()); + if (!ImportSynchronization.class.isAssignableFrom(userStorageProviderFactory.getClass())) { + return; + } + + userFederationId = res.getId(); + log.infof("Added %s user federation provider: %s", fs.getName(), res.getId()); + })); + + assumeThat("Cannot run UserSyncTest because there is no user federation provider that supports sync", userFederationId, notNullValue()); + } + + @Override + public void cleanEnvironment(KeycloakSession s) { + final RealmModel realm = s.realms().getRealm(realmId); + s.getContext().setRealm(realm); + + ComponentModel ldapModel = LDAPTestUtils.getLdapProviderModel(realm); + LDAPStorageProvider ldapFedProvider = LDAPTestUtils.getLdapProvider(s, ldapModel); + LDAPTestUtils.removeAllLDAPUsers(ldapFedProvider, realm); + + s.realms().removeRealm(realmId); + } + + private record TestContext (KeycloakSession session, RealmModel realm, String previousUserId, String previousLdapId) {}; + + private void assertAttributeDifferentThanExternalStorage(Consumer assertion) { + // create user1 in LDAP + String ldapId = withRealm(realmId, (session, realm) -> { + ComponentModel ldapModel = LDAPTestUtils.getLdapProviderModel(realm); + LDAPStorageProvider ldapFedProvider = LDAPTestUtils.getLdapProvider(session, ldapModel); + LDAPObject ldapObject = LDAPTestUtils.addLDAPUser(ldapFedProvider, realm, "user1", "User1" + "FN", "User1" + "LN", "user1@email.org", "my-street 9", "12"); + return ldapObject.getUuid(); + }); + + // import user + String previous = withRealm(realmId, (session, realm) -> + session.users().getUserByUsername(realm, "user1").getId()); + + withRealm(realmId, (session, realm) -> { + ComponentModel ldapModel = LDAPTestUtils.getLdapProviderModel(realm); + LDAPStorageProvider provider = LDAPTestUtils.getLdapProvider(session, ldapModel); + LDAPObject ldapObject = provider.loadLDAPUserByUuid(realm, ldapId); + ldapObject.setSingleAttribute(LDAPConstants.UID, "changed"); + provider.getLdapIdentityStore().update(ldapObject); + return null; + }); + + withRealm(realmId, (BiFunction) (session, realm) -> { + assertion.accept(new TestContext(session, realm, previous, ldapId)); + return null; + }); + } + + @Test + public void testInvalidUsernameWhenDifferentThanExternalStorageNoCache() { + withRealm(realmId, (session, realm) -> { + UserStorageProviderModel providerModel = new UserStorageProviderModel(realm.getComponent(userFederationId)); + providerModel.setCachePolicy(CacheableStorageProviderModel.CachePolicy.NO_CACHE); + realm.updateComponent(providerModel); + return null; + }); + + assertAttributeDifferentThanExternalStorage((context) -> { + KeycloakSession session = context.session(); + RealmModel realm = context.realm(); + UserModel cached = session.users().getUserByUsername(realm, "user1"); + assertThat(cached, nullValue()); + UserModel user = session.users().getUserByUsername(realm, "changed"); + assertThat(user.getFirstAttribute(LDAP_ID), is(context.previousLdapId())); + assertThat(user.getId(), is(context.previousUserId())); + assertThat(user.getUsername(), is("changed")); + UserModel localUser = UserStoragePrivateUtil.userLocalStorage(session).getUserById(realm, context.previousUserId()); + assertThat(localUser.getUsername(), is("changed")); + }); + } + + @Test + public void testInvalidUsernameWhenDifferentThanExternalStorageWithCache() { + withRealm(realmId, (session, realm) -> { + UserStorageProviderModel providerModel = new UserStorageProviderModel(realm.getComponent(userFederationId)); + providerModel.setCachePolicy(CacheableStorageProviderModel.CachePolicy.DEFAULT); + realm.updateComponent(providerModel); + return null; + }); + + assertAttributeDifferentThanExternalStorage((context) -> { + KeycloakSession session = context.session(); + RealmModel realm = context.realm(); + // cache not yet invalidated, set a max lifespan if you want to eventually invalidate federated users + UserModel cached = session.users().getUserByUsername(realm, "user1"); + assertThat(cached, notNullValue()); + UserModel user = session.users().getUserByUsername(realm, "changed"); + assertThat(user.getFirstAttribute(LDAP_ID), is(context.previousLdapId())); + assertThat(user.getId(), is(context.previousUserId())); + assertThat(user.getUsername(), is("changed")); + UserModel localUser = UserStoragePrivateUtil.userLocalStorage(session).getUserById(realm, context.previousUserId()); + assertThat(localUser.getUsername(), is("changed")); + // cache now invalidated + cached = session.users().getUserByUsername(realm, "user1"); + assertThat(cached, is(nullValue())); + }); + } + + @Test + public void testInvalidUsernameWhenDifferentThanExternalStorageWithCacheMaxLifespan() { + withRealm(realmId, (session, realm) -> { + UserStorageProviderModel providerModel = new UserStorageProviderModel(realm.getComponent(userFederationId)); + providerModel.setCachePolicy(CacheableStorageProviderModel.CachePolicy.MAX_LIFESPAN); + providerModel.setMaxLifespan(60000); + realm.updateComponent(providerModel); + return null; + }); + + assertAttributeDifferentThanExternalStorage((context) -> { + KeycloakSession session = context.session(); + RealmModel realm = context.realm(); + UserModel cached = session.users().getUserByUsername(realm, "user1"); + assertThat(cached, notNullValue()); + setTimeOffset(120000); + cached = session.users().getUserByUsername(realm, "user1"); + assertThat(cached, nullValue()); + UserModel user = session.users().getUserByUsername(realm, "changed"); + assertThat(user, notNullValue()); + assertThat(user.getFirstAttribute(LDAP_ID), is(context.previousLdapId())); + assertThat(user.getId(), is(context.previousUserId())); + assertThat(user.getUsername(), is("changed")); + UserModel localUser = UserStoragePrivateUtil.userLocalStorage(session).getUserById(realm, context.previousUserId()); + assertThat(localUser.getUsername(), is("changed")); + cached = session.users().getUserByUsername(realm, "user1"); + assertThat(cached, nullValue()); + }); + } +} + diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/user/UserSyncTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/user/UserSyncTest.java index 919dffca8bf..661e33e74fe 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/user/UserSyncTest.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/user/UserSyncTest.java @@ -55,10 +55,12 @@ import java.util.stream.IntStream; import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.CoreMatchers.notNullValue; import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assume.assumeThat; +import static org.keycloak.models.LDAPConstants.LDAP_ID; import static org.keycloak.storage.UserStorageProviderModel.REMOVE_INVALID_USERS_ENABLED; @RequireProvider(UserProvider.class) @@ -270,17 +272,17 @@ public class UserSyncTest extends KeycloakModelTest { }); // import user - withRealm(realmId, (session, realm) -> { + String oldUserId = withRealm(realmId, (session, realm) -> { UserModel user1 = session.users().getUserByUsername(realm, "user1"); user1.setSingleAttribute("LDAP_ID", "WRONG"); - return user1; + return user1.getId(); }); - // validate imported user + // validate imported user, user will be deleted and re-created withRealm(realmId, (session, realm) -> { - assertThat(session.users().getUserByUsername(realm, "user1"), is(nullValue()));; - UserModel deletedUser = UserStoragePrivateUtil.userLocalStorage(session).getUserByUsername(realm, "user1"); - assertThat(deletedUser, is(nullValue())); + UserModel user = session.users().getUserByUsername(realm, "user1"); + assertThat(user, notNullValue()); + assertThat(user.getId(), not(equalTo(oldUserId))); return null; }); } @@ -344,5 +346,56 @@ public class UserSyncTest extends KeycloakModelTest { return null; }); } + + @Test + public void testInvalidUsernameWhenDifferentThanExternalStorage() { + withRealm(realmId, (session, realm) -> { + UserStorageProviderModel providerModel = new UserStorageProviderModel(realm.getComponent(userFederationId)); + providerModel.setCachePolicy(CacheableStorageProviderModel.CachePolicy.NO_CACHE); + providerModel.getConfig().putSingle(REMOVE_INVALID_USERS_ENABLED, Boolean.FALSE.toString()); // prevent local delete + realm.updateComponent(providerModel); + return null; + }); + + // create user1 in LDAP + String ldapId = withRealm(realmId, (session, realm) -> { + ComponentModel ldapModel = LDAPTestUtils.getLdapProviderModel(realm); + LDAPStorageProvider ldapFedProvider = LDAPTestUtils.getLdapProvider(session, ldapModel); + int i = 1; + LDAPObject ldapObject = LDAPTestUtils.addLDAPUser(ldapFedProvider, realm, "user" + i, "User" + i + "FN", "User" + i + "LN", "user" + i + "@email.org", "my-street 9", "12" + i); + return ldapObject.getUuid(); + }); + + // import user + String userId = withRealm(realmId, (session, realm) -> { + return session.users().getUserByUsername(realm, "user1").getId(); + }); + + withRealm(realmId, (session, realm) -> { + ComponentModel ldapModel = LDAPTestUtils.getLdapProviderModel(realm); + LDAPStorageProvider provider = LDAPTestUtils.getLdapProvider(session, ldapModel); + LDAPObject ldapObject = provider.loadLDAPUserByUuid(realm, ldapId); + ldapObject.setSingleAttribute(LDAPConstants.UID, "changed"); + provider.getLdapIdentityStore().update(ldapObject); + return null; + }); + + // user id changed, user cannot be resolved + withRealm(realmId, (session, realm) -> { + assertThat(session.users().getUserByUsername(realm, "user1"), nullValue()); + return null; + }); + + // cache and local database reflecting the change in the database for the existing account + withRealm(realmId, (session, realm) -> { + UserModel user = session.users().getUserByUsername(realm, "changed"); + assertThat(user.getFirstAttribute(LDAP_ID), is(ldapId)); + assertThat(user.getId(), is(userId)); + assertThat(user.getUsername(), is("changed")); + UserModel localUser = UserStoragePrivateUtil.userLocalStorage(session).getUserById(realm, userId); + assertThat(localUser.getUsername(), is("changed")); + return null; + }); + } }