diff --git a/model/infinispan/src/main/java/org/keycloak/marshalling/KeycloakModelSchema.java b/model/infinispan/src/main/java/org/keycloak/marshalling/KeycloakModelSchema.java index ca105d07754..3506d864433 100644 --- a/model/infinispan/src/main/java/org/keycloak/marshalling/KeycloakModelSchema.java +++ b/model/infinispan/src/main/java/org/keycloak/marshalling/KeycloakModelSchema.java @@ -97,9 +97,11 @@ import org.keycloak.models.sessions.infinispan.events.RealmRemovedSessionEvent; import org.keycloak.models.sessions.infinispan.events.RemoveAllUserLoginFailuresEvent; import org.keycloak.models.sessions.infinispan.events.RemoveUserSessionsEvent; import org.keycloak.models.sessions.infinispan.stream.AuthClientSessionSetMapper; +import org.keycloak.models.sessions.infinispan.stream.ClientSessionFilterByUser; import org.keycloak.models.sessions.infinispan.stream.CollectionToStreamMapper; import org.keycloak.models.sessions.infinispan.stream.GroupAndCountCollectorSupplier; import org.keycloak.models.sessions.infinispan.stream.MapEntryToKeyMapper; +import org.keycloak.models.sessions.infinispan.stream.RemoveKeyConsumer; import org.keycloak.models.sessions.infinispan.stream.SessionPredicate; import org.keycloak.models.sessions.infinispan.stream.SessionUnwrapMapper; import org.keycloak.models.sessions.infinispan.stream.SessionWrapperPredicate; @@ -223,6 +225,8 @@ import org.keycloak.storage.managers.UserStorageSyncManager; GroupAndCountCollectorSupplier.class, MapEntryToKeyMapper.class, SessionUnwrapMapper.class, + ClientSessionFilterByUser.class, + RemoveKeyConsumer.class, // infinispan.module.certificates ReloadCertificateFunction.class, diff --git a/model/infinispan/src/main/java/org/keycloak/marshalling/Marshalling.java b/model/infinispan/src/main/java/org/keycloak/marshalling/Marshalling.java index 8cbcdd53862..dd657d2f7b0 100644 --- a/model/infinispan/src/main/java/org/keycloak/marshalling/Marshalling.java +++ b/model/infinispan/src/main/java/org/keycloak/marshalling/Marshalling.java @@ -45,6 +45,7 @@ import org.infinispan.protostream.SerializationContextInitializer; *

