User session deleted events for invalid sessions

Closes #44513

Signed-off-by: Pedro Ruivo <1492066+pruivo@users.noreply.github.com>
Co-authored-by: Pedro Ruivo <1492066+pruivo@users.noreply.github.com>
This commit is contained in:
Pedro Ruivo 2025-11-28 15:43:59 +00:00 committed by GitHub
parent aa789dd023
commit b35dd72392
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 131 additions and 115 deletions

View File

@ -62,7 +62,7 @@ abstract class BaseUserSessionExpirationListener {
.session(userSessionId)
.user(userId)
.event(EventType.USER_SESSION_DELETED)
.detail(Details.REASON, Details.EXPIRED_DETAIL)
.detail(Details.REASON, Details.USER_SESSION_EXPIRED_REASON)
.success();
});
}

View File

@ -262,7 +262,7 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv
if (realm.isRememberMe()) {
UserSessionExpirationLogic.expireRegularSessions(sessionFactory, realm, currentTime, expiration, true, expirationBatch);
} else {
UserSessionExpirationLogic.deleteInvalidSessions(sessionFactory, realm);
UserSessionExpirationLogic.deleteInvalidSessions(sessionFactory, realm, expirationBatch);
}
}

View File

@ -68,11 +68,11 @@ import org.hibernate.annotations.DynamicUpdate;
" WHERE sess.offline = :offline AND sess.realmId = :realmId AND sess.lastSessionRefresh >= :lastSessionRefresh" +
" GROUP BY clientSess.clientId, clientSess.externalClientId, clientSess.clientStorageProvider"),
@NamedQuery(name = "findUserSessionAndDataWithNullRememberMeLastRefresh",
query = "SELECT sess.userSessionId, sess.data" +
query = "SELECT sess.userSessionId, sess.userId, sess.data" +
" FROM PersistentUserSessionEntity sess" +
" WHERE sess.realmId = :realmId AND sess.offline = '0' AND sess.rememberMe IS NULL AND sess.lastSessionRefresh < :lastSessionRefresh"),
@NamedQuery(name = "findUserSessionAndDataWithNullRememberMeCreatedOn",
query = "SELECT sess.userSessionId, sess.data" +
query = "SELECT sess.userSessionId, sess.userId, sess.data" +
" FROM PersistentUserSessionEntity sess" +
" WHERE sess.realmId = :realmId AND sess.offline = '0' AND sess.rememberMe IS NULL AND sess.createdOn < :createdOn"),
@NamedQuery(name = "updateUserSessionRememberMeColumn",
@ -95,8 +95,9 @@ import org.hibernate.annotations.DynamicUpdate;
query = "SELECT sess.userSessionId, sess.userId" +
" FROM PersistentUserSessionEntity sess" +
" WHERE sess.realmId = :realmId AND sess.offline = '0' AND sess.rememberMe = :rememberMe AND sess.createdOn < :createdOn"),
@NamedQuery(name = "deleteInvalidSessions",
query = "DELETE FROM PersistentUserSessionEntity sess" +
@NamedQuery(name = "findInvalidRegularUserSessions",
query = "SELECT sess.userSessionId, sess.userId" +
" FROM PersistentUserSessionEntity sess" +
" WHERE sess.realmId = :realmId AND sess.offline = '0' AND sess.rememberMe = true"),
})

View File

