diff --git a/docs/documentation/server_admin/topics/login-settings/remember-me.adoc b/docs/documentation/server_admin/topics/login-settings/remember-me.adoc index 0b2b598d35e..e99b92548ee 100644 --- a/docs/documentation/server_admin/topics/login-settings/remember-me.adoc +++ b/docs/documentation/server_admin/topics/login-settings/remember-me.adoc @@ -16,5 +16,11 @@ When you save this setting, a `remember me` checkbox displays on the realm's log .Remember Me image:images/remember-me.png[Remember Me] -WARNING: Note that disabling the "Remember me" option will invalidate all sessions created with the "Remember me" checkbox selected during login, requiring users to log in again. Any refresh tokens related to these sessions will also become invalid. -Note also that the sessions will not be invalidated immediately when the switch is disabled, but only when a cookie or token associated with an invalid session is used. This means that disabling and then re-enabling the "Remember me" switch cannot be used to invalidate old sessions. +[WARNING] +===== +Disabling the "Remember me" option will invalidate all sessions created with the "Remember me" checkbox selected during login, requiring users to log in again. +Any refresh tokens related to these sessions will also become invalid. + +The sessions will not be invalidated immediately when the switch is disabled, but when a cookie or token associated with an invalid session is used, or asynchronously in the background. +This means that disabling and then re-enabling the "Remember me" switch cannot be used to invalidate old sessions. +===== diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaSessionUtil.java b/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaSessionUtil.java new file mode 100644 index 00000000000..48500a8b7c1 --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaSessionUtil.java @@ -0,0 +1,50 @@ +/* + * 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.jpa.session; + +import org.keycloak.models.session.PersistentAuthenticatedClientSessionAdapter; +import org.keycloak.storage.StorageId; + +public final class JpaSessionUtil { + + private JpaSessionUtil() {} + + public static String offlineToString(boolean offline) { + return offline ? "1" : "0"; + } + + public static boolean offlineFromString(String offlineStr) { + return "1".equals(offlineStr); + } + + public static boolean isExternalClient(PersistentClientSessionEntity entity) { + return !entity.getExternalClientId().equals(PersistentClientSessionEntity.LOCAL); + } + + public static String getExternalClientId(PersistentClientSessionEntity entity) { + return new StorageId(entity.getClientStorageProvider(), entity.getExternalClientId()).getId(); + } + + public static String getClientId(PersistentClientSessionEntity entity) { + return isExternalClient(entity) ? getExternalClientId(entity) : entity.getClientId(); + } + + public static boolean hasClient(PersistentAuthenticatedClientSessionAdapter clientSession) { + return clientSession.getClient() != null; + } +} diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaUserSessionPersisterProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaUserSessionPersisterProvider.java index bb1df05766a..4fb6c81df49 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaUserSessionPersisterProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaUserSessionPersisterProvider.java @@ -36,13 +36,10 @@ import jakarta.persistence.TypedQuery; import org.keycloak.common.util.MultiSiteUtils; import org.keycloak.common.util.Time; -import org.keycloak.connections.jpa.JpaConnectionProvider; -import org.keycloak.events.Details; -import org.keycloak.events.EventBuilder; -import org.keycloak.events.EventType; import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.OfflineUserSessionModel; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; @@ -52,16 +49,16 @@ import org.keycloak.models.session.PersistentClientSessionModel; import org.keycloak.models.session.PersistentUserSessionAdapter; import org.keycloak.models.session.PersistentUserSessionModel; import org.keycloak.models.session.UserSessionPersisterProvider; -import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.models.utils.RealmExpiration; import org.keycloak.models.utils.SessionExpirationUtils; -import org.keycloak.models.utils.SessionTimeoutHelper; import org.keycloak.storage.StorageId; import org.keycloak.utils.StreamsUtil; -import org.hibernate.jpa.AvailableHints; import org.jboss.logging.Logger; import static org.keycloak.models.jpa.PaginationUtils.paginateQuery; +import static org.keycloak.models.jpa.session.JpaSessionUtil.offlineFromString; +import static org.keycloak.models.jpa.session.JpaSessionUtil.offlineToString; import static org.keycloak.utils.StreamsUtil.closing; /** @@ -248,15 +245,24 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv @Override public void removeExpired(RealmModel realm) { - int expiredOffline = calculateOldestSessionTime(realm, true) - SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS; + final RealmExpiration expiration = RealmExpiration.fromRealm(realm); + final int currentTime = Time.currentTime(); + final KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory(); - expire(realm, expiredOffline, true); + UserSessionExpirationLogic.expireOfflineSessions(sessionFactory, realm, currentTime, expiration, expirationBatch); - if (MultiSiteUtils.isPersistentSessionsEnabled()) { + if (!MultiSiteUtils.isPersistentSessionsEnabled()) { + return; + } - int expired = calculateOldestSessionTime(realm, false) - SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS; + // The offline sessions do not have remember_me flag. We do not waste time migrating them. + UserSessionExpirationLogic.migrateRememberMe(sessionFactory, realm, expiration, currentTime, expirationBatch); - expire(realm, expired, false); + UserSessionExpirationLogic.expireRegularSessions(sessionFactory, realm, currentTime, expiration, false, expirationBatch); + if (realm.isRememberMe()) { + UserSessionExpirationLogic.expireRegularSessions(sessionFactory, realm, currentTime, expiration, true, expirationBatch); + } else { + UserSessionExpirationLogic.deleteInvalidSessions(sessionFactory, realm); } } @@ -264,18 +270,6 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv return Time.currentTime() - (int) TimeUnit.MILLISECONDS.toSeconds(SessionExpirationUtils.calculateUserSessionIdleTimestamp(offline, realm.isRememberMe(), 0, realm)); } - private void expire(RealmModel realm, int expired, boolean offline) { - logger.tracef("Trigger removing expired user sessions for realm '%s'", realm.getName()); - String realmId = realm.getId(); - - boolean hasMore = true; - while (hasMore) { - hasMore = KeycloakModelUtils.runJobInTransactionWithResult(session.getKeycloakSessionFactory(), - s -> executeExpirationRemoval(s, realmId, expirationBatch, offline, expired)); - } - logger.tracef("Finished expiration check for realm '%s'", realm.getName()); - } - @Override public Map getUserSessionsCountsByClients(RealmModel realm, boolean offline) { @@ -514,7 +508,7 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv logger.tracef("Adding client session %s / %s", userSession, clientSessAdapter); - userSession.getAuthenticatedClientSessions().put(getClientId(clientSessionEntity), clientSessAdapter); + userSession.getAuthenticatedClientSessions().put(JpaSessionUtil.getClientId(clientSessionEntity), clientSessAdapter); return true; } @@ -611,7 +605,7 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv private PersistentAuthenticatedClientSessionAdapter toAdapter(RealmModel realm, ClientModel client, UserSessionModel userSession, PersistentClientSessionEntity entity) { if (client == null) { // can be null if client is not found anymore - client = realm.getClientById(getClientId(entity)); + client = realm.getClientById(JpaSessionUtil.getClientId(entity)); if (client == null) { logger.debugf("Client not found for clientId %s clientStorageProvider %s externalClientId %s", entity.getClientId(), entity.getClientStorageProvider(), entity.getExternalClientId()); @@ -631,7 +625,7 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv @Override public String getClientId() { - return JpaUserSessionPersisterProvider.getClientId(entity); + return JpaSessionUtil.getClientId(entity); } @Override @@ -721,26 +715,6 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv // NOOP } - private static String offlineToString(boolean offline) { - return offline ? "1" : "0"; - } - - private static boolean offlineFromString(String offlineStr) { - return "1".equals(offlineStr); - } - - private static boolean isExternalClient(PersistentClientSessionEntity entity) { - return !entity.getExternalClientId().equals(PersistentClientSessionEntity.LOCAL); - } - - private static String getExternalClientId(PersistentClientSessionEntity entity) { - return new StorageId(entity.getClientStorageProvider(), entity.getExternalClientId()).getId(); - } - - private static String getClientId(PersistentClientSessionEntity entity) { - return isExternalClient(entity) ? getExternalClientId(entity) : entity.getClientId(); - } - private Stream fetchClientSessions(PersistentUserSessionAdapter userSession, String offlineStr) { TypedQuery clientSessionQuery = em.createNamedQuery("findClientSessionsByUserSession", PersistentClientSessionEntity.class); clientSessionQuery.setParameter("userSessionId", userSession.getId()); @@ -748,74 +722,7 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv return closing(clientSessionQuery.getResultStream() .map(entity -> toAdapter(userSession.getRealm(), null, userSession, entity)) - .filter(JpaUserSessionPersisterProvider::hasClient) + .filter(JpaSessionUtil::hasClient) ); } - - private static void createExpirationEvent(KeycloakSession session, RealmModel realm, UserSessionAndUser userSessionAndUser) { - new EventBuilder(realm, session) - .user(userSessionAndUser.userId()) - .session(userSessionAndUser.userSessionId()) - .event(EventType.USER_SESSION_DELETED) - .detail(Details.REASON, Details.EXPIRED_DETAIL) - .success(); - } - - private static boolean hasClient(PersistentAuthenticatedClientSessionAdapter clientSession) { - return clientSession.getClient() != null; - } - - private record UserSessionAndUser(String userSessionId, String userId) { - } - - private static UserSessionAndUser userSessionAndUserProjection(Object[] projection) { - assert projection.length == 2; - assert projection[0] != null; - assert projection[1] != null; - return new UserSessionAndUser(String.valueOf(projection[0]), String.valueOf(projection[1])); - } - - // returns true if it has more rows to check - private static boolean executeExpirationRemoval(KeycloakSession session, String realmId, int batchSize, boolean offline, int expired) { - EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); - RealmModel realm = session.realms().getRealm(realmId); - session.getContext().setRealm(realm); - String offlineStr = offlineToString(offline); - TypedQuery query = em.createNamedQuery("findExpiredUserSessions", Object[].class) - .setParameter("realmId", realmId) - .setParameter("lastSessionRefresh", expired) - .setParameter("offline", offlineStr) - .setHint(AvailableHints.HINT_READ_ONLY, true) - .setMaxResults(batchSize); - - var expiredSessions = query.getResultStream() - .map(JpaUserSessionPersisterProvider::userSessionAndUserProjection) - .toList(); - - if (expiredSessions.isEmpty()) { - return false; - } - - // creates the expiration events and extracts the user session IDs for the delete statement. - var sessionIds = expiredSessions.stream() - .peek(sessionAndUser -> createExpirationEvent(session, realm, sessionAndUser)) - .map(UserSessionAndUser::userSessionId) - .toList(); - - - int cs = em.createNamedQuery("deleteClientSessionsByUserSessions") - .setParameter("userSessionId", sessionIds) - .setParameter("offline", offlineStr) - .executeUpdate(); - - int us = em.createNamedQuery("deleteUserSessions") - .setParameter("userSessionId", sessionIds) - .setParameter("offline", offlineStr) - .executeUpdate(); - logger.debugf("Removed %d expired user sessions and %d expired client sessions in realm '%s'", us, cs, realm.getName()); - - // This should be safe. - // If the hits are less than the desired batch size, we should not have expired sessions. - return expiredSessions.size() >= batchSize; - } } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentUserSessionEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentUserSessionEntity.java index cbcf02e4216..8483e02ea10 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentUserSessionEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentUserSessionEntity.java @@ -42,7 +42,8 @@ import org.hibernate.annotations.DynamicUpdate; @NamedQuery(name="deleteUserSessionsByUser", query="delete from PersistentUserSessionEntity sess where sess.userId = :userId"), // The query "deleteExpiredUserSessions" is deprecated (since 26.5) and may be removed in the future. @NamedQuery(name="deleteExpiredUserSessions", query="delete from PersistentUserSessionEntity sess where sess.realmId = :realmId AND sess.offline = :offline AND sess.lastSessionRefresh < :lastSessionRefresh"), - @NamedQuery(name="deleteUserSessions", query="delete from PersistentUserSessionEntity sess where sess.offline = :offline AND sess.userSessionId IN (:userSessionId)"), + @NamedQuery(name="deleteUserSessions", query="delete from PersistentUserSessionEntity sess where sess.offline = :offline AND sess.userSessionId IN (:userSessionIds)"), + // The query "findExpiredUserSessions" is deprecated (since 26.5) and may be removed in the future. @NamedQuery(name="findExpiredUserSessions", query="select sess.userSessionId, sess.userId from PersistentUserSessionEntity sess where sess.realmId = :realmId AND sess.offline = :offline AND sess.lastSessionRefresh < :lastSessionRefresh"), @NamedQuery(name="updateUserSessionLastSessionRefresh", query="update PersistentUserSessionEntity sess set lastSessionRefresh = :lastSessionRefresh where sess.realmId = :realmId" + " AND sess.offline = :offline AND sess.userSessionId IN (:userSessionIds)"), @@ -65,7 +66,38 @@ import org.hibernate.annotations.DynamicUpdate; @NamedQuery(name="findClientSessionsClientIds", query="SELECT clientSess.clientId, clientSess.externalClientId, clientSess.clientStorageProvider, count(clientSess)" + " FROM PersistentClientSessionEntity clientSess INNER JOIN PersistentUserSessionEntity sess ON clientSess.userSessionId = sess.userSessionId AND sess.offline = clientSess.offline" + " WHERE sess.offline = :offline AND sess.realmId = :realmId AND sess.lastSessionRefresh >= :lastSessionRefresh" + - " GROUP BY clientSess.clientId, clientSess.externalClientId, clientSess.clientStorageProvider") + " GROUP BY clientSess.clientId, clientSess.externalClientId, clientSess.clientStorageProvider"), + @NamedQuery(name = "findUserSessionAndDataWithNullRememberMeLastRefresh", + query = "SELECT sess.userSessionId, 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" + + " FROM PersistentUserSessionEntity sess" + + " WHERE sess.realmId = :realmId AND sess.offline = '0' AND sess.rememberMe IS NULL AND sess.createdOn < :createdOn"), + @NamedQuery(name = "updateUserSessionRememberMeColumn", + query = "UPDATE PersistentUserSessionEntity sess" + + " SET sess.rememberMe = :rememberMe" + + " WHERE sess.userSessionId IN (:userSessionIds)"), + @NamedQuery(name = "findExpiredOfflineUserSessionsLastRefresh", + query = "SELECT sess.userSessionId, sess.userId" + + " FROM PersistentUserSessionEntity sess" + + " WHERE sess.realmId = :realmId AND sess.offline = '1' AND sess.lastSessionRefresh < :lastSessionRefresh"), + @NamedQuery(name = "findExpiredOfflineUserSessionsCreatedOn", + query = "SELECT sess.userSessionId, sess.userId" + + " FROM PersistentUserSessionEntity sess" + + " WHERE sess.realmId = :realmId AND sess.offline = '1' AND sess.createdOn < :createdOn"), + @NamedQuery(name = "findExpiredRegularUserSessionsLastRefresh", + query = "SELECT sess.userSessionId, sess.userId" + + " FROM PersistentUserSessionEntity sess" + + " WHERE sess.realmId = :realmId AND sess.offline = '0' AND sess.rememberMe = :rememberMe AND sess.lastSessionRefresh < :lastSessionRefresh"), + @NamedQuery(name = "findExpiredRegularUserSessionsCreatedOn", + 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" + + " WHERE sess.realmId = :realmId AND sess.offline = '0' AND sess.rememberMe = true"), }) @Table(name="OFFLINE_USER_SESSION") @@ -81,7 +113,7 @@ public class PersistentUserSessionEntity { @Column(name = "REALM_ID", length = 36) protected String realmId; - @Column(name="USER_ID", length = 255) + @Column(name="USER_ID") protected String userId; @Column(name = "CREATED_ON") diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/session/UserSessionAndUser.java b/model/jpa/src/main/java/org/keycloak/models/jpa/session/UserSessionAndUser.java new file mode 100644 index 00000000000..099c95999d9 --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/session/UserSessionAndUser.java @@ -0,0 +1,29 @@ +/* + * 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.jpa.session; + +record UserSessionAndUser(String userSessionId, String userId) { + + static UserSessionAndUser fromQueryProjection(Object[] projection) { + assert projection.length == 2; + assert projection[0] != null; + assert projection[1] != null; + return new UserSessionAndUser(String.valueOf(projection[0]), String.valueOf(projection[1])); + } + +} diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/session/UserSessionExpirationLogic.java b/model/jpa/src/main/java/org/keycloak/models/jpa/session/UserSessionExpirationLogic.java new file mode 100644 index 00000000000..1b5889f2d1c --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/session/UserSessionExpirationLogic.java @@ -0,0 +1,343 @@ +/* + * 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.jpa.session; + + +import java.lang.invoke.MethodHandles; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; + +import org.keycloak.connections.jpa.JpaConnectionProvider; +import org.keycloak.events.Details; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; +import org.keycloak.models.utils.RealmExpiration; +import org.keycloak.models.utils.SessionTimeoutHelper; + +import org.hibernate.jpa.AvailableHints; +import org.jboss.logging.Logger; + +import static org.keycloak.models.jpa.session.JpaSessionUtil.offlineToString; +import static org.keycloak.models.utils.KeycloakModelUtils.runJobInTransactionWithResult; + + +/** + * Moved the user session expiration logic from {@link JpaUserSessionPersisterProvider} to here. + */ +final class UserSessionExpirationLogic { + + private static final Logger logger = Logger.getLogger(MethodHandles.lookup().lookupClass()); + + private UserSessionExpirationLogic() { + } + + /** + * It expires the offline user sessions, using the {@code currentTime} and the realm's {@link RealmExpiration}. + * + * @param sessionFactory The {@link KeycloakSessionFactory}, used to start transactions. + * @param realm The {@link RealmModel} from the user session should be checked for expiration. + * @param currentTime The current timestamp, in seconds. + * @param expiration The realm's {@link RealmExpiration}. It contains the user session lifespan and max-idle + * settings. + * @param batchSize Sets the maximum number of user sessions to delete in a single transaction. + */ + public static void expireOfflineSessions(KeycloakSessionFactory sessionFactory, RealmModel realm, int currentTime, RealmExpiration expiration, int batchSize) { + long start = System.nanoTime(); + logger.tracef("Removing expired offline user sessions for realm '%s'", realm.getName()); + + final int oldestCreatedOn = realm.isOfflineSessionMaxLifespanEnabled() ? + currentTime - expiration.offlineLifespan() - SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS : + 0; + Consumer> setCreatedOn = setCreatedOn(oldestCreatedOn); + + final int oldestLastSessionRefresh = currentTime - expiration.offlineMaxIdle() - SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS; + Consumer> setLastSessionRefresh = setLastSessionRefresh(oldestLastSessionRefresh); + + String realmId = realm.getId(); + final List expiredSessions = new ArrayList<>(batchSize); + + boolean hasMore = true; + while (hasMore) { + hasMore = runJobInTransactionWithResult(sessionFactory, + s -> removeExpiredOfflineSessionsInTransaction(s, realmId, batchSize, "findExpiredOfflineUserSessionsLastRefresh", setLastSessionRefresh, 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)); + } + + /** + * It expires the regular user sessions, using the {@code currentTime} and the realm's {@link RealmExpiration}. + * + * @param sessionFactory The {@link KeycloakSessionFactory}, used to start transactions. + * @param realm The {@link RealmModel} from the user session should be checked for expiration. + * @param currentTime The current timestamp, in seconds. + * @param expiration The realm's {@link RealmExpiration}. It contains the user session lifespan and max-idle + * settings. + * @param rememberMe If {@code true}, it only checks that have remember me enabled. + * @param batchSize Sets the maximum number of user sessions to delete in a single transaction. + */ + public static void expireRegularSessions(KeycloakSessionFactory sessionFactory, RealmModel realm, int currentTime, RealmExpiration expiration, boolean rememberMe, int batchSize) { + long start = System.nanoTime(); + 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> setCreatedOn = setCreatedOn(oldestCreatedOn); + + int oldestLastSessionRefresh = currentTime - expiration.getMaxIdle(rememberMe) - SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS; + Consumer> setLastSessionRefresh = setLastSessionRefresh(oldestLastSessionRefresh); + + String realmId = realm.getId(); + final List expiredSessions = new ArrayList<>(batchSize); + + boolean hasMore = true; + while (hasMore) { + hasMore = runJobInTransactionWithResult(sessionFactory, + s -> removeExpiredRegularSessionInTransaction(s, realmId, rememberMe, batchSize, "findExpiredRegularUserSessionsLastRefresh", setLastSessionRefresh, expiredSessions)); + expiredSessions.clear(); + } + + hasMore = true; + while (hasMore) { + hasMore = runJobInTransactionWithResult(sessionFactory, + s -> removeExpiredRegularSessionInTransaction(s, realmId, rememberMe, batchSize, "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)); + } + + /** + * Migrates the remember me flag into to its own column, for an efficient query. + *