* Docs: Language Guide (proto 3) */ +@SuppressWarnings("unused") public final class Marshalling { public static final String PROTO_SCHEMA_PACKAGE = "keycloak"; @@ -183,6 +184,8 @@ public final class Marshalling { public static final int RELOAD_CERTIFICATE_FUNCTION = 65615; public static final int EMBEDDED_CLIENT_SESSION_KEY = 65616; + public static final int CLIENT_SESSION_USER_FILTER = 65617; + public static final int REMOVE_KEY_BI_CONSUMER = 65618; public static void configure(GlobalConfigurationBuilder builder) { getSchemas().forEach(builder.serialization()::addContextInitializer); diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java index 1b59ad6b10b..c069ad8bcb7 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java @@ -906,6 +906,7 @@ public class InfinispanUserSessionProvider implements UserSessionProvider, Sessi entity.setRealmId(realmId); entity.setClientId(clientId); entity.setUserSessionId(clientSession.getUserSession().getId()); + entity.setUserId(clientSession.getUserSession().getId()); entity.setAction(clientSession.getAction()); entity.setAuthMethod(clientSession.getProtocol()); diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/PersistentUserSessionProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/PersistentUserSessionProvider.java index 78f0541b713..e6da21c3320 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/PersistentUserSessionProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/PersistentUserSessionProvider.java @@ -78,7 +78,10 @@ import org.keycloak.models.sessions.infinispan.entities.UserSessionEntity; import org.keycloak.models.sessions.infinispan.events.RealmRemovedSessionEvent; import org.keycloak.models.sessions.infinispan.events.RemoveUserSessionsEvent; import org.keycloak.models.sessions.infinispan.events.SessionEventsSenderTransaction; +import org.keycloak.models.sessions.infinispan.stream.ClientSessionFilterByUser; +import org.keycloak.models.sessions.infinispan.stream.MapEntryToKeyMapper; import org.keycloak.models.sessions.infinispan.stream.Mappers; +import org.keycloak.models.sessions.infinispan.stream.RemoveKeyConsumer; import org.keycloak.models.sessions.infinispan.stream.SessionWrapperPredicate; import org.keycloak.models.sessions.infinispan.stream.UserSessionPredicate; import org.keycloak.models.sessions.infinispan.util.FuturesHelper; @@ -107,6 +110,7 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi protected final ClientSessionPersistentChangelogBasedTransaction clientSessionTx; protected final SessionEventsSenderTransaction clusterEventsSenderTx; + protected final UserSessionPersisterProvider userSessionPersister; public PersistentUserSessionProvider(KeycloakSession session, UserSessionPersistentChangelogBasedTransaction sessionTx, @@ -121,6 +125,7 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi this.clusterEventsSenderTx = new SessionEventsSenderTransaction(session); session.getTransactionManager().enlistAfterCompletion(clusterEventsSenderTx); + userSessionPersister = session.getProvider(UserSessionPersisterProvider.class); } protected Cache> getCache(boolean offline) { @@ -148,6 +153,7 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi entity.setRealmId(realm.getId()); entity.setClientId(client.getId()); entity.setUserSessionId(userSession.getId()); + entity.setUserId(userSession.getUser().getId()); entity.setTimestamp(Time.currentTime()); entity.getNotes().put(AuthenticatedClientSessionModel.STARTED_AT_NOTE, String.valueOf(entity.getTimestamp())); entity.getNotes().put(AuthenticatedClientSessionModel.USER_SESSION_STARTED_AT_NOTE, String.valueOf(userSession.getStarted())); @@ -392,7 +398,6 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi } protected void removeUserSessions(RealmModel realm, UserModel user, boolean offline) { - UserSessionPredicate.create(realm.getId()).user(user.getId()); getUserSessionsStream(realm, UserSessionPredicate.create(realm.getId()).user(user.getId()), offline) .forEach(s -> removeUserSession(realm, s)); } @@ -485,13 +490,9 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi } protected void onUserRemoved(RealmModel realm, UserModel user) { - removeUserSessions(realm, user, true); - removeUserSessions(realm, user, false); - - UserSessionPersisterProvider persisterProvider = session.getProvider(UserSessionPersisterProvider.class); - if (persisterProvider != null) { - persisterProvider.onUserRemoved(realm, user); - } + userSessionPersister.onUserRemoved(realm, user); + removeCachedUserAndClientSessionForUser(realm.getId(), user.getId(), true); + removeCachedUserAndClientSessionForUser(realm.getId(), user.getId(), false); } @Override @@ -640,6 +641,7 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi UserSessionEntity userSessionEntityToImport = createUserSessionEntityInstance(persistentUserSession); String realmId = userSessionEntityToImport.getRealmId(); String sessionId = userSessionEntityToImport.getId(); + String userId = userSessionEntityToImport.getUser(); RealmModel realm = session.realms().getRealm(realmId); long lifespan = offline ? @@ -660,9 +662,8 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi for (Map.Entry entry : persistentUserSession.getAuthenticatedClientSessions().entrySet()) { String clientUUID = entry.getKey(); AuthenticatedClientSessionModel clientSession = entry.getValue(); - AuthenticatedClientSessionEntity clientSessionToImport = createAuthenticatedClientSessionInstance(sessionId, clientSession, + AuthenticatedClientSessionEntity clientSessionToImport = createAuthenticatedClientSessionInstance(sessionId, userId, clientSession, realmId, clientUUID, offline); - clientSessionToImport.setUserSessionId(sessionId); if (offline) { // Update timestamp to the same value as userSession. LastSessionRefresh of userSession from DB will have a correct value. @@ -766,9 +767,8 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi private AuthenticatedClientSessionAdapter importOfflineClientSession(UserSessionAdapter sessionToImportInto, AuthenticatedClientSessionModel clientSession) { - AuthenticatedClientSessionEntity entity = createAuthenticatedClientSessionInstance(sessionToImportInto.getId(), clientSession, + AuthenticatedClientSessionEntity entity = createAuthenticatedClientSessionInstance(sessionToImportInto.getId(), sessionToImportInto.getUser().getId(), clientSession, sessionToImportInto.getRealm().getId(), clientSession.getClient().getId(), true); - entity.setUserSessionId(sessionToImportInto.getId()); // Update timestamp to same value as userSession. LastSessionRefresh of userSession from DB will have correct value entity.setTimestamp(sessionToImportInto.getLastSessionRefresh()); @@ -795,9 +795,8 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi for (Map.Entry entry : persistentUserSession.getAuthenticatedClientSessions().entrySet()) { String clientUUID = entry.getKey(); - AuthenticatedClientSessionEntity clientSession = createAuthenticatedClientSessionInstance(persistentUserSession.getId(), entry.getValue(), + AuthenticatedClientSessionEntity clientSession = createAuthenticatedClientSessionInstance(persistentUserSession.getId(), userSessionEntity.getUser(), entry.getValue(), userSessionEntity.getRealmId(), clientUUID, offline); - clientSession.setUserSessionId(userSessionEntity.getId()); if (offline) { // Update timestamp to the same value as userSession. LastSessionRefresh of userSession from DB will have a correct value. @@ -918,6 +917,7 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi clientSession.getEntity().setClientId(clientId); } clientSession.getEntity().setUserSessionId(sessionEntityWrapper.getEntity().getId()); + clientSession.getEntity().setUserId(sessionEntityWrapper.getEntity().getUser()); MergedUpdate merged = MergedUpdate.computeUpdate(Collections.singletonList(Tasks.addIfAbsentSync()), clientSession, 1, 1); clientSessionPerformer.registerChange(Map.entry(key, new SessionUpdatesList<>(realm, clientSession)), merged); } @@ -956,4 +956,25 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi private void addClientSessionToUserSession(EmbeddedClientSessionKey cacheKey, boolean offline) { sessionTx.registerClientSession(cacheKey.userSessionId(), cacheKey.clientId(), offline); } + + private void removeCachedUserAndClientSessionForUser(String realmId, String userId, boolean offline) { + if (getCache(offline) == null) { + // caching disabled + return; + } + try (var stream = getCache(offline).getAdvancedCache() + .entrySet() + .stream() + .filter(UserSessionPredicate.create(realmId).user(userId)) + .map(MapEntryToKeyMapper.getInstance())) { + stream.forEach(RemoveKeyConsumer.getInstance()); + } + try (var stream = getClientSessionCache(offline) .getAdvancedCache() + .entrySet() + .stream() + .filter(new ClientSessionFilterByUser(realmId, userId)) + .map(MapEntryToKeyMapper.getInstance())) { + stream.forEach(RemoveKeyConsumer.getInstance()); + } + } } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/ClientSessionPersistentChangelogBasedTransaction.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/ClientSessionPersistentChangelogBasedTransaction.java index 016331402e3..8cd4f3cd43e 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/ClientSessionPersistentChangelogBasedTransaction.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/ClientSessionPersistentChangelogBasedTransaction.java @@ -136,7 +136,7 @@ public class ClientSessionPersistentChangelogBasedTransaction extends Persistent return authenticatedClientSessionEntitySessionEntityWrapper; } - public static AuthenticatedClientSessionEntity createAuthenticatedClientSessionInstance(String userSessionId, AuthenticatedClientSessionModel clientSession, + public static AuthenticatedClientSessionEntity createAuthenticatedClientSessionInstance(String userSessionId, String userId, AuthenticatedClientSessionModel clientSession, String realmId, String clientId, boolean offline) { AuthenticatedClientSessionEntity entity = new AuthenticatedClientSessionEntity(); @@ -151,12 +151,13 @@ public class ClientSessionPersistentChangelogBasedTransaction extends Persistent entity.setTimestamp(clientSession.getTimestamp()); entity.setOffline(offline); entity.setUserSessionId(userSessionId); + entity.setUserId(userId); return entity; } private SessionEntityWrapper importClientSession(RealmModel realm, ClientModel client, UserSessionModel userSession, AuthenticatedClientSessionModel persistentClientSession, EmbeddedClientSessionKey clientSessionId) { - AuthenticatedClientSessionEntity entity = createAuthenticatedClientSessionInstance(userSession.getId(), persistentClientSession, + AuthenticatedClientSessionEntity entity = createAuthenticatedClientSessionInstance(userSession.getId(), userSession.getUser().getId(), persistentClientSession, realm.getId(), client.getId(), userSession.isOffline()); boolean offline = userSession.isOffline(); diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/AuthenticatedClientSessionEntity.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/AuthenticatedClientSessionEntity.java index ece888b225a..24ea0776208 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/AuthenticatedClientSessionEntity.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/AuthenticatedClientSessionEntity.java @@ -25,7 +25,6 @@ import org.infinispan.protostream.annotations.ProtoFactory; import org.infinispan.protostream.annotations.ProtoField; import org.infinispan.protostream.annotations.ProtoReserved; import org.infinispan.protostream.annotations.ProtoTypeId; -import org.jboss.logging.Logger; import org.keycloak.common.util.Time; import org.keycloak.marshalling.Marshalling; import org.keycloak.models.AuthenticatedClientSessionModel; @@ -44,8 +43,6 @@ import org.keycloak.models.UserSessionModel; ) public class AuthenticatedClientSessionEntity extends SessionEntity { - public static final Logger logger = Logger.getLogger(AuthenticatedClientSessionEntity.class); - // Metadata attribute, which contains the last timestamp available on remoteCache. Used in decide whether we need to write to remoteCache (DC) or not @Deprecated(since = "26.4", forRemoval = true) public static final String LAST_TIMESTAMP_REMOTE = "lstr"; @@ -62,6 +59,7 @@ public class AuthenticatedClientSessionEntity extends SessionEntity { // TODO [pruivo] [KC27] make these fields final. They are the client session identity. private volatile String userSessionId; private volatile String clientId; + private volatile String userId; public AuthenticatedClientSessionEntity() { } @@ -117,7 +115,7 @@ public class AuthenticatedClientSessionEntity extends SessionEntity { this.clientId = clientId; } - @ProtoField(value = 5) + @ProtoField(5) public String getAction() { return action; } @@ -135,6 +133,15 @@ public class AuthenticatedClientSessionEntity extends SessionEntity { this.notes = notes; } + @ProtoField(10) + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + @Override public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; @@ -152,7 +159,7 @@ public class AuthenticatedClientSessionEntity extends SessionEntity { // factory method required because of final fields @ProtoFactory - AuthenticatedClientSessionEntity(String realmId, String authMethod, String redirectUri, int timestamp, String action, Map notes, String userSessionId, String clientId) { + AuthenticatedClientSessionEntity(String realmId, String authMethod, String redirectUri, int timestamp, String action, Map notes, String userSessionId, String clientId, String userId) { super(realmId); this.authMethod = authMethod; this.redirectUri = redirectUri; @@ -161,6 +168,7 @@ public class AuthenticatedClientSessionEntity extends SessionEntity { this.notes = notes; this.userSessionId = userSessionId; this.clientId = clientId; + this.userId = userId; } @ProtoField(8) @@ -182,6 +190,7 @@ public class AuthenticatedClientSessionEntity extends SessionEntity { if (userSession.isRememberMe()) { entity.getNotes().put(AuthenticatedClientSessionModel.USER_SESSION_REMEMBER_ME_NOTE, "true"); } + entity.setUserId(userSession.getUser().getId()); return entity; } @@ -190,5 +199,4 @@ public class AuthenticatedClientSessionEntity extends SessionEntity { entity.setNotes(model.getNotes() == null ? new ConcurrentHashMap<>() : model.getNotes()); return entity; } - } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/stream/ClientSessionFilterByUser.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/stream/ClientSessionFilterByUser.java new file mode 100644 index 00000000000..344afb3604e --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/stream/ClientSessionFilterByUser.java @@ -0,0 +1,48 @@ +/* + * Copyright 2025 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.models.sessions.infinispan.stream; + +import java.util.Map; +import java.util.Objects; +import java.util.function.Predicate; + +import org.infinispan.protostream.annotations.Proto; +import org.infinispan.protostream.annotations.ProtoTypeId; +import org.keycloak.models.sessions.infinispan.changes.SessionEntityWrapper; +import org.keycloak.models.sessions.infinispan.entities.AuthenticatedClientSessionEntity; + +import static org.keycloak.marshalling.Marshalling.CLIENT_SESSION_USER_FILTER; + +/** + * A {@link Predicate} to filter {@link AuthenticatedClientSessionEntity} values based on the Realm ID and the User ID. + * + * @param realmId The Realm ID. + * @param userId The User ID. + */ +@ProtoTypeId(CLIENT_SESSION_USER_FILTER) +@Proto +public record ClientSessionFilterByUser(String realmId, + String userId) implements Predicate>> { + + @Override + public boolean test(Map.Entry> entry) { + var entity = entry.getValue().getEntity(); + return Objects.equals(userId, entity.getUserId()) && + Objects.equals(realmId, entity.getRealmId()); + } +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/stream/RemoveKeyConsumer.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/stream/RemoveKeyConsumer.java new file mode 100644 index 00000000000..db00135cebe --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/stream/RemoveKeyConsumer.java @@ -0,0 +1,54 @@ +/* + * Copyright 2025 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.models.sessions.infinispan.stream; + +import java.util.function.BiConsumer; + +import org.infinispan.Cache; +import org.infinispan.context.Flag; +import org.infinispan.protostream.annotations.ProtoFactory; +import org.infinispan.protostream.annotations.ProtoTypeId; + +import static org.keycloak.marshalling.Marshalling.REMOVE_KEY_BI_CONSUMER; + +/** + * Removes keys from a {@link Cache}. + *

+ * This implementation is best-effortly, meaning if the removal fails, it won't throw any exception. + * + * @param The type of key stored in the cache. + * @param The type of the value store in the cache. + */ +@ProtoTypeId(REMOVE_KEY_BI_CONSUMER) +public class RemoveKeyConsumer implements BiConsumer, K> { + + private static final RemoveKeyConsumer INSTANCE = new RemoveKeyConsumer<>(); + + @ProtoFactory + @SuppressWarnings("unchecked") + public static RemoveKeyConsumer getInstance() { + return (RemoveKeyConsumer) INSTANCE; + } + + @Override + public void accept(Cache cache, K key) { + cache.getAdvancedCache() + .withFlags(Flag.ZERO_LOCK_ACQUISITION_TIMEOUT, Flag.FAIL_SILENTLY, Flag.IGNORE_RETURN_VALUES) + .remove(key); + } +} diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionPersisterProviderTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionPersisterProviderTest.java index f67e52b2c9f..ef953c2f372 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionPersisterProviderTest.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionPersisterProviderTest.java @@ -25,8 +25,14 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.BrokenBarrierException; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; +import java.util.stream.IntStream; import java.util.stream.Stream; import org.hamcrest.Matchers; @@ -73,6 +79,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME; +import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.USER_SESSION_CACHE_NAME; /** * @author Marek Posolda @@ -746,6 +753,113 @@ public class UserSessionPersisterProviderTest extends KeycloakModelTest { }); } + + @Test + public void testUserRemoved() throws InterruptedException { + final String userName = "to-remove"; + final int numberOfSessions = 5; + final int clusterSize = 4; + inComittedTransaction(session -> { + RealmModel realm = getRealm(session); + session.sessions().removeUserSessions(realm); + session.users().addUser(realm, userName).setEmail(userName + "@localhost"); + }); + + final UserSessionCount initial = getUserSessionCount(); + final CyclicBarrier barrier = new CyclicBarrier(clusterSize); + final AtomicBoolean userDeleted = new AtomicBoolean(false); + + inIndependentFactories(clusterSize, 60, () -> { + try { + barrier.await(10, TimeUnit.SECONDS); + inComittedTransaction(session -> { + RealmModel realm = getRealm(session); + UserModel user = session.users().getUserByUsername(realm, userName); + ClientModel testApp = realm.getClientByClientId("test-app"); + IntStream.range(0, numberOfSessions) + .forEach(ignored -> { + UserSessionModel us = session.sessions().createUserSession(null, realm, user, userName, "127.0.0.1", "form", false, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); + session.sessions().createClientSession(realm, testApp, us); + }); + }); + + barrier.await(10, TimeUnit.SECONDS); + assertSessionCount(numberOfSessions * clusterSize, initial); + + barrier.await(10, TimeUnit.SECONDS); + if (userDeleted.compareAndSet(false, true)) { + inComittedTransaction(session -> { + RealmModel realm = getRealm(session); + UserModel user = session.users().getUserByUsername(realm, userName); + new UserManager(session).removeUser(realm, user); + }); + } + + barrier.await(10, TimeUnit.SECONDS); + assertSessionCount(0, initial); + + barrier.await(10, TimeUnit.SECONDS); + } catch (BrokenBarrierException | TimeoutException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } + + private UserSessionCount getUserSessionCount() { + if (InfinispanUtils.isEmbeddedInfinispan()) { + return MultiSiteUtils.isPersistentSessionsEnabled() ? + new UserSessionCount(getPersistedUserSessionsCount(), getEmbeddedCachedUserSessionsCount()) : + new UserSessionCount(-1, getEmbeddedCachedUserSessionsCount()); + + } + return MultiSiteUtils.isPersistentSessionsEnabled() ? + new UserSessionCount(getPersistedUserSessionsCount(), -1) : + new UserSessionCount(-1, getRemoteCachedUserSessionsCount()); + } + + private void assertSessionCount(int offset, UserSessionCount initial) { + UserSessionCount current = getUserSessionCount(); + if (initial.database() != -1) { + assertEquals("Wrong number of session in database", initial.database() + offset, current.database()); + } else { + assertEquals("Wrong number of session in database", initial.database(), current.database()); + } + if (initial.cache() != -1) { + assertEquals("Wrong number of session in cache", initial.cache() + offset, current.cache()); + } else { + assertEquals("Wrong number of session in cache", initial.cache(), current.cache()); + } + } + + private int getRemoteCachedUserSessionsCount() { + return inComittedTransaction(session -> { + getRealm(session); + return session.getProvider(InfinispanConnectionProvider.class).getRemoteCache(USER_SESSION_CACHE_NAME).size(); + }); + } + + private int getEmbeddedCachedUserSessionsCount() { + return inComittedTransaction(session -> { + getRealm(session); + return session.getProvider(InfinispanConnectionProvider.class).getCache(USER_SESSION_CACHE_NAME).size(); + }); + } + + private int getPersistedUserSessionsCount() { + return inComittedTransaction(session -> { + getRealm(session); + return session.getProvider(UserSessionPersisterProvider.class).getUserSessionsCount(false); + }); + } + + private RealmModel getRealm(KeycloakSession session) { + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + return realm; + } + private long countUserSessionsInRealm(KeycloakSession session) { JpaUserSessionPersisterProvider sessionPersisterProvider = (JpaUserSessionPersisterProvider) session.getProvider(UserSessionPersisterProvider.class); RealmModel realm = session.realms().getRealm(realmId); @@ -886,4 +1000,6 @@ public class UserSessionPersisterProviderTest extends KeycloakModelTest { assertThat(actual, Matchers.arrayContainingInAnyOrder(expectedSessionIds)); } + + private record UserSessionCount(int database, int cache) {} }