@ -34,6 +34,7 @@ import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.KeycloakSessionTaskWithResult;
import org.keycloak.models.RealmModel;
import org.keycloak.models.utils.RealmExpiration;
import org.keycloak.models.utils.SessionTimeoutHelper;
@ -51,6 +52,8 @@ import static org.keycloak.models.utils.KeycloakModelUtils.runJobInTransactionWi
final class UserSessionExpirationLogic {
private static final Logger logger = Logger.getLogger(MethodHandles.lookup().lookupClass());
private static final Consumer<TypedQuery<Object[]>> NO_PARAMETERS = typedQuery -> {
};
private UserSessionExpirationLogic() {
}
@ -80,19 +83,14 @@ final class UserSessionExpirationLogic {
String realmId = realm.getId();
final List<UserSessionAndUser> expiredSessions = new ArrayList<>(batchSize);
boolean hasMore = true;
while (hasMore) {
hasMore = runJobInTransactionWithResult(sessionFactory,
s -> removeExpiredOfflineSessionsInTransaction(s, realmId, batchSize, "findExpiredOfflineUserSessionsLastRefresh", setLastSessionRefresh, expiredSessions));
expiredSessions.clear();
}
runInBatches(sessionFactory,
s -> findAndRemoveSessions(s, realmId, batchSize, true, Details.USER_SESSION_EXPIRED_REASON, "findExpiredOfflineUserSessionsLastRefresh", setLastSessionRefresh, expiredSessions),
expiredSessions::clear);
runInBatches(sessionFactory,
s -> findAndRemoveSessions(s, realmId, batchSize, true, Details.USER_SESSION_EXPIRED_REASON, "findExpiredOfflineUserSessionsCreatedOn", setCreatedOn, expiredSessions),
expiredSessions::clear);
hasMore = true;
while (hasMore) {
hasMore = runJobInTransactionWithResult(sessionFactory,
s -> removeExpiredOfflineSessionsInTransaction(s, realmId, batchSize, "findExpiredOfflineUserSessionsCreatedOn", setCreatedOn, expiredSessions));
expiredSessions.clear();
}
long duration = System.nanoTime() - start;
logger.debugf("Offline user session expiration task completed for realm '%s'. Took %dms", realm.getName(), TimeUnit.NANOSECONDS.toMillis(duration));
@ -114,27 +112,24 @@ final class UserSessionExpirationLogic {
logger.tracef("Removing expired regular user sessions for realm '%s'", realm.getName());
int oldestCreatedOn = currentTime - expiration.getLifespan(rememberMe) - SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS;
Consumer<TypedQuery<Object[]>> setCreatedOn = setCreatedOn(oldestCreatedOn);
Consumer<TypedQuery<Object[]>> setRememberMe = setRememberMe(rememberMe);
Consumer<TypedQuery<Object[]>> setCreatedOn = UserSessionExpirationLogic.<Object[]>setCreatedOn(oldestCreatedOn)
.andThen(setRememberMe);
int oldestLastSessionRefresh = currentTime - expiration.getMaxIdle(rememberMe) - SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS;
Consumer<TypedQuery<Object[]>> setLastSessionRefresh = setLastSessionRefresh(oldestLastSessionRefresh);
var setLastSessionRefresh = UserSessionExpirationLogic.<Object[]>setLastSessionRefresh(oldestLastSessionRefresh)
.andThen(setRememberMe);
String realmId = realm.getId();
final List<UserSessionAndUser> expiredSessions = new ArrayList<>(batchSize);
boolean hasMore = true;
while (hasMore) {
hasMore = runJobInTransactionWithResult(sessionFactory,
s -> removeExpiredRegularSessionInTransaction(s, realmId, rememberMe, batchSize, "findExpiredRegularUserSessionsLastRefresh", setLastSessionRefresh, expiredSessions));
expiredSessions.clear();
}
runInBatches(sessionFactory,
s -> findAndRemoveSessions(s, realmId, batchSize, false, Details.USER_SESSION_EXPIRED_REASON, "findExpiredRegularUserSessionsLastRefresh", setLastSessionRefresh, expiredSessions),
expiredSessions::clear);
hasMore = true;
while (hasMore) {
hasMore = runJobInTransactionWithResult(sessionFactory,
s -> removeExpiredRegularSessionInTransaction(s, realmId, rememberMe, batchSize, "findExpiredRegularUserSessionsCreatedOn", setCreatedOn, expiredSessions));
expiredSessions.clear();
}
runInBatches(sessionFactory,
s -> findAndRemoveSessions(s, realmId, batchSize, false, Details.USER_SESSION_EXPIRED_REASON, "findExpiredRegularUserSessionsCreatedOn", setCreatedOn, expiredSessions),
expiredSessions::clear);
long duration = System.nanoTime() - start;
logger.debugf("Regular user session expiration task completed for realm '%s'. Took %dms", realm.getName(), TimeUnit.NANOSECONDS.toMillis(duration));
@ -166,23 +161,18 @@ final class UserSessionExpirationLogic {
final int expireLifespan = currentTime - Math.min(expiration.lifespan(), expiration.rememberMeLifespan());
Consumer<TypedQuery<Object[]>> setCreatedOn = setCreatedOn(expireLifespan);
final List<String> sessionsWithRememberMe = new ArrayList<>(batchSize);
final List<UserSessionAndUser> sessionsWithRememberMe = new ArrayList<>(batchSize);
final List<String> sessionsWithoutRememberMe = new ArrayList<>(batchSize);
boolean hasMore = true;
while (hasMore) {
hasMore = runJobInTransactionWithResult(sessionFactory,
s -> handleRememberMeColumnValue(s, realmId, realmName, batchSize, rememberMeEnabledInRealm, "findUserSessionAndDataWithNullRememberMeLastRefresh", setLastSessionRefresh, sessionsWithRememberMe, sessionsWithoutRememberMe));
final Runnable cleanup = () -> {
sessionsWithRememberMe.clear();
sessionsWithoutRememberMe.clear();
}
hasMore = true;
while (hasMore) {
hasMore = runJobInTransactionWithResult(sessionFactory,
s -> handleRememberMeColumnValue(s, realmId, realmName, batchSize, rememberMeEnabledInRealm, "findUserSessionAndDataWithNullRememberMeCreatedOn", setCreatedOn, sessionsWithRememberMe, sessionsWithoutRememberMe));
sessionsWithRememberMe.clear();
sessionsWithoutRememberMe.clear();
}
};
runInBatches(sessionFactory,
s -> handleRememberMeColumnValue(s, realmId, realmName, batchSize, rememberMeEnabledInRealm, "findUserSessionAndDataWithNullRememberMeLastRefresh", setLastSessionRefresh, sessionsWithRememberMe, sessionsWithoutRememberMe),
cleanup);
runInBatches(sessionFactory,
s -> handleRememberMeColumnValue(s, realmId, realmName, batchSize, rememberMeEnabledInRealm, "findUserSessionAndDataWithNullRememberMeCreatedOn", setCreatedOn, sessionsWithRememberMe, sessionsWithoutRememberMe),
cleanup);
long duration = System.nanoTime() - start;
logger.debugf("Migration task completed for realm '%s'. Took %dms", realmName, TimeUnit.NANOSECONDS.toMillis(duration));
@ -196,25 +186,25 @@ final class UserSessionExpirationLogic {
*
* @param sessionFactory The {@link KeycloakSessionFactory}, used to start transactions.
* @param realm The {@link RealmModel} to check and remove invalid user sessions.
* @param batchSize Sets the maximum number of user sessions to remove in a single transaction.
*/
public static void deleteInvalidSessions(KeycloakSessionFactory sessionFactory, RealmModel realm) {
public static void deleteInvalidSessions(KeycloakSessionFactory sessionFactory, RealmModel realm, int batchSize) {
long start = System.nanoTime();
final String realmId = realm.getId();
final String realmName = realm.getName();
logger.tracef("Removing invalid user sessions for realm '%s'", realmName);
int count = runJobInTransactionWithResult(sessionFactory, session -> {
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
return em.createNamedQuery("deleteInvalidSessions")
.setParameter("realmId", realmId)
.executeUpdate();
});
List<UserSessionAndUser> invalidSession = new ArrayList<>();
runInBatches(sessionFactory,
s -> findAndRemoveSessions(s, realmId, batchSize, false, Details.INVALID_USER_SESSION_REMEMBER_ME_REASON, "findInvalidRegularUserSessions", NO_PARAMETERS, invalidSession),
invalidSession::clear);
long duration = System.nanoTime() - start;
logger.debugf("%d invalid session removed for realm '%s'. Took %dms", (Object) count, realmName, TimeUnit.NANOSECONDS.toMillis(duration));
logger.debugf("Invalid session removed for realm '%s'. Took %dms", realmName, TimeUnit.NANOSECONDS.toMillis(duration));
}
private static boolean handleRememberMeColumnValue(KeycloakSession session, String realmId, String realmName, int batchSize, boolean rememberMeEnabled, String queryName, Consumer<TypedQuery<Object[]>> setParameters, List<String> sessionsWithRememberMeCollector, List<String> sessionsWithoutRememberMeCollector) {
private static boolean handleRememberMeColumnValue(KeycloakSession session, String realmId, String realmName, int batchSize, boolean rememberMeEnabled, String queryName, Consumer<TypedQuery<Object[]>> setParameters, List<UserSessionAndUser> sessionsWithRememberMeCollector, List<String> sessionsWithoutRememberMeCollector) {
final EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
TypedQuery<Object[]> query = em.createNamedQuery(queryName, Object[].class);
@ -224,35 +214,41 @@ final class UserSessionExpirationLogic {
.setMaxResults(batchSize)
.getResultStream()
.map(UserSessionIdAndRememberMe::fromQueryProjection)
.forEach(userSession -> (userSession.rememberMe() ? sessionsWithRememberMeCollector : sessionsWithoutRememberMeCollector).add(userSession.id()));
.forEach(userSession -> {
if (userSession.rememberMe()) {
sessionsWithRememberMeCollector.add(userSession.sessionAndUser());
} else {
sessionsWithoutRememberMeCollector.add(userSession.sessionAndUser().userSessionId());
}
});
int updateCount = updateRememberMeColumn(em, false, sessionsWithoutRememberMeCollector);
if (rememberMeEnabled) {
int rememberMeUpdateCount = updateRememberMeColumn(em, true, sessionsWithRememberMeCollector);
int rememberMeUpdateCount = updateRememberMeColumn(em, true, sessionsWithRememberMeCollector.stream().map(UserSessionAndUser::userSessionId).toList());
logger.debugf("%d sessions with remember me, and %d sessions without remember updated, for realm '%s'", rememberMeUpdateCount, updateCount, realmName);
} else {
int deletedCount = deleteUserSessions(em, offlineToString(false), sessionsWithRememberMeCollector);
int deletedCount = handleResultsToRemove(session, em, realmId, false, Details.INVALID_USER_SESSION_REMEMBER_ME_REASON, sessionsWithRememberMeCollector);
logger.debugf("%d sessions without remember me updated, and %d invalid sessions deleted, for realm '%s'", updateCount, deletedCount, realmName);
}
return sessionsWithRememberMeCollector.size() + sessionsWithoutRememberMeCollector.size() >= batchSize;
}
private static void createExpirationEvent(KeycloakSession session, RealmModel realm, UserSessionAndUser userSessionAndUser) {
private static void createUserSessionDeletedEvent(KeycloakSession session, RealmModel realm, UserSessionAndUser data, String reason) {
new EventBuilder(realm, session)
.user(userSessionAndUser.userId())
.session(userSessionAndUser.userSessionId())
.user(data.userId())
.session(data.userSessionId())
.event(EventType.USER_SESSION_DELETED)
.detail(Details.REASON, Details.EXPIRED_DETAIL)
.detail(Details.REASON, reason)
.success();
}
// returns true if it has more rows to check
private static boolean removeExpiredOfflineSessionsInTransaction(KeycloakSession session, String realmId, int batchSize, String queryName, Consumer<TypedQuery<Object[]>> setParameters, List<UserSessionAndUser> expiredSessions) {
private static boolean findAndRemoveSessions(KeycloakSession session, String realmId, int batchSize, boolean offline, String eventReason, String queryName, Consumer<TypedQuery<Object[]>> queryParameters, List<UserSessionAndUser> expiredSessions) {
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
TypedQuery<Object[]> query = em.createNamedQuery(queryName, Object[].class);
setParameters.accept(query);
queryParameters.accept(query);
query.setParameter("realmId", realmId)
.setHint(AvailableHints.HINT_READ_ONLY, true)
.setMaxResults(batchSize)
@ -260,37 +256,16 @@ final class UserSessionExpirationLogic {
.map(UserSessionAndUser::fromQueryProjection)
.forEach(expiredSessions::add);
handleExpirationQueryResults(session, em, realmId, expiredSessions, true);
handleResultsToRemove(session, em, realmId, offline, eventReason, expiredSessions);
// This should be safe.
// If the hits are less than the desired batch size, we should not have expired sessions.
return expiredSessions.size() >= batchSize;
}
// returns true if it has more rows to check
private static boolean removeExpiredRegularSessionInTransaction(KeycloakSession session, String realmId, boolean rememberMe, int batchSize, String queryName, Consumer<TypedQuery<Object[]>> setParameters, List<UserSessionAndUser> expiredSessions) {
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
TypedQuery<Object[]> query = em.createNamedQuery(queryName, Object[].class);
setParameters.accept(query);
query.setParameter("realmId", realmId)
.setParameter("rememberMe", rememberMe)
.setHint(AvailableHints.HINT_READ_ONLY, true)
.setMaxResults(batchSize)
.getResultStream()
.map(UserSessionAndUser::fromQueryProjection)
.forEach(expiredSessions::add);
handleExpirationQueryResults(session, em, realmId, expiredSessions, false);
// This should be safe.
// If the hits are less than the desired batch size, we should not have expired sessions.
return expiredSessions.size() >= batchSize;
}
private static void handleExpirationQueryResults(KeycloakSession session, EntityManager em, String realmId, Collection<UserSessionAndUser> expiredSessions, boolean offline) {
private static int handleResultsToRemove(KeycloakSession session, EntityManager em, String realmId, boolean offline, String eventReason, Collection<UserSessionAndUser> expiredSessions) {
if (expiredSessions.isEmpty()) {
return;
return 0;
}
RealmModel realm = session.realms().getRealm(realmId);
@ -298,7 +273,7 @@ final class UserSessionExpirationLogic {
// creates the expiration events and extracts the user session IDs for the delete statement.
var sessionIds = expiredSessions.stream()
.peek(sessionAndUser -> createExpirationEvent(session, realm, sessionAndUser))
.peek(sessionAndUser -> createUserSessionDeletedEvent(session, realm, sessionAndUser, eventReason))
.map(UserSessionAndUser::userSessionId)
.toList();
@ -309,8 +284,12 @@ final class UserSessionExpirationLogic {
.setParameter("offline", offlineStr)
.executeUpdate();
int us = deleteUserSessions(em, offlineStr, sessionIds);
logger.debugf("Removed %d expired user sessions and %d expired client sessions in realm '%s'", us, cs, realm.getName());
int us = em.createNamedQuery("deleteUserSessions")
.setParameter("offline", offlineStr)
.setParameter("userSessionIds", sessionIds)
.executeUpdate();
logger.debugf("Removed %d user sessions and %d client sessions in realm '%s'", us, cs, realm.getName());
return us;
}
private static int updateRememberMeColumn(EntityManager em, boolean rememberMe, Collection<String> sessionIds) {
@ -323,14 +302,12 @@ final class UserSessionExpirationLogic {
.executeUpdate();
}
private static int deleteUserSessions(EntityManager em, String offlineStr, Collection<String> sessionIds) {
if (sessionIds.isEmpty()) {
return 0;
}
return em.createNamedQuery("deleteUserSessions")
.setParameter("offline", offlineStr)
.setParameter("userSessionIds", sessionIds)
.executeUpdate();
private static void runInBatches(KeycloakSessionFactory sessionFactory, KeycloakSessionTaskWithResult<Boolean> task, Runnable afterBatchAction) {
boolean hasMore;
do {
hasMore = runJobInTransactionWithResult(sessionFactory, null, task, "session-expiration-task");
afterBatchAction.run();
} while (hasMore);
}
private static <T> Consumer<TypedQuery<T>> setLastSessionRefresh(int value) {
@ -340,4 +317,8 @@ final class UserSessionExpirationLogic {
private static <T> Consumer<TypedQuery<T>> setCreatedOn(int value) {
return query -> query.setParameter("createdOn", value);
}
private static <T> Consumer<TypedQuery<T>> setRememberMe(boolean value) {
return query -> query.setParameter("rememberMe", value);
}
}

View File

@ -23,23 +23,25 @@ import java.util.Objects;
import org.keycloak.util.JsonSerialization;
record UserSessionIdAndRememberMe(String id, boolean rememberMe) {
record UserSessionIdAndRememberMe(UserSessionAndUser sessionAndUser, boolean rememberMe) {
UserSessionIdAndRememberMe {
Objects.requireNonNull(id);
Objects.requireNonNull(sessionAndUser);
}
static UserSessionIdAndRememberMe fromQueryProjection(Object[] projection) {
assert projection.length == 2;
assert projection.length == 3;
assert projection[0] != null;
assert projection[1] != null;
assert projection[2] != null;
try {
String id = String.valueOf(projection[0]);
String data = String.valueOf(projection[1]);
String sessionId = String.valueOf(projection[0]);
String userId = String.valueOf(projection[1]);
String data = String.valueOf(projection[2]);
Map<?, ?> values = JsonSerialization.readValue(data, Map.class);
// TODO should we make PersistentUserSessionData public?
boolean rememberMe = Boolean.parseBoolean(String.valueOf(values.get("rememberMe")));
return new UserSessionIdAndRememberMe(id, rememberMe);
return new UserSessionIdAndRememberMe(new UserSessionAndUser(sessionId, userId), rememberMe);
} catch (IOException e) {
throw new RuntimeException(e);
}

View File

@ -117,5 +117,6 @@ public interface Details {
String CLIENT_POLICY_ERROR = "client_policy_error";
String CLIENT_POLICY_ERROR_DETAIL = "client_policy_error_detail";
String EXPIRED_DETAIL = "expired";
String USER_SESSION_EXPIRED_REASON = "user_session_expired";
String INVALID_USER_SESSION_REMEMBER_ME_REASON = "invalid_user_session_remember_me";
}

View File

@ -195,7 +195,7 @@ public class AssertEvents implements TestRule {
return expect(EventType.USER_SESSION_DELETED)
.session(sessionId)
.user(userId)
.detail(Details.REASON, Details.EXPIRED_DETAIL)
.detail(Details.REASON, Details.USER_SESSION_EXPIRED_REASON)
.client((String) null)
.ipAddress((String) null);
}
@ -387,7 +387,7 @@ public class AssertEvents implements TestRule {
if (key.equals(Details.SCOPE)) {
// the scopes can be given in any order,
// therefore, use a matcher that takes a string and ignores the order of the scopes
return detail(key, new TypeSafeMatcher<String>() {
return detail(key, new TypeSafeMatcher<>() {
@Override
protected boolean matchesSafely(String actualValue) {
return Matchers.containsInAnyOrder(value.split(" ")).matches(Arrays.asList(actualValue.split(" ")));
@ -405,7 +405,7 @@ public class AssertEvents implements TestRule {
public ExpectedEvent detail(String key, Matcher<? super String> matcher) {
if (details == null) {
details = new HashMap<String, Matcher<? super String>>();
details = new HashMap<>();
}
details.put(key, matcher);
return this;
@ -530,7 +530,7 @@ public class AssertEvents implements TestRule {
}
public static Matcher<String> isUUID() {
return new TypeSafeMatcher<String>() {
return new TypeSafeMatcher<>() {
@Override
protected boolean matchesSafely(String item) {
return 36 == item.length() && item.charAt(8) == '-' && item.charAt(13) == '-' && item.charAt(18) == '-' && item.charAt(23) == '-';
@ -544,7 +544,7 @@ public class AssertEvents implements TestRule {
}
public static Matcher<String> isAccessTokenId(String expectedGrantShortcut) {
return new TypeSafeMatcher<String>() {
return new TypeSafeMatcher<>() {
@Override
protected boolean matchesSafely(String item) {
String[] items = item.split(":");
@ -562,7 +562,7 @@ public class AssertEvents implements TestRule {
}
public Matcher<String> defaultRealmId() {
return new TypeSafeMatcher<String>() {
return new TypeSafeMatcher<>() {
private String realmId;
@Override
@ -590,7 +590,7 @@ public class AssertEvents implements TestRule {
}
public Matcher<String> defaultUserId() {
return new TypeSafeMatcher<String>() {
return new TypeSafeMatcher<>() {
private String userId;
@Override

View File

@ -148,7 +148,7 @@ public class UserSessionExpirationTest extends KeycloakModelTest {
return provider.createQuery()
.type(EventType.USER_SESSION_DELETED)
.getResultStream()
.filter(event -> Details.EXPIRED_DETAIL.equals(event.getDetails().get(Details.REASON)))
.filter(event -> Details.USER_SESSION_EXPIRED_REASON.equals(event.getDetails().get(Details.REASON)))
.count();
}
@ -157,7 +157,7 @@ public class UserSessionExpirationTest extends KeycloakModelTest {
return provider.createQuery()
.type(EventType.USER_SESSION_DELETED)
.getResultStream()
.filter(event -> Details.EXPIRED_DETAIL.equals(event.getDetails().get(Details.REASON)))
.filter(event -> Details.USER_SESSION_EXPIRED_REASON.equals(event.getDetails().get(Details.REASON)))
.collect(Collectors.toUnmodifiableMap(Event::getSessionId, Event::getUserId));
}

View File

@ -25,6 +25,7 @@ import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
@ -46,6 +47,8 @@ import org.keycloak.common.util.MultiSiteUtils;
import org.keycloak.common.util.Time;
import org.keycloak.connections.infinispan.InfinispanConnectionProvider;
import org.keycloak.connections.jpa.JpaConnectionProvider;
import org.keycloak.events.Details;
import org.keycloak.events.Event;
import org.keycloak.events.EventStoreProvider;
import org.keycloak.events.EventType;
import org.keycloak.infinispan.util.InfinispanUtils;
@ -929,6 +932,13 @@ public class UserSessionPersisterProviderTest extends KeycloakModelTest {
return expiration;
});
String userId = withRealm(realmId, (session, realm) -> {
// enable events
realm.setEventsEnabled(true);
realm.setEnabledEventTypes(Set.of(EventType.USER_SESSION_DELETED.name()));
return session.users().getUserByUsername(realm, "user1").getId();
});
final int initialCount = getPersistedUserSessionsCount();
final int sessionCount = JpaUserSessionPersisterProviderFactory.DEFAULT_EXPIRATION_BATCH * 2;
@ -950,6 +960,7 @@ public class UserSessionPersisterProviderTest extends KeycloakModelTest {
// realm has remember me disabled, so half of the session should be deleted by the expiration job.
triggerExpiration(realmExpiration.maxIdle() + 10);
assertEquals(initialCount + (sessionCount / 2), getPersistedUserSessionsCount());
assertEquals((sessionCount / 2), getUserSessionInvalidRememberMeEventCount(userId));
// check if everything worked as expected.
withRealmConsumer(realmId, (session, realm) -> {
@ -970,9 +981,13 @@ public class UserSessionPersisterProviderTest extends KeycloakModelTest {
createSessions(sessionCount, value -> value % 2 == 0);
assertEquals(initialCount + sessionCount, getPersistedUserSessionsCount());
// clear events
withRealmConsumer(realmId, (session, realm) -> session.getProvider(EventStoreProvider.class).clear(realm));
// realm has remember me disabled, so half of the session should be deleted by the expiration job.
triggerExpiration(realmExpiration.maxIdle() + 10);
assertEquals(initialCount + (sessionCount / 2), getPersistedUserSessionsCount());
assertEquals(sessionCount / 2, getUserSessionInvalidRememberMeEventCount(userId));
triggerExpiration(realmExpiration.maxIdle() + PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS + 10);
assertEquals(initialCount, getPersistedUserSessionsCount());
@ -1212,6 +1227,14 @@ public class UserSessionPersisterProviderTest extends KeycloakModelTest {
}
private long getUserSessionExpirationEventCount(String userId) {
return getUserSessionDeletedEventCount(userId, Details.USER_SESSION_EXPIRED_REASON);
}
private long getUserSessionInvalidRememberMeEventCount(String userId) {
return getUserSessionDeletedEventCount(userId, Details.INVALID_USER_SESSION_REMEMBER_ME_REASON);
}
private long getUserSessionDeletedEventCount(String userId, String reason) {
return withRealm(realmId, (session, ignored) -> {
EventStoreProvider eventStore = session.getProvider(EventStoreProvider.class);
return eventStore.createQuery()
@ -1219,10 +1242,18 @@ public class UserSessionPersisterProviderTest extends KeycloakModelTest {
.user(userId)
.type(EventType.USER_SESSION_DELETED)
.getResultStream()
.map(UserSessionPersisterProviderTest::findReason)
.flatMap(Optional::stream)
.filter(reason::equals)
.count();
});
}
private static Optional<String> findReason(Event event) {
return Optional.ofNullable(event.getDetails())
.map(map -> map.get(Details.REASON));
}
private long countUserSessionsInRealm(KeycloakSession session) {
JpaUserSessionPersisterProvider sessionPersisterProvider = (JpaUserSessionPersisterProvider) session.getProvider(UserSessionPersisterProvider.class);
RealmModel realm = session.realms().getRealm(realmId);