+ * It only affects regular user sessions since offline sessions do not have remember me, and only migrates sessions + * close to the expiration time to avoid concurrency issues on existing sessions. + * + * @param sessionFactory The {@link KeycloakSessionFactory}, used to start transactions. + * @param realm The {@link RealmModel} with the user session to be migrated. + * @param currentTime The current timestamp, in seconds. + * @param expiration The realm's {@link RealmExpiration}. It contains the user session lifespan and max-idle + * settings. + * @param batchSize Sets the maximum number of user sessions to update in a single transaction. + */ + public static void migrateRememberMe(KeycloakSessionFactory sessionFactory, RealmModel realm, RealmExpiration expiration, int currentTime, int batchSize) { + long start = System.nanoTime(); + final boolean rememberMeEnabledInRealm = realm.isRememberMe(); + final String realmId = realm.getId(); + final String realmName = realm.getName(); + logger.tracef("Migrating remember me value for regular user sessions, for realm '%s'", realmName); + + // migrating session, they don't need to be accurate. + final int expireMaxIdle = currentTime - Math.min(expiration.maxIdle(), expiration.rememberMeMaxIdle()); + Consumer> setLastSessionRefresh = setLastSessionRefresh(expireMaxIdle); + final int expireLifespan = currentTime - Math.min(expiration.lifespan(), expiration.rememberMeLifespan()); + Consumer> setCreatedOn = setCreatedOn(expireLifespan); + + final List sessionsWithRememberMe = new ArrayList<>(batchSize); + final List sessionsWithoutRememberMe = new ArrayList<>(batchSize); + boolean hasMore = true; + while (hasMore) { + hasMore = runJobInTransactionWithResult(sessionFactory, + s -> handleRememberMeColumnValue(s, realmId, realmName, batchSize, rememberMeEnabledInRealm, "findUserSessionAndDataWithNullRememberMeLastRefresh", setLastSessionRefresh, sessionsWithRememberMe, sessionsWithoutRememberMe)); + 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(); + } + + long duration = System.nanoTime() - start; + logger.debugf("Migration task completed for realm '%s'. Took %dms", realmName, TimeUnit.NANOSECONDS.toMillis(duration)); + } + + /** + * Removes invalid regular user sessions from the database. + *

+ * An invalid user session is a regular session with remember me column set to true, but with the remember me + * disabled in the realm settings. + * + * @param sessionFactory The {@link KeycloakSessionFactory}, used to start transactions. + * @param realm The {@link RealmModel} to check and remove invalid user sessions. + */ + public static void deleteInvalidSessions(KeycloakSessionFactory sessionFactory, RealmModel realm) { + 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(); + }); + long duration = System.nanoTime() - start; + logger.debugf("%d invalid session removed for realm '%s'. Took %dms", (Object) count, realmName, TimeUnit.NANOSECONDS.toMillis(duration)); + } + + private static boolean handleRememberMeColumnValue(KeycloakSession session, String realmId, String realmName, int batchSize, boolean rememberMeEnabled, String queryName, Consumer> setParameters, List sessionsWithRememberMeCollector, List sessionsWithoutRememberMeCollector) { + final EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); + + TypedQuery query = em.createNamedQuery(queryName, Object[].class); + setParameters.accept(query); + query.setParameter("realmId", realmId) + .setHint(AvailableHints.HINT_READ_ONLY, true) + .setMaxResults(batchSize) + .getResultStream() + .map(UserSessionIdAndRememberMe::fromQueryProjection) + .forEach(userSession -> (userSession.rememberMe() ? sessionsWithRememberMeCollector : sessionsWithoutRememberMeCollector).add(userSession.id())); + + int updateCount = updateRememberMeColumn(em, false, sessionsWithoutRememberMeCollector); + if (rememberMeEnabled) { + int rememberMeUpdateCount = updateRememberMeColumn(em, true, sessionsWithRememberMeCollector); + 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); + 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) { + new EventBuilder(realm, session) + .user(userSessionAndUser.userId()) + .session(userSessionAndUser.userSessionId()) + .event(EventType.USER_SESSION_DELETED) + .detail(Details.REASON, Details.EXPIRED_DETAIL) + .success(); + } + + // returns true if it has more rows to check + private static boolean removeExpiredOfflineSessionsInTransaction(KeycloakSession session, String realmId, int batchSize, String queryName, Consumer> setParameters, List expiredSessions) { + EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); + + TypedQuery query = em.createNamedQuery(queryName, Object[].class); + setParameters.accept(query); + query.setParameter("realmId", realmId) + .setHint(AvailableHints.HINT_READ_ONLY, true) + .setMaxResults(batchSize) + .getResultStream() + .map(UserSessionAndUser::fromQueryProjection) + .forEach(expiredSessions::add); + + handleExpirationQueryResults(session, em, realmId, expiredSessions, true); + + // 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> setParameters, List expiredSessions) { + EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); + + TypedQuery 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 expiredSessions, boolean offline) { + if (expiredSessions.isEmpty()) { + return; + } + + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + + // creates the expiration events and extracts the user session IDs for the delete statement. + var sessionIds = expiredSessions.stream() + .peek(sessionAndUser -> createExpirationEvent(session, realm, sessionAndUser)) + .map(UserSessionAndUser::userSessionId) + .toList(); + + String offlineStr = offlineToString(offline); + + int cs = em.createNamedQuery("deleteClientSessionsByUserSessions") + .setParameter("userSessionId", sessionIds) + .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()); + } + + private static int updateRememberMeColumn(EntityManager em, boolean rememberMe, Collection sessionIds) { + if (sessionIds.isEmpty()) { + return 0; + } + return em.createNamedQuery("updateUserSessionRememberMeColumn") + .setParameter("rememberMe", rememberMe) + .setParameter("userSessionIds", sessionIds) + .executeUpdate(); + } + + private static int deleteUserSessions(EntityManager em, String offlineStr, Collection sessionIds) { + if (sessionIds.isEmpty()) { + return 0; + } + return em.createNamedQuery("deleteUserSessions") + .setParameter("offline", offlineStr) + .setParameter("userSessionIds", sessionIds) + .executeUpdate(); + } + + private static Consumer> setLastSessionRefresh(int value) { + return query -> query.setParameter("lastSessionRefresh", value); + } + + private static Consumer> setCreatedOn(int value) { + return query -> query.setParameter("createdOn", value); + } +} diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/session/UserSessionIdAndRememberMe.java b/model/jpa/src/main/java/org/keycloak/models/jpa/session/UserSessionIdAndRememberMe.java new file mode 100644 index 00000000000..e60f7d636ba --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/session/UserSessionIdAndRememberMe.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.jpa.session; + +import java.io.IOException; +import java.util.Map; +import java.util.Objects; + +import org.keycloak.util.JsonSerialization; + +record UserSessionIdAndRememberMe(String id, boolean rememberMe) { + + UserSessionIdAndRememberMe { + Objects.requireNonNull(id); + } + + static UserSessionIdAndRememberMe fromQueryProjection(Object[] projection) { + assert projection.length == 2; + assert projection[0] != null; + assert projection[1] != null; + try { + String id = String.valueOf(projection[0]); + String data = String.valueOf(projection[1]); + 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); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/model/jpa/src/main/resources/META-INF/jpa-changelog-26.5.0.xml b/model/jpa/src/main/resources/META-INF/jpa-changelog-26.5.0.xml index 1a486223cc6..ce49ab31f6a 100644 --- a/model/jpa/src/main/resources/META-INF/jpa-changelog-26.5.0.xml +++ b/model/jpa/src/main/resources/META-INF/jpa-changelog-26.5.0.xml @@ -89,21 +89,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/RealmExpiration.java b/server-spi-private/src/main/java/org/keycloak/models/utils/RealmExpiration.java new file mode 100644 index 00000000000..1faecba50f6 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/RealmExpiration.java @@ -0,0 +1,119 @@ +/* + * 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.utils; + +import java.util.concurrent.TimeUnit; + +import org.keycloak.models.RealmModel; + +/** + * A record with the {@link RealmModel} expiration settings. + * + * @param lifespan The regular user session lifespan in seconds. + * @param maxIdle The regular user session max-idle in seconds. + * @param offlineLifespan The offline user session lifespan in seconds. + * @param offlineMaxIdle The offline user session max-idle in seconds. + * @param rememberMeLifespan The regular user session, in seconds, when remember me is enabled for the session. + * @param rememberMeMaxIdle the regular user session, in seconds, when remember me is enabled for the session. + */ +public record RealmExpiration(int lifespan, + int maxIdle, + int offlineLifespan, + int offlineMaxIdle, + int rememberMeLifespan, + int rememberMeMaxIdle) { + + /** + * Returns the lifespan for a regular session. + * + * @param rememberMe If the session has remember_me enabled. + * @return The computed lifespan for a regular session, in seconds. + */ + public int getLifespan(boolean rememberMe) { + return rememberMe ? rememberMeLifespan : lifespan; + } + + /** + * Returns the max-idle for a regular session. + * + * @param rememberMe If the session has remember_me enabled. + * @return The computed max-idle for a regular session, in seconds. + */ + public int getMaxIdle(boolean rememberMe) { + return rememberMe ? rememberMeMaxIdle : maxIdle; + } + + /** + * Computes the time, in milliseconds, in which the offline session is expired via max lifetime. + * + * @param created The timestamp, in milliseconds, when the session was created. + * @return The timestamp, in milliseconds, since when this session is not longer valid. + */ + public long calculateOfflineLifespanTimestamp(long created) { + return offlineLifespan == -1 ? -1 : created + TimeUnit.SECONDS.toMillis(offlineLifespan); + } + + /** + * Computes the time, in milliseconds, in which the regular session is expired via max lifetime. + * + * @param created The timestamp, in milliseconds, when the session was created. + * @param rememberMe Set to {@code true} if the session has remember me enabled. + * @return The timestamp, in milliseconds, since when this session is not longer valid. + */ + public long calculateRegularLifespanTimestamp(long created, boolean rememberMe) { + return created + TimeUnit.SECONDS.toMillis(getLifespan(rememberMe)); + } + + /** + * Computes the time, in milliseconds, in which the offline session is expired via max idle. + * + * @param lastRefresh timestamp when the session was created + * @return The timestamp, in milliseconds, since when this session is not long valid. + */ + public long calculateOfflineMaxIdleTimestamp(long lastRefresh) { + return lastRefresh + TimeUnit.SECONDS.toMillis(offlineMaxIdle); + } + + /** + * Computes the time, in milliseconds, in which the offline session is expired via max idle. + * + * @param lastRefresh timestamp when the session was created + * @param rememberMe Set to {@code true} if the session has remember me enabled. + * @return The timestamp, in milliseconds, since when this session is not long valid. + */ + public long calculateRegularMaxIdleTimestamp(long lastRefresh, boolean rememberMe) { + return lastRefresh + TimeUnit.SECONDS.toMillis(getMaxIdle(rememberMe)); + } + + /** + * Creates a new {@link RealmExpiration} instance from the {@link RealmModel} instance. + * + * @param realm The {@link RealmModel} instance to get the expiration settings. + * @return A new {@link RealmExpiration}. + */ + public static RealmExpiration fromRealm(RealmModel realm) { + int offlineMaxIdle = SessionExpirationUtils.getOfflineSessionIdleTimeout(realm); + int offlineLifespan = realm.isOfflineSessionMaxLifespanEnabled() ? SessionExpirationUtils.getOfflineSessionMaxLifespan(realm) : -1; + int maxIdle = SessionExpirationUtils.getSsoSessionIdleTimeout(realm); + int lifespan = SessionExpirationUtils.getSsoSessionMaxLifespan(realm); + int maxIdleRememberMe = Math.max(maxIdle, realm.getSsoSessionIdleTimeoutRememberMe()); + int lifespanRememberMe = Math.max(lifespan, realm.getSsoSessionMaxLifespanRememberMe()); + return new RealmExpiration(lifespan, maxIdle, offlineLifespan, offlineMaxIdle, lifespanRememberMe, maxIdleRememberMe); + } + +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/SessionExpirationUtils.java b/server-spi-private/src/main/java/org/keycloak/models/utils/SessionExpirationUtils.java index 6c4b5c6d140..2c4c996399d 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/SessionExpirationUtils.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/SessionExpirationUtils.java @@ -41,19 +41,10 @@ public class SessionExpirationUtils { * @return The time when the user session is expired or -1 if does not expire */ public static long calculateUserSessionMaxLifespanTimestamp(boolean offline, boolean isRememberMe, long created, RealmModel realm) { - long timestamp = -1; - if (offline) { - if (realm.isOfflineSessionMaxLifespanEnabled()) { - timestamp = created + TimeUnit.SECONDS.toMillis(getOfflineSessionMaxLifespan(realm)); - } - } else { - long userSessionMaxLifespan = TimeUnit.SECONDS.toMillis(getSsoSessionMaxLifespan(realm)); - if (isRememberMe) { - userSessionMaxLifespan = Math.max(userSessionMaxLifespan, TimeUnit.SECONDS.toMillis(realm.getSsoSessionMaxLifespanRememberMe())); - } - timestamp = created + userSessionMaxLifespan; - } - return timestamp; + RealmExpiration expiration = RealmExpiration.fromRealm(realm); + return offline ? + expiration.calculateOfflineLifespanTimestamp(created) : + expiration.calculateRegularLifespanTimestamp(created, isRememberMe); } /** @@ -66,17 +57,10 @@ public class SessionExpirationUtils { * @return The time in which the user session is expired by idle timeout */ public static long calculateUserSessionIdleTimestamp(boolean offline, boolean isRememberMe, long lastRefreshed, RealmModel realm) { - long timestamp; - if (offline) { - timestamp = lastRefreshed + TimeUnit.SECONDS.toMillis(getOfflineSessionIdleTimeout(realm)); - } else { - long userSessionIdleTimeout = TimeUnit.SECONDS.toMillis(getSsoSessionIdleTimeout(realm)); - if (isRememberMe) { - userSessionIdleTimeout = Math.max(userSessionIdleTimeout, TimeUnit.SECONDS.toMillis(realm.getSsoSessionIdleTimeoutRememberMe())); - } - timestamp = lastRefreshed + userSessionIdleTimeout; - } - return timestamp; + RealmExpiration expiration = RealmExpiration.fromRealm(realm); + return offline ? + expiration.calculateOfflineMaxIdleTimestamp(lastRefreshed) : + expiration.calculateRegularMaxIdleTimestamp(lastRefreshed, isRememberMe); } /** @@ -170,7 +154,7 @@ public class SessionExpirationUtils { return timestamp; } - private static int getSsoSessionMaxLifespan(RealmModel realm) { + public static int getSsoSessionMaxLifespan(RealmModel realm) { int lifespan = realm.getSsoSessionMaxLifespan(); if (lifespan <= 0) { lifespan = Constants.DEFAULT_SESSION_MAX_LIFESPAN; @@ -178,7 +162,7 @@ public class SessionExpirationUtils { return lifespan; } - private static int getOfflineSessionMaxLifespan(RealmModel realm) { + public static int getOfflineSessionMaxLifespan(RealmModel realm) { int lifespan = realm.getOfflineSessionMaxLifespan(); if (lifespan <= 0) { lifespan = Constants.DEFAULT_OFFLINE_SESSION_MAX_LIFESPAN; diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionExpirationTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionExpirationTest.java index 09916f9786b..0c7422cbf67 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionExpirationTest.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionExpirationTest.java @@ -105,16 +105,14 @@ public class UserSessionExpirationTest extends KeycloakModelTest { @Test public void testExpirationEvents() { UserSessionModel[] userSessions = inComittedTransaction(session -> { - return createSessions(session, realmId); + return createSessions(session, realmId, false); }); Map sessionIdAndUsers = Arrays.stream(userSessions) .collect(Collectors.toUnmodifiableMap(UserSessionModel::getId, s -> s.getUser().getId())); - inComittedTransaction(session -> { + withRealmConsumer(realmId, (session, realm) -> { // Time offset is automatically cleaned up in KeycloakModelTest.cleanEnvironment() Time.setOffset(IDLE_TIMEOUT + PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS + 10); - RealmModel realm = session.realms().getRealm(realmId); - session.getContext().setRealm(realm); session.getProvider(UserSessionPersisterProvider.class).removeExpired(realm); var hotRodServer = getParameters(HotRodServerRule.class).findFirst(); @@ -134,9 +132,7 @@ public class UserSessionExpirationTest extends KeycloakModelTest { Awaitility.await().until(() -> eventsCount(session) == sessionIdAndUsers.size()); }); - inComittedTransaction(session -> { - RealmModel realm = session.realms().getRealm(realmId); - session.getContext().setRealm(realm); + withRealmConsumer(realmId, (session, realm) -> { // user session id -> user id Map eventsData = events(session); Assert.assertEquals(sessionIdAndUsers, eventsData); 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 d7fa5545185..086f510085c 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 @@ -32,6 +32,7 @@ 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.function.IntFunction; import java.util.stream.IntStream; import java.util.stream.Stream; @@ -65,8 +66,8 @@ import org.keycloak.models.sessions.infinispan.PersistentUserSessionProvider; import org.keycloak.models.sessions.infinispan.changes.SessionEntityWrapper; import org.keycloak.models.sessions.infinispan.entities.AuthenticatedClientSessionEntity; import org.keycloak.models.sessions.infinispan.entities.EmbeddedClientSessionKey; +import org.keycloak.models.utils.RealmExpiration; import org.keycloak.models.utils.ResetTimeOffsetEvent; -import org.keycloak.models.utils.SessionTimeoutHelper; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory; import org.keycloak.services.managers.ClientManager; @@ -85,6 +86,7 @@ import org.junit.Test; import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME; import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.USER_SESSION_CACHE_NAME; +import static org.keycloak.models.utils.SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertEquals; @@ -852,6 +854,125 @@ public class UserSessionPersisterProviderTest extends KeycloakModelTest { withRealmConsumer(realmId, (session, realm) -> assertTrue(loadUserSessionDirectlyDatabase(session, userSessionIds.get(0)).isRememberMe())); } + @Deprecated(since = "26.5", forRemoval = true) // to be removed when remember_me is removed from the data column + @Test + public void testUserSessionRememberMeMigrationWithExpiration() { + Assume.assumeTrue(MultiSiteUtils.isPersistentSessionsEnabled()); + + RealmExpiration realmExpiration = withRealm(realmId, (session, realm) -> { + // enable remember me + realm.setRememberMe(true); + RealmExpiration expiration = RealmExpiration.fromRealm(realm); + + // double max-idle and lifespan for remember me + realm.setSsoSessionIdleTimeoutRememberMe(expiration.maxIdle() * 2); + realm.setSsoSessionMaxLifespanRememberMe(expiration.lifespan() * 2); + return expiration; + }); + + final int initialCount = getPersistedUserSessionsCount(); + final int sessionCount = JpaUserSessionPersisterProviderFactory.DEFAULT_EXPIRATION_BATCH * 2; + + createSessions(sessionCount, value -> value % 2 == 0); + assertEquals(initialCount + sessionCount, getPersistedUserSessionsCount()); + + // se the column to null. + withRealmConsumer(realmId, (session, realm) -> { + EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); + int count = em.createQuery("UPDATE PersistentUserSessionEntity sess SET sess.rememberMe = NULL WHERE sess.realmId = :realmId") + .setParameter("realmId", realmId) + .executeUpdate(); + assertEquals(sessionCount, count); + }); + + // trigger expiration, it should perform the migration + // because PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS, nothing should be removed but all session should be migrated and the remember me column must be updated. + triggerExpiration(realmExpiration.maxIdle() + 10); + assertEquals(initialCount + sessionCount, getPersistedUserSessionsCount()); + + // check if everything worked as expected. + withRealmConsumer(realmId, (session, realm) -> { + EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); + long count = em.createQuery("SELECT count(*) FROM PersistentUserSessionEntity sess WHERE sess.rememberMe IS NOT NULL AND sess.realmId = :realmId", Number.class) + .setParameter("realmId", realmId) + .getSingleResult() + .longValue(); + assertEquals(sessionCount, count); + }); + + // lets expire regular sessions + triggerExpiration(realmExpiration.maxIdle() + PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS + 10); + assertEquals(initialCount + (sessionCount / 2), getPersistedUserSessionsCount()); + + // lets expire regular sessions with remember me + triggerExpiration((realmExpiration.maxIdle() * 2) + PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS + 10); + assertEquals(initialCount, getPersistedUserSessionsCount()); + } + + @Test + public void testUserSessionWithRememberMeRemovedAfterRememberMeDisabled() { + Assume.assumeTrue(MultiSiteUtils.isPersistentSessionsEnabled()); + + RealmExpiration realmExpiration = withRealm(realmId, (session, realm) -> { + // enable remember me + realm.setRememberMe(true); + RealmExpiration expiration = RealmExpiration.fromRealm(realm); + + // double max-idle and lifespan for remember me + realm.setSsoSessionIdleTimeoutRememberMe(expiration.maxIdle() * 2); + realm.setSsoSessionMaxLifespanRememberMe(expiration.lifespan() * 2); + return expiration; + }); + + final int initialCount = getPersistedUserSessionsCount(); + final int sessionCount = JpaUserSessionPersisterProviderFactory.DEFAULT_EXPIRATION_BATCH * 2; + + createSessions(sessionCount, value -> value % 2 == 0); + assertEquals(initialCount + sessionCount, getPersistedUserSessionsCount()); + + withRealmConsumer(realmId, (session, realm) -> { + EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); + // se the column to null. + int count = em.createQuery("UPDATE PersistentUserSessionEntity sess SET sess.rememberMe = NULL WHERE sess.realmId = :realmId") + .setParameter("realmId", realmId) + .executeUpdate(); + assertEquals(sessionCount, count); + // disable remember me + realm.setRememberMe(false); + }); + + // trigger expiration, it should perform the migration + // 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()); + + // check if everything worked as expected. + withRealmConsumer(realmId, (session, realm) -> { + EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); + long count = em.createQuery("SELECT count(*) FROM PersistentUserSessionEntity sess WHERE sess.rememberMe IS NOT NULL AND sess.realmId = :realmId", Number.class) + .setParameter("realmId", realmId) + .getSingleResult() + .longValue(); + assertEquals((sessionCount / 2), count); + }); + + // lets expire regular sessions + triggerExpiration(realmExpiration.maxIdle() + PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS + 10); + assertEquals(initialCount, getPersistedUserSessionsCount()); + + // these sessions have remember me column not null. + // ensure those are deleted too. + createSessions(sessionCount, value -> value % 2 == 0); + assertEquals(initialCount + sessionCount, getPersistedUserSessionsCount()); + + // 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()); + + triggerExpiration(realmExpiration.maxIdle() + PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS + 10); + assertEquals(initialCount, getPersistedUserSessionsCount()); + } + private PersistentUserSessionEntity loadUserSessionDirectlyDatabase(KeycloakSession session, String userSessionId) { EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); return em.createNamedQuery("findUserSession", PersistentUserSessionEntity.class) @@ -863,7 +984,6 @@ public class UserSessionPersisterProviderTest extends KeycloakModelTest { .getSingleResult(); } - private UserSessionCount getUserSessionCount() { if (InfinispanUtils.isEmbeddedInfinispan()) { return MultiSiteUtils.isPersistentSessionsEnabled() ? @@ -929,8 +1049,8 @@ public class UserSessionPersisterProviderTest extends KeycloakModelTest { private long doExpirationWithSessions(int count, int initialSessionCount, long currentEventCount) { String userId = withRealm(realmId, (session, realm) -> session.users().getUserByUsername(realm, "user1").getId()); - int offset = withRealm(realmId, (session, realm) -> realm.getSsoSessionMaxLifespan() + SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS + 10); - createSessions(count); + int offset = withRealm(realmId, (session, realm) -> realm.getSsoSessionMaxLifespan() + PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS + 10); + createSessions(count, value -> false); assertEquals(count + initialSessionCount, getPersistedUserSessionsCount()); triggerExpiration(offset); assertEquals(initialSessionCount, getPersistedUserSessionsCount()); @@ -939,13 +1059,13 @@ public class UserSessionPersisterProviderTest extends KeycloakModelTest { return eventCount; } - private void createSessions(int count) { + private void createSessions(int count, IntFunction rememberMeFunction) { withRealmConsumer(realmId, (session, realm) -> { UserModel user = session.users().getUserByUsername(realm, "user1"); ClientModel client = realm.getClientByClientId("test-app"); IntStream.range(0, count) .forEach(value -> { - var us = session.sessions().createUserSession(null, realm, user, "user1", "127.0.0." + value, "form", true, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); + var us = session.sessions().createUserSession(null, realm, user, "user1", "127.0.0." + value, "form", rememberMeFunction.apply(value), null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); createClientSession(session, realmId, client, us, "http://redirect", "state"); }); }); @@ -1014,18 +1134,22 @@ public class UserSessionPersisterProviderTest extends KeycloakModelTest { } protected static UserSessionModel[] createSessions(KeycloakSession session, String realmId) { + return createSessions(session, realmId, true); + } + + protected static UserSessionModel[] createSessions(KeycloakSession session, String realmId, boolean rememberMe) { RealmModel realm = session.realms().getRealm(realmId); session.getContext().setRealm(realm); UserSessionModel[] sessions = new UserSessionModel[3]; - sessions[0] = session.sessions().createUserSession(null, realm, session.users().getUserByUsername(realm, "user1"), "user1", "127.0.0.1", "form", true, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); + sessions[0] = session.sessions().createUserSession(null, realm, session.users().getUserByUsername(realm, "user1"), "user1", "127.0.0.1", "form", rememberMe, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); createClientSession(session, realmId, realm.getClientByClientId("test-app"), sessions[0], "http://redirect", "state"); createClientSession(session, realmId, realm.getClientByClientId("third-party"), sessions[0], "http://redirect", "state"); - sessions[1] = session.sessions().createUserSession(null, realm, session.users().getUserByUsername(realm, "user1"), "user1", "127.0.0.2", "form", true, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); + sessions[1] = session.sessions().createUserSession(null, realm, session.users().getUserByUsername(realm, "user1"), "user1", "127.0.0.2", "form", rememberMe, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); createClientSession(session, realmId, realm.getClientByClientId("test-app"), sessions[1], "http://redirect", "state"); - sessions[2] = session.sessions().createUserSession(null, realm, session.users().getUserByUsername(realm, "user2"), "user2", "127.0.0.3", "form", true, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); + sessions[2] = session.sessions().createUserSession(null, realm, session.users().getUserByUsername(realm, "user2"), "user2", "127.0.0.3", "form", rememberMe, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); createClientSession(session, realmId, realm.getClientByClientId("test-app"), sessions[2], "http://redirect", "state"); return sessions;