From f4b5942c6c1b5ef2f3b86d904e8bff9c43cc96b6 Mon Sep 17 00:00:00 2001 From: mposolda Date: Tue, 19 Jan 2021 20:06:03 +0100 Subject: [PATCH] KEYCLOAK-16755 ClearExpiredUserSessions optimization. Rely on infinispan expiration rather than Keycloak own background task. --- .../java/org/keycloak/common/util/Time.java | 2 +- .../InfinispanClusterProviderFactory.java | 16 +- ...ltInfinispanConnectionProviderFactory.java | 6 +- .../AuthenticatedClientSessionAdapter.java | 7 - ...finispanAuthenticationSessionProvider.java | 26 +- .../InfinispanCodeToTokenStoreProvider.java | 5 +- .../InfinispanKeycloakTransaction.java | 9 +- ...InfinispanSingleUseTokenStoreProvider.java | 5 +- ...nfinispanTokenRevocationStoreProvider.java | 6 +- .../InfinispanUserSessionProvider.java | 162 +--------- .../InfinispanUserSessionProviderFactory.java | 40 +-- .../RootAuthenticationSessionAdapter.java | 5 +- .../InfinispanChangelogBasedTransaction.java | 32 +- .../infinispan/changes/MergedUpdate.java | 35 ++- .../infinispan/changes/SessionUpdateTask.java | 5 - .../remotestore/RemoteCacheInvoker.java | 15 +- .../RemoteCacheSessionListener.java | 76 +++-- .../infinispan/util/InfinispanUtil.java | 26 ++ .../infinispan/util/SessionTimeouts.java | 293 ++++++++++++++++++ .../ConcurrencyJDGCacheReplaceTest.java | 29 ++ .../MapRootAuthenticationSessionProvider.java | 5 + .../keycloak/models/UserSessionProvider.java | 5 + .../AuthenticationSessionProvider.java | 6 + .../scheduled/ClearExpiredUserSessions.java | 8 +- .../model/infinispan/InfinispanTestUtil.java | 94 ++++++ .../infinispan/KeycloakTestTimeService.java | 52 ++++ .../rest/TestingResourceProvider.java | 17 + .../rest/resource/TestCacheResource.java | 7 + .../main/module.xml | 1 + .../resources/TestingCacheResource.java | 9 + .../client/resources/TestingResource.java | 14 + .../crossdc/AbstractCrossDCTest.java | 24 ++ .../LastSessionRefreshCrossDCTest.java | 227 +++++++------- .../crossdc/SessionExpirationCrossDCTest.java | 51 ++- .../keycloak/testsuite/forms/LoginTest.java | 31 +- .../AuthenticationSessionProviderTest.java | 7 +- .../model/UserSessionProviderOfflineTest.java | 8 +- .../model/UserSessionProviderTest.java | 6 + .../util/InfinispanTestTimeServiceRule.java | 79 +++++ 39 files changed, 1046 insertions(+), 405 deletions(-) create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/util/SessionTimeouts.java create mode 100644 testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/model/infinispan/InfinispanTestUtil.java create mode 100644 testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/model/infinispan/KeycloakTestTimeService.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/InfinispanTestTimeServiceRule.java diff --git a/common/src/main/java/org/keycloak/common/util/Time.java b/common/src/main/java/org/keycloak/common/util/Time.java index 561dd4aaf2..76ce5f776c 100644 --- a/common/src/main/java/org/keycloak/common/util/Time.java +++ b/common/src/main/java/org/keycloak/common/util/Time.java @@ -65,7 +65,7 @@ public class Time { * @param time Time in seconds since the epoch * @return Time in milliseconds */ - public static long toMillis(int time) { + public static long toMillis(long time) { return time * 1000L; } diff --git a/model/infinispan/src/main/java/org/keycloak/cluster/infinispan/InfinispanClusterProviderFactory.java b/model/infinispan/src/main/java/org/keycloak/cluster/infinispan/InfinispanClusterProviderFactory.java index 79d835f05d..ef5bce86fa 100644 --- a/model/infinispan/src/main/java/org/keycloak/cluster/infinispan/InfinispanClusterProviderFactory.java +++ b/model/infinispan/src/main/java/org/keycloak/cluster/infinispan/InfinispanClusterProviderFactory.java @@ -19,9 +19,6 @@ package org.keycloak.cluster.infinispan; import org.infinispan.Cache; import org.infinispan.client.hotrod.exceptions.HotRodClientException; -import org.infinispan.commons.marshall.Externalizer; -import org.infinispan.commons.marshall.MarshallUtil; -import org.infinispan.commons.marshall.SerializeWith; import org.infinispan.manager.EmbeddedCacheManager; import org.infinispan.notifications.Listener; import org.infinispan.notifications.cachemanagerlistener.annotation.ViewChanged; @@ -39,26 +36,16 @@ import org.keycloak.connections.infinispan.InfinispanConnectionProvider; import org.keycloak.connections.infinispan.TopologyInfo; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; -import org.keycloak.models.sessions.infinispan.stream.RootAuthenticationSessionPredicate; import org.keycloak.models.sessions.infinispan.util.InfinispanUtil; -import org.keycloak.models.sessions.infinispan.util.KeycloakMarshallUtil; -import java.io.IOException; -import java.io.ObjectInput; -import java.io.ObjectOutput; import java.io.Serializable; import java.util.Collection; -import java.util.Iterator; -import java.util.List; -import java.util.Map; import java.util.Set; -import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; -import java.util.function.Predicate; import java.util.stream.Collectors; /** @@ -149,7 +136,8 @@ public class InfinispanClusterProviderFactory implements ClusterProviderFactory try { V result; if (taskTimeoutInSeconds > 0) { - result = (V) crossDCAwareCacheFactory.getCache().putIfAbsent(key, value, taskTimeoutInSeconds, TimeUnit.SECONDS); + long lifespanMs = InfinispanUtil.toHotrodTimeMs(crossDCAwareCacheFactory.getCache(), Time.toMillis(taskTimeoutInSeconds)); + result = (V) crossDCAwareCacheFactory.getCache().putIfAbsent(key, value, lifespanMs, TimeUnit.MILLISECONDS); } else { result = (V) crossDCAwareCacheFactory.getCache().putIfAbsent(key, value); } diff --git a/model/infinispan/src/main/java/org/keycloak/connections/infinispan/DefaultInfinispanConnectionProviderFactory.java b/model/infinispan/src/main/java/org/keycloak/connections/infinispan/DefaultInfinispanConnectionProviderFactory.java index 00ff73b09e..871c7417b7 100755 --- a/model/infinispan/src/main/java/org/keycloak/connections/infinispan/DefaultInfinispanConnectionProviderFactory.java +++ b/model/infinispan/src/main/java/org/keycloak/connections/infinispan/DefaultInfinispanConnectionProviderFactory.java @@ -185,11 +185,11 @@ public class DefaultInfinispanConnectionProviderFactory implements InfinispanCon String jgroupsUdpMcastAddr = config.get("jgroupsUdpMcastAddr", System.getProperty(InfinispanConnectionProvider.JGROUPS_UDP_MCAST_ADDR)); configureTransport(gcb, topologyInfo.getMyNodeName(), topologyInfo.getMySiteName(), jgroupsUdpMcastAddr); gcb.jmx() - .jmxDomain(InfinispanConnectionProvider.JMX_DOMAIN + "-" + topologyInfo.getMyNodeName()); + .domain(InfinispanConnectionProvider.JMX_DOMAIN + "-" + topologyInfo.getMyNodeName()).enable(); + } else { + gcb.jmx().domain(InfinispanConnectionProvider.JMX_DOMAIN).enable(); } - gcb.jmx().domain(InfinispanConnectionProvider.JMX_DOMAIN).enable(); - // For Infinispan 10, we go with the JBoss marshalling. // TODO: This should be replaced later with the marshalling recommended by infinispan. Probably protostream. // See https://infinispan.org/docs/stable/titles/developing/developing.html#marshalling for the details diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/AuthenticatedClientSessionAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/AuthenticatedClientSessionAdapter.java index c32cea2256..729aa01764 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/AuthenticatedClientSessionAdapter.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/AuthenticatedClientSessionAdapter.java @@ -47,14 +47,12 @@ public class AuthenticatedClientSessionAdapter implements AuthenticatedClientSes private final InfinispanUserSessionProvider provider; private AuthenticatedClientSessionEntity entity; private final ClientModel client; - private final InfinispanChangelogBasedTransaction userSessionUpdateTx; private final InfinispanChangelogBasedTransaction clientSessionUpdateTx; private UserSessionModel userSession; private boolean offline; public AuthenticatedClientSessionAdapter(KeycloakSession kcSession, InfinispanUserSessionProvider provider, AuthenticatedClientSessionEntity entity, ClientModel client, UserSessionModel userSession, - InfinispanChangelogBasedTransaction userSessionUpdateTx, InfinispanChangelogBasedTransaction clientSessionUpdateTx, boolean offline) { if (userSession == null) { throw new NullPointerException("userSession must not be null"); @@ -65,15 +63,10 @@ public class AuthenticatedClientSessionAdapter implements AuthenticatedClientSes this.entity = entity; this.userSession = userSession; this.client = client; - this.userSessionUpdateTx = userSessionUpdateTx; this.clientSessionUpdateTx = clientSessionUpdateTx; this.offline = offline; } - private void update(UserSessionUpdateTask task) { - userSessionUpdateTx.addTask(userSession.getId(), task); - } - private void update(ClientSessionUpdateTask task) { clientSessionUpdateTx.addTask(entity.getId(), task); } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProvider.java index 673136b141..2e2b10ea3a 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProvider.java @@ -20,6 +20,7 @@ package org.keycloak.models.sessions.infinispan; import org.keycloak.cluster.ClusterProvider; import java.util.Iterator; import java.util.Map; +import java.util.concurrent.TimeUnit; import org.infinispan.Cache; import org.jboss.logging.Logger; @@ -39,7 +40,6 @@ import org.keycloak.models.utils.RealmInfoUtil; import org.keycloak.sessions.AuthenticationSessionCompoundId; import org.keycloak.sessions.AuthenticationSessionProvider; import org.keycloak.sessions.RootAuthenticationSessionModel; -import org.infinispan.AdvancedCache; /** * @author Marek Posolda @@ -80,7 +80,8 @@ public class InfinispanAuthenticationSessionProvider implements AuthenticationSe entity.setRealmId(realm.getId()); entity.setTimestamp(Time.currentTime()); - tx.put(cache, id, entity); + int expirationSeconds = RealmInfoUtil.getDettachedClientSessionLifespan(realm); + tx.put(cache, id, entity, expirationSeconds, TimeUnit.SECONDS); return wrap(realm, entity); } @@ -94,28 +95,17 @@ public class InfinispanAuthenticationSessionProvider implements AuthenticationSe private RootAuthenticationSessionEntity getRootAuthenticationSessionEntity(String authSessionId) { // Chance created in this transaction RootAuthenticationSessionEntity entity = tx.get(cache, authSessionId); - - if (entity == null) { - entity = cache.get(authSessionId); - } - return entity; } + @Override + public void removeAllExpired() { + // Rely on expiration of cache entries provided by infinispan. Nothing needed here + } @Override public void removeExpired(RealmModel realm) { - log.debugf("Removing expired sessions"); - - int expired = Time.currentTime() - RealmInfoUtil.getDettachedClientSessionLifespan(realm); - - final AdvancedCache localCache = CacheDecorators.localCache(cache); - int localCacheSizePre = localCache.size(); - // Each cluster node cleanups just local sessions, which are those owned by himself (+ few more taking l1 cache into account) - localCache.entrySet() - .removeIf(RootAuthenticationSessionPredicate.create(realm.getId()).expired(expired)); - - log.debugf("Removed %d expired authentication sessions for realm '%s'", localCache.size() - localCacheSizePre, realm.getName()); + // Rely on expiration of cache entries provided by infinispan. Nothing needed here } @Override diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanCodeToTokenStoreProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanCodeToTokenStoreProvider.java index d4530f8af6..65dc3aa2eb 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanCodeToTokenStoreProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanCodeToTokenStoreProvider.java @@ -25,9 +25,11 @@ import java.util.function.Supplier; import org.infinispan.client.hotrod.exceptions.HotRodClientException; import org.infinispan.commons.api.BasicCache; import org.jboss.logging.Logger; +import org.keycloak.common.util.Time; import org.keycloak.models.CodeToTokenStoreProvider; import org.keycloak.models.KeycloakSession; import org.keycloak.models.sessions.infinispan.entities.ActionTokenValueEntity; +import org.keycloak.models.sessions.infinispan.util.InfinispanUtil; /** * @author Marek Posolda @@ -51,7 +53,8 @@ public class InfinispanCodeToTokenStoreProvider implements CodeToTokenStoreProvi try { BasicCache cache = codeCache.get(); - cache.put(codeId, tokenValue, lifespanSeconds, TimeUnit.SECONDS); + long lifespanMs = InfinispanUtil.toHotrodTimeMs(cache, Time.toMillis(lifespanSeconds)); + cache.put(codeId, tokenValue, lifespanMs, TimeUnit.MILLISECONDS); } catch (HotRodClientException re) { // No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened. if (logger.isDebugEnabled()) { diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanKeycloakTransaction.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanKeycloakTransaction.java index a86a6605e7..7899589fbd 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanKeycloakTransaction.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanKeycloakTransaction.java @@ -112,7 +112,7 @@ public class InfinispanKeycloakTransaction implements KeycloakTransaction { @Override public String toString() { - return String.format("CacheTaskWithValue: Operation 'put' for key %s, lifespan %d TimeUnit %s", key, lifespan, lifespanUnit.toString()); + return String.format("CacheTaskWithValue: Operation 'put' for key %s, lifespan %d TimeUnit %s", key, lifespan, lifespanUnit); } }); } @@ -142,7 +142,7 @@ public class InfinispanKeycloakTransaction implements KeycloakTransaction { } } - public void replace(Cache cache, K key, V value) { + public void replace(Cache cache, K key, V value, long lifespan, TimeUnit lifespanUnit) { log.tracev("Adding cache operation: {0} on {1}", CacheOperation.REPLACE, key); Object taskKey = getTaskKey(cache, key); @@ -155,12 +155,12 @@ public class InfinispanKeycloakTransaction implements KeycloakTransaction { tasks.put(taskKey, new CacheTaskWithValue(value) { @Override public void execute() { - decorateCache(cache).replace(key, value); + decorateCache(cache).replace(key, value, lifespan, lifespanUnit); } @Override public String toString() { - return String.format("CacheTaskWithValue: Operation 'replace' for key %s", key); + return String.format("CacheTaskWithValue: Operation 'replace' for key %s, lifespan %d TimeUnit %s", key, lifespan, lifespanUnit); } }); @@ -208,7 +208,6 @@ public class InfinispanKeycloakTransaction implements KeycloakTransaction { if (current instanceof CacheTaskWithValue) { return ((CacheTaskWithValue) current).getValue(); } - return null; } // Should we have per-transaction cache for lookups? diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanSingleUseTokenStoreProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanSingleUseTokenStoreProvider.java index 7caf185ae3..6d7f06e0cf 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanSingleUseTokenStoreProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanSingleUseTokenStoreProvider.java @@ -23,9 +23,11 @@ import java.util.function.Supplier; import org.infinispan.client.hotrod.exceptions.HotRodClientException; import org.infinispan.commons.api.BasicCache; import org.jboss.logging.Logger; +import org.keycloak.common.util.Time; import org.keycloak.models.KeycloakSession; import org.keycloak.models.SingleUseTokenStoreProvider; import org.keycloak.models.sessions.infinispan.entities.ActionTokenValueEntity; +import org.keycloak.models.sessions.infinispan.util.InfinispanUtil; /** * TODO: Check if Boolean can be used as single-use cache argument instead of ActionTokenValueEntity. With respect to other single-use cache usecases like "Revoke Refresh Token" . @@ -55,7 +57,8 @@ public class InfinispanSingleUseTokenStoreProvider implements SingleUseTokenStor try { BasicCache cache = tokenCache.get(); - ActionTokenValueEntity existing = cache.putIfAbsent(tokenId, tokenValue, lifespanInSeconds, TimeUnit.SECONDS); + long lifespanMs = InfinispanUtil.toHotrodTimeMs(cache, Time.toMillis(lifespanInSeconds)); + ActionTokenValueEntity existing = cache.putIfAbsent(tokenId, tokenValue, lifespanMs, TimeUnit.MILLISECONDS); return existing == null; } catch (HotRodClientException re) { // No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened. diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanTokenRevocationStoreProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanTokenRevocationStoreProvider.java index f48e48ef84..9ecf5c8a7f 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanTokenRevocationStoreProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanTokenRevocationStoreProvider.java @@ -20,16 +20,17 @@ package org.keycloak.models.sessions.infinispan; import java.util.Collections; import java.util.Map; -import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.function.Supplier; import org.infinispan.client.hotrod.exceptions.HotRodClientException; import org.infinispan.commons.api.BasicCache; import org.jboss.logging.Logger; +import org.keycloak.common.util.Time; import org.keycloak.models.KeycloakSession; import org.keycloak.models.TokenRevocationStoreProvider; import org.keycloak.models.sessions.infinispan.entities.ActionTokenValueEntity; +import org.keycloak.models.sessions.infinispan.util.InfinispanUtil; /** * @author Marek Posolda @@ -57,7 +58,8 @@ public class InfinispanTokenRevocationStoreProvider implements TokenRevocationSt try { BasicCache cache = tokenCache.get(); - cache.put(tokenId, tokenValue, lifespanSeconds + 1, TimeUnit.SECONDS); + long lifespanMs = InfinispanUtil.toHotrodTimeMs(cache, Time.toMillis(lifespanSeconds + 1)); + cache.put(tokenId, tokenValue, lifespanMs, TimeUnit.MILLISECONDS); } catch (HotRodClientException re) { // No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened. if (logger.isDebugEnabled()) { 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 20db30bf0a..196e1fea6e 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 @@ -53,7 +53,6 @@ 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.events.SessionEventsSenderTransaction; -import org.keycloak.models.sessions.infinispan.stream.AuthenticatedClientSessionPredicate; import org.keycloak.models.sessions.infinispan.stream.Comparators; import org.keycloak.models.sessions.infinispan.stream.Mappers; import org.keycloak.models.sessions.infinispan.stream.SessionPredicate; @@ -62,7 +61,7 @@ import org.keycloak.models.sessions.infinispan.stream.UserSessionPredicate; import org.keycloak.models.sessions.infinispan.util.FuturesHelper; import org.keycloak.models.sessions.infinispan.util.InfinispanKeyGenerator; import org.keycloak.models.sessions.infinispan.util.InfinispanUtil; -import org.keycloak.models.utils.SessionTimeoutHelper; +import org.keycloak.models.sessions.infinispan.util.SessionTimeouts; import java.io.Serializable; import java.util.Collection; @@ -132,12 +131,12 @@ public class InfinispanUserSessionProvider implements UserSessionProvider { this.offlineClientSessionCache = offlineClientSessionCache; this.loginFailureCache = loginFailureCache; - this.sessionTx = new InfinispanChangelogBasedTransaction<>(session, sessionCache, remoteCacheInvoker); - this.offlineSessionTx = new InfinispanChangelogBasedTransaction<>(session, offlineSessionCache, remoteCacheInvoker); - this.clientSessionTx = new InfinispanChangelogBasedTransaction<>(session, clientSessionCache, remoteCacheInvoker); - this.offlineClientSessionTx = new InfinispanChangelogBasedTransaction<>(session, offlineClientSessionCache, remoteCacheInvoker); + this.sessionTx = new InfinispanChangelogBasedTransaction<>(session, sessionCache, remoteCacheInvoker, SessionTimeouts::getUserSessionLifespanMs, SessionTimeouts::getUserSessionMaxIdleMs); + this.offlineSessionTx = new InfinispanChangelogBasedTransaction<>(session, offlineSessionCache, remoteCacheInvoker, SessionTimeouts::getOfflineSessionLifespanMs, SessionTimeouts::getOfflineSessionMaxIdleMs); + this.clientSessionTx = new InfinispanChangelogBasedTransaction<>(session, clientSessionCache, remoteCacheInvoker, SessionTimeouts::getClientSessionLifespanMs, SessionTimeouts::getClientSessionMaxIdleMs); + this.offlineClientSessionTx = new InfinispanChangelogBasedTransaction<>(session, offlineClientSessionCache, remoteCacheInvoker, SessionTimeouts::getOfflineClientSessionLifespanMs, SessionTimeouts::getOfflineClientSessionMaxIdleMs); - this.loginFailuresTx = new InfinispanChangelogBasedTransaction<>(session, loginFailureCache, remoteCacheInvoker); + this.loginFailuresTx = new InfinispanChangelogBasedTransaction<>(session, loginFailureCache, remoteCacheInvoker, SessionTimeouts::getLoginFailuresLifespanMs, SessionTimeouts::getLoginFailuresMaxIdleMs); this.clusterEventsSenderTx = new SessionEventsSenderTransaction(session); @@ -192,7 +191,7 @@ public class InfinispanUserSessionProvider implements UserSessionProvider { InfinispanChangelogBasedTransaction userSessionUpdateTx = getTransaction(false); InfinispanChangelogBasedTransaction clientSessionUpdateTx = getClientSessionTransaction(false); - AuthenticatedClientSessionAdapter adapter = new AuthenticatedClientSessionAdapter(session, this, entity, client, userSession, userSessionUpdateTx, clientSessionUpdateTx, false); + AuthenticatedClientSessionAdapter adapter = new AuthenticatedClientSessionAdapter(session, this, entity, client, userSession, clientSessionUpdateTx, false); // For now, the clientSession is considered transient in case that userSession was transient UserSessionModel.SessionPersistenceState persistenceState = (userSession instanceof UserSessionAdapter && ((UserSessionAdapter) userSession).getPersistenceState() != null) ? @@ -463,146 +462,19 @@ public class InfinispanUserSessionProvider implements UserSessionProvider { } } + public void removeAllExpired() { + // Rely on expiration of cache entries provided by infinispan. Just expire entries from persister is needed + // TODO: Avoid iteration over all realms here (Details in the KEYCLOAK-16802) + session.realms().getRealmsStream().forEach(this::removeExpired); + + } + @Override public void removeExpired(RealmModel realm) { - log.debugf("Removing expired sessions"); - removeExpiredUserSessions(realm); - removeExpiredOfflineUserSessions(realm); + // Rely on expiration of cache entries provided by infinispan. Nothing needed here besides calling persister session.getProvider(UserSessionPersisterProvider.class).removeExpired(realm); } - private void removeExpiredUserSessions(RealmModel realm) { - int expired = Time.currentTime() - realm.getSsoSessionMaxLifespan(); - int expiredRefresh = Time.currentTime() - realm.getSsoSessionIdleTimeout() - SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS; - int expiredRememberMe = Time.currentTime() - (realm.getSsoSessionMaxLifespanRememberMe() > 0 ? realm.getSsoSessionMaxLifespanRememberMe() : realm.getSsoSessionMaxLifespan()); - int expiredRefreshRememberMe = Time.currentTime() - (realm.getSsoSessionIdleTimeoutRememberMe() > 0 ? realm.getSsoSessionIdleTimeoutRememberMe() : realm.getSsoSessionIdleTimeout()) - - SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS; - - FuturesHelper futures = new FuturesHelper(); - - // Each cluster node cleanups just local sessions, which are those owned by itself (+ few more taking l1 cache into account) - Cache> localCache = CacheDecorators.localCache(sessionCache); - Cache> localCacheStoreIgnore = CacheDecorators.skipCacheLoaders(localCache); - - final AtomicInteger userSessionsSize = new AtomicInteger(); - final AtomicInteger clientSessionsSize = new AtomicInteger(); - - // Ignore remoteStore for stream iteration. But we will invoke remoteStore for userSession removal propagate - localCacheStoreIgnore - .entrySet() - .stream() - .filter(UserSessionPredicate.create(realm.getId()).expired(expired, expiredRefresh, expiredRememberMe, expiredRefreshRememberMe)) - .map(Mappers.userSessionEntity()) - .forEach(new Consumer() { - - @Override - public void accept(UserSessionEntity userSessionEntity) { - userSessionsSize.incrementAndGet(); - - Future future = sessionCache.removeAsync(userSessionEntity.getId()); - futures.addTask(future); - - userSessionEntity.getAuthenticatedClientSessions().forEach((clientUUID, clientSessionId) -> { - clientSessionsSize.incrementAndGet(); - Future f = clientSessionCache.removeAsync(clientSessionId); - futures.addTask(f); - }); - } - - }); - - // Removing detached clientSessions. Ignore remoteStore for stream iteration. But we will invoke remoteStore for clientSession removal propagate - Cache> localClientSessionCache = CacheDecorators.localCache(clientSessionCache); - Cache> localClientSessionCacheStoreIgnore = CacheDecorators.skipCacheLoaders(localClientSessionCache); - - localClientSessionCacheStoreIgnore - .entrySet() - .stream() - .filter(AuthenticatedClientSessionPredicate.create(realm.getId()).expired(Math.min(expired, expiredRememberMe))) - .map(Mappers.clientSessionEntity()) - .forEach(new Consumer() { - - @Override - public void accept(AuthenticatedClientSessionEntity clientSessionEntity) { - clientSessionsSize.incrementAndGet(); - - Future future = clientSessionCache.removeAsync(clientSessionEntity.getId()); - futures.addTask(future); - } - - }); - - futures.waitForAllToFinish(); - - log.debugf("Removed %d expired user sessions and %d expired client sessions for realm '%s'", userSessionsSize.get(), - clientSessionsSize.get(), realm.getName()); - } - - private void removeExpiredOfflineUserSessions(RealmModel realm) { - int expiredOffline = Time.currentTime() - realm.getOfflineSessionIdleTimeout() - SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS; - - // Each cluster node cleanups just local sessions, which are those owned by himself (+ few more taking l1 cache into account) - Cache> localCache = CacheDecorators.localCache(offlineSessionCache); - - UserSessionPredicate predicate = UserSessionPredicate.create(realm.getId()).expired(null, expiredOffline); - - FuturesHelper futures = new FuturesHelper(); - - Cache> localCacheStoreIgnore = CacheDecorators.skipCacheLoaders(localCache); - - final AtomicInteger userSessionsSize = new AtomicInteger(); - final AtomicInteger clientSessionsSize = new AtomicInteger(); - - // Ignore remoteStore for stream iteration. But we will invoke remoteStore for userSession removal propagate - localCacheStoreIgnore - .entrySet() - .stream() - .filter(predicate) - .map(Mappers.userSessionEntity()) - .forEach(new Consumer() { - - @Override - public void accept(UserSessionEntity userSessionEntity) { - userSessionsSize.incrementAndGet(); - - Future future = offlineSessionCache.removeAsync(userSessionEntity.getId()); - futures.addTask(future); - userSessionEntity.getAuthenticatedClientSessions().forEach((clientUUID, clientSessionId) -> { - clientSessionsSize.incrementAndGet(); - Future f = offlineClientSessionCache.removeAsync(clientSessionId); - futures.addTask(f); - }); - - } - }); - - // Removing detached clientSessions. Ignore remoteStore for stream iteration. But we will invoke remoteStore for clientSession removal propagate - Cache> localClientSessionCache = CacheDecorators.localCache(offlineClientSessionCache); - Cache> localClientSessionCacheStoreIgnore = CacheDecorators.skipCacheLoaders(localClientSessionCache); - - localClientSessionCacheStoreIgnore - .entrySet() - .stream() - .filter(AuthenticatedClientSessionPredicate.create(realm.getId()).expired(expiredOffline)) - .map(Mappers.clientSessionEntity()) - .forEach(new Consumer() { - - @Override - public void accept(AuthenticatedClientSessionEntity clientSessionEntity) { - clientSessionsSize.incrementAndGet(); - - Future future = offlineClientSessionCache.removeAsync(clientSessionEntity.getId()); - futures.addTask(future); - } - - }); - - futures.waitForAllToFinish(); - - log.debugf("Removed %d expired offline user sessions and %d expired offline client sessions for realm '%s'", - userSessionsSize.get(), clientSessionsSize.get(), realm.getName()); - } - @Override public void removeUserSessions(RealmModel realm) { // Don't send message to all DCs, just to all cluster nodes in current DC. The remoteCache will notify client listeners for removed userSessions. @@ -794,7 +666,7 @@ public class InfinispanUserSessionProvider implements UserSessionProvider { AuthenticatedClientSessionAdapter wrap(UserSessionModel userSession, ClientModel client, AuthenticatedClientSessionEntity entity, boolean offline) { InfinispanChangelogBasedTransaction userSessionUpdateTx = getTransaction(offline); InfinispanChangelogBasedTransaction clientSessionUpdateTx = getClientSessionTransaction(offline); - return entity != null ? new AuthenticatedClientSessionAdapter(session,this, entity, client, userSession, userSessionUpdateTx, clientSessionUpdateTx, offline) : null; + return entity != null ? new AuthenticatedClientSessionAdapter(session,this, entity, client, userSession, clientSessionUpdateTx, offline) : null; } UserLoginFailureModel wrap(LoginFailureKey key, LoginFailureEntity entity) { @@ -1041,7 +913,7 @@ public class InfinispanUserSessionProvider implements UserSessionProvider { SessionUpdateTask registerClientSessionTask = new RegisterClientSessionTask(clientSession.getClient().getId(), clientSessionId); userSessionUpdateTx.addTask(sessionToImportInto.getId(), registerClientSessionTask); - return new AuthenticatedClientSessionAdapter(session,this, entity, clientSession.getClient(), sessionToImportInto, userSessionUpdateTx, clientSessionUpdateTx, offline); + return new AuthenticatedClientSessionAdapter(session,this, entity, clientSession.getClient(), sessionToImportInto, clientSessionUpdateTx, offline); } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProviderFactory.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProviderFactory.java index dc7b465bed..97078520ab 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProviderFactory.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProviderFactory.java @@ -24,6 +24,7 @@ import org.jboss.logging.Logger; import org.keycloak.Config; import org.keycloak.cluster.ClusterProvider; import org.keycloak.common.util.Environment; +import org.keycloak.common.util.Time; import org.keycloak.connections.infinispan.InfinispanConnectionProvider; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; @@ -56,6 +57,7 @@ import org.keycloak.models.sessions.infinispan.remotestore.RemoteCacheSessionLis import org.keycloak.models.sessions.infinispan.remotestore.RemoteCacheSessionsLoader; import org.keycloak.models.sessions.infinispan.util.InfinispanKeyGenerator; import org.keycloak.models.sessions.infinispan.util.InfinispanUtil; +import org.keycloak.models.sessions.infinispan.util.SessionTimeouts; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.PostMigrationEvent; import org.keycloak.models.utils.ResetTimeOffsetEvent; @@ -65,6 +67,7 @@ import org.keycloak.provider.ProviderEventListener; import java.io.Serializable; import java.util.Set; import java.util.UUID; +import java.util.function.BiFunction; public class InfinispanUserSessionProviderFactory implements UserSessionProviderFactory { @@ -245,47 +248,48 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider InfinispanConnectionProvider ispn = session.getProvider(InfinispanConnectionProvider.class); Cache> sessionsCache = ispn.getCache(InfinispanConnectionProvider.USER_SESSION_CACHE_NAME); - boolean sessionsRemoteCache = checkRemoteCache(session, sessionsCache, (RealmModel realm) -> { + RemoteCache sessionsRemoteCache = checkRemoteCache(session, sessionsCache, (RealmModel realm) -> { // We won't write to the remoteCache during token refresh, so the timeout needs to be longer. - return realm.getSsoSessionMaxLifespan() * 1000; - }); + return Time.toMillis(realm.getSsoSessionMaxLifespan()); + }, SessionTimeouts::getUserSessionLifespanMs, SessionTimeouts::getUserSessionMaxIdleMs); - if (sessionsRemoteCache) { + if (sessionsRemoteCache != null) { lastSessionRefreshStore = new CrossDCLastSessionRefreshStoreFactory().createAndInit(session, sessionsCache, false); } Cache> clientSessionsCache = ispn.getCache(InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME); checkRemoteCache(session, clientSessionsCache, (RealmModel realm) -> { // We won't write to the remoteCache during token refresh, so the timeout needs to be longer. - return realm.getSsoSessionMaxLifespan() * 1000; - }); + return Time.toMillis(realm.getSsoSessionMaxLifespan()); + }, SessionTimeouts::getClientSessionLifespanMs, SessionTimeouts::getClientSessionMaxIdleMs); Cache> offlineSessionsCache = ispn.getCache(InfinispanConnectionProvider.OFFLINE_USER_SESSION_CACHE_NAME); - boolean offlineSessionsRemoteCache = checkRemoteCache(session, offlineSessionsCache, (RealmModel realm) -> { - return realm.getOfflineSessionIdleTimeout() * 1000; - }); + RemoteCache offlineSessionsRemoteCache = checkRemoteCache(session, offlineSessionsCache, (RealmModel realm) -> { + return Time.toMillis(realm.getOfflineSessionIdleTimeout()); + }, SessionTimeouts::getOfflineSessionLifespanMs, SessionTimeouts::getOfflineSessionMaxIdleMs); - if (offlineSessionsRemoteCache) { + if (offlineSessionsRemoteCache != null) { offlineLastSessionRefreshStore = new CrossDCLastSessionRefreshStoreFactory().createAndInit(session, offlineSessionsCache, true); } Cache> offlineClientSessionsCache = ispn.getCache(InfinispanConnectionProvider.OFFLINE_CLIENT_SESSION_CACHE_NAME); checkRemoteCache(session, offlineClientSessionsCache, (RealmModel realm) -> { - return realm.getOfflineSessionIdleTimeout() * 1000; - }); + return Time.toMillis(realm.getOfflineSessionIdleTimeout()); + }, SessionTimeouts::getOfflineClientSessionLifespanMs, SessionTimeouts::getOfflineClientSessionMaxIdleMs); Cache> loginFailuresCache = ispn.getCache(InfinispanConnectionProvider.LOGIN_FAILURE_CACHE_NAME); checkRemoteCache(session, loginFailuresCache, (RealmModel realm) -> { - return realm.getMaxDeltaTimeSeconds() * 1000; - }); + return Time.toMillis(realm.getMaxDeltaTimeSeconds()); + }, SessionTimeouts::getLoginFailuresLifespanMs, SessionTimeouts::getLoginFailuresMaxIdleMs); } - private boolean checkRemoteCache(KeycloakSession session, Cache> ispnCache, RemoteCacheInvoker.MaxIdleTimeLoader maxIdleLoader) { + private RemoteCache checkRemoteCache(KeycloakSession session, Cache> ispnCache, RemoteCacheInvoker.MaxIdleTimeLoader maxIdleLoader, + BiFunction lifespanMsLoader, BiFunction maxIdleTimeMsLoader) { Set remoteStores = InfinispanUtil.getRemoteStores(ispnCache); if (remoteStores.isEmpty()) { log.debugf("No remote store configured for cache '%s'", ispnCache.getName()); - return false; + return null; } else { log.infof("Remote store configured for cache '%s'", ispnCache.getName()); @@ -297,9 +301,9 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider remoteCacheInvoker.addRemoteCache(ispnCache.getName(), remoteCache, maxIdleLoader); - RemoteCacheSessionListener hotrodListener = RemoteCacheSessionListener.createListener(session, ispnCache, remoteCache); + RemoteCacheSessionListener hotrodListener = RemoteCacheSessionListener.createListener(session, ispnCache, remoteCache, lifespanMsLoader, maxIdleTimeMsLoader); remoteCache.addClientListener(hotrodListener); - return true; + return remoteCache; } } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/RootAuthenticationSessionAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/RootAuthenticationSessionAdapter.java index 7eaf6cfac6..e55034eac7 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/RootAuthenticationSessionAdapter.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/RootAuthenticationSessionAdapter.java @@ -19,6 +19,7 @@ package org.keycloak.models.sessions.infinispan; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.TimeUnit; import org.infinispan.Cache; import org.keycloak.common.util.Time; @@ -27,6 +28,7 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.sessions.infinispan.entities.AuthenticationSessionEntity; import org.keycloak.models.sessions.infinispan.entities.RootAuthenticationSessionEntity; +import org.keycloak.models.utils.RealmInfoUtil; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.RootAuthenticationSessionModel; @@ -52,7 +54,8 @@ public class RootAuthenticationSessionAdapter implements RootAuthenticationSessi } void update() { - provider.tx.replace(cache, entity.getId(), entity); + int expirationSeconds = RealmInfoUtil.getDettachedClientSessionLifespan(realm); + provider.tx.replace(cache, entity.getId(), entity, expirationSeconds, TimeUnit.SECONDS); } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/InfinispanChangelogBasedTransaction.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/InfinispanChangelogBasedTransaction.java index 546b4af70d..4cf5bd9676 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/InfinispanChangelogBasedTransaction.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/InfinispanChangelogBasedTransaction.java @@ -20,6 +20,7 @@ package org.keycloak.models.sessions.infinispan.changes; import java.util.HashMap; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.function.BiFunction; import org.infinispan.Cache; import org.infinispan.context.Flag; @@ -47,11 +48,17 @@ public class InfinispanChangelogBasedTransaction ext private final Map> updates = new HashMap<>(); - public InfinispanChangelogBasedTransaction(KeycloakSession kcSession, Cache> cache, RemoteCacheInvoker remoteCacheInvoker) { + private final BiFunction lifespanMsLoader; + private final BiFunction maxIdleTimeMsLoader; + + public InfinispanChangelogBasedTransaction(KeycloakSession kcSession, Cache> cache, RemoteCacheInvoker remoteCacheInvoker, + BiFunction lifespanMsLoader, BiFunction maxIdleTimeMsLoader) { this.kcSession = kcSession; this.cacheName = cache.getName(); this.cache = cache; this.remoteCacheInvoker = remoteCacheInvoker; + this.lifespanMsLoader = lifespanMsLoader; + this.maxIdleTimeMsLoader = maxIdleTimeMsLoader; } @@ -155,7 +162,10 @@ public class InfinispanChangelogBasedTransaction ext RealmModel realm = sessionUpdates.getRealm(); - MergedUpdate merged = MergedUpdate.computeUpdate(sessionUpdates.getUpdateTasks(), sessionWrapper); + long lifespanMs = lifespanMsLoader.apply(realm, sessionWrapper.getEntity()); + long maxIdleTimeMs = maxIdleTimeMsLoader.apply(realm, sessionWrapper.getEntity()); + + MergedUpdate merged = MergedUpdate.computeUpdate(sessionUpdates.getUpdateTasks(), sessionWrapper, lifespanMs, maxIdleTimeMs); if (merged != null) { // Now run the operation in our cluster @@ -185,21 +195,25 @@ public class InfinispanChangelogBasedTransaction ext case ADD: CacheDecorators.skipCacheStore(cache) .getAdvancedCache().withFlags(Flag.IGNORE_RETURN_VALUES) - .put(key, sessionWrapper, task.getLifespanMs(), TimeUnit.MILLISECONDS); + .put(key, sessionWrapper, task.getLifespanMs(), TimeUnit.MILLISECONDS, task.getMaxIdleTimeMs(), TimeUnit.MILLISECONDS); + + logger.tracef("Added entity '%s' to the cache '%s' . Lifespan: %d ms, MaxIdle: %d ms", key, cache.getName(), task.getLifespanMs(), task.getMaxIdleTimeMs()); break; case ADD_IF_ABSENT: - SessionEntityWrapper existing = CacheDecorators.skipCacheStore(cache).putIfAbsent(key, sessionWrapper); + SessionEntityWrapper existing = CacheDecorators.skipCacheStore(cache).putIfAbsent(key, sessionWrapper, task.getLifespanMs(), TimeUnit.MILLISECONDS, task.getMaxIdleTimeMs(), TimeUnit.MILLISECONDS); if (existing != null) { logger.debugf("Existing entity in cache for key: %s . Will update it", key); // Apply updates on the existing entity and replace it task.runUpdate(existing.getEntity()); - replace(key, task, existing); + replace(key, task, existing, task.getLifespanMs(), task.getMaxIdleTimeMs()); + } else { + logger.tracef("Add_if_absent successfully called for entity '%s' to the cache '%s' . Lifespan: %d ms, MaxIdle: %d ms", key, cache.getName(), task.getLifespanMs(), task.getMaxIdleTimeMs()); } break; case REPLACE: - replace(key, task, sessionWrapper); + replace(key, task, sessionWrapper, task.getLifespanMs(), task.getMaxIdleTimeMs()); break; default: throw new IllegalStateException("Unsupported state " + operation); @@ -208,7 +222,7 @@ public class InfinispanChangelogBasedTransaction ext } - private void replace(K key, MergedUpdate task, SessionEntityWrapper oldVersionEntity) { + private void replace(K key, MergedUpdate task, SessionEntityWrapper oldVersionEntity, long lifespanMs, long maxIdleTimeMs) { boolean replaced = false; int iteration = 0; V session = oldVersionEntity.getEntity(); @@ -219,7 +233,7 @@ public class InfinispanChangelogBasedTransaction ext SessionEntityWrapper newVersionEntity = generateNewVersionAndWrapEntity(session, oldVersionEntity.getLocalMetadata()); // Atomic cluster-aware replace - replaced = CacheDecorators.skipCacheStore(cache).replace(key, oldVersionEntity, newVersionEntity); + replaced = CacheDecorators.skipCacheStore(cache).replace(key, oldVersionEntity, newVersionEntity, lifespanMs, TimeUnit.MILLISECONDS, maxIdleTimeMs, TimeUnit.MILLISECONDS); // Replace fail. Need to load latest entity from cache, apply updates again and try to replace in cache again if (!replaced) { @@ -239,7 +253,7 @@ public class InfinispanChangelogBasedTransaction ext task.runUpdate(session); } else { if (logger.isTraceEnabled()) { - logger.tracef("Replace SUCCESS for entity: %s . old version: %s, new version: %s", key, oldVersionEntity.getVersion(), newVersionEntity.getVersion()); + logger.tracef("Replace SUCCESS for entity: %s . old version: %s, new version: %s, Lifespan: %d ms, MaxIdle: %d ms", key, oldVersionEntity.getVersion(), newVersionEntity.getVersion(), task.getLifespanMs(), task.getMaxIdleTimeMs()); } } } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/MergedUpdate.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/MergedUpdate.java index 8e0cf0ed82..eaa7bb562a 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/MergedUpdate.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/MergedUpdate.java @@ -20,21 +20,29 @@ package org.keycloak.models.sessions.infinispan.changes; import java.util.LinkedList; import java.util.List; +import org.jboss.logging.Logger; import org.keycloak.models.sessions.infinispan.entities.SessionEntity; +import org.keycloak.models.sessions.infinispan.util.SessionTimeouts; /** * @author Marek Posolda */ -class MergedUpdate implements SessionUpdateTask { +public class MergedUpdate implements SessionUpdateTask { - private List> childUpdates = new LinkedList<>(); + private static final Logger logger = Logger.getLogger(MergedUpdate.class); + + private final List> childUpdates = new LinkedList<>(); private CacheOperation operation; private CrossDCMessageStatus crossDCMessageStatus; + private final long lifespanMs; + private final long maxIdleTimeMs; - private MergedUpdate(CacheOperation operation, CrossDCMessageStatus crossDCMessageStatus) { + private MergedUpdate(CacheOperation operation, CrossDCMessageStatus crossDCMessageStatus, long lifespanMs, long maxIdleTimeMs) { this.operation = operation; this.crossDCMessageStatus = crossDCMessageStatus; + this.lifespanMs = lifespanMs; + this.maxIdleTimeMs = maxIdleTimeMs; } @Override @@ -54,8 +62,16 @@ class MergedUpdate implements SessionUpdateTask { return crossDCMessageStatus; } + public long getLifespanMs() { + return lifespanMs; + } - public static MergedUpdate computeUpdate(List> childUpdates, SessionEntityWrapper sessionWrapper) { + public long getMaxIdleTimeMs() { + return maxIdleTimeMs; + } + + + public static MergedUpdate computeUpdate(List> childUpdates, SessionEntityWrapper sessionWrapper, long lifespanMs, long maxIdleTimeMs) { if (childUpdates == null || childUpdates.isEmpty()) { return null; } @@ -64,14 +80,21 @@ class MergedUpdate implements SessionUpdateTask { S session = sessionWrapper.getEntity(); for (SessionUpdateTask child : childUpdates) { if (result == null) { - result = new MergedUpdate<>(child.getOperation(session), child.getCrossDCMessageStatus(sessionWrapper)); + CacheOperation operation = child.getOperation(session); + + if (lifespanMs == SessionTimeouts.ENTRY_EXPIRED_FLAG || maxIdleTimeMs == SessionTimeouts.ENTRY_EXPIRED_FLAG) { + operation = CacheOperation.REMOVE; + logger.tracef("Entry '%s' is expired. Will remove it from the cache", sessionWrapper); + } + + result = new MergedUpdate<>(operation, child.getCrossDCMessageStatus(sessionWrapper), lifespanMs, maxIdleTimeMs); result.childUpdates.add(child); } else { // Merge the operations. REMOVE is special case as other operations are not needed then. CacheOperation mergedOp = result.getOperation(session).merge(child.getOperation(session), session); if (mergedOp == CacheOperation.REMOVE) { - result = new MergedUpdate<>(child.getOperation(session), child.getCrossDCMessageStatus(sessionWrapper)); + result = new MergedUpdate<>(child.getOperation(session), child.getCrossDCMessageStatus(sessionWrapper), lifespanMs, maxIdleTimeMs); result.childUpdates.add(child); return result; } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/SessionUpdateTask.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/SessionUpdateTask.java index 244a88bd99..4a71507e5d 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/SessionUpdateTask.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/SessionUpdateTask.java @@ -30,11 +30,6 @@ public interface SessionUpdateTask { CrossDCMessageStatus getCrossDCMessageStatus(SessionEntityWrapper sessionWrapper); - default long getLifespanMs() { - return -1; - } - - enum CacheOperation { ADD, diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remotestore/RemoteCacheInvoker.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remotestore/RemoteCacheInvoker.java index faf3ce1c0c..fd27b2bda8 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remotestore/RemoteCacheInvoker.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remotestore/RemoteCacheInvoker.java @@ -32,6 +32,7 @@ import org.jboss.logging.Logger; import org.keycloak.connections.infinispan.TopologyInfo; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; +import org.keycloak.models.sessions.infinispan.changes.MergedUpdate; import org.keycloak.models.sessions.infinispan.changes.SessionEntityWrapper; import org.keycloak.models.sessions.infinispan.changes.SessionUpdateTask; import org.keycloak.models.sessions.infinispan.entities.SessionEntity; @@ -57,7 +58,7 @@ public class RemoteCacheInvoker { } - public void runTask(KeycloakSession kcSession, RealmModel realm, String cacheName, K key, SessionUpdateTask task, SessionEntityWrapper sessionWrapper) { + public void runTask(KeycloakSession kcSession, RealmModel realm, String cacheName, K key, MergedUpdate task, SessionEntityWrapper sessionWrapper) { RemoteCacheContext context = remoteCaches.get(cacheName); if (context == null) { return; @@ -104,7 +105,7 @@ public class RemoteCacheInvoker { } - private void runOnRemoteCache(TopologyInfo topology, RemoteCache> remoteCache, long maxIdleMs, K key, SessionUpdateTask task, SessionEntityWrapper sessionWrapper) { + private void runOnRemoteCache(TopologyInfo topology, RemoteCache> remoteCache, long maxIdleMs, K key, MergedUpdate task, SessionEntityWrapper sessionWrapper) { final V session = sessionWrapper.getEntity(); SessionUpdateTask.CacheOperation operation = task.getOperation(session); @@ -113,12 +114,14 @@ public class RemoteCacheInvoker { remoteCache.remove(key); break; case ADD: - remoteCache.put(key, sessionWrapper.forTransport(), task.getLifespanMs(), TimeUnit.MILLISECONDS, maxIdleMs, TimeUnit.MILLISECONDS); + remoteCache.put(key, sessionWrapper.forTransport(), + InfinispanUtil.toHotrodTimeMs(remoteCache, task.getLifespanMs()), TimeUnit.MILLISECONDS, + InfinispanUtil.toHotrodTimeMs(remoteCache, maxIdleMs), TimeUnit.MILLISECONDS); break; case ADD_IF_ABSENT: SessionEntityWrapper existing = remoteCache .withFlags(Flag.FORCE_RETURN_VALUE) - .putIfAbsent(key, sessionWrapper.forTransport(), -1, TimeUnit.MILLISECONDS, maxIdleMs, TimeUnit.MILLISECONDS); + .putIfAbsent(key, sessionWrapper.forTransport(), -1, TimeUnit.MILLISECONDS, InfinispanUtil.toHotrodTimeMs(remoteCache, maxIdleMs), TimeUnit.MILLISECONDS); if (existing != null) { logger.debugf("Existing entity in remote cache for key: %s . Will update it", key); @@ -135,6 +138,10 @@ public class RemoteCacheInvoker { private void replace(TopologyInfo topology, RemoteCache> remoteCache, long lifespanMs, long maxIdleMs, K key, SessionUpdateTask task) { + // Adjust based on the hotrod protocol + lifespanMs = InfinispanUtil.toHotrodTimeMs(remoteCache, lifespanMs); + maxIdleMs = InfinispanUtil.toHotrodTimeMs(remoteCache, maxIdleMs); + boolean replaced = false; int replaceIteration = 0; while (!replaced && replaceIteration < InfinispanUtil.MAXIMUM_REPLACE_RETRIES) { diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remotestore/RemoteCacheSessionListener.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remotestore/RemoteCacheSessionListener.java index 477a01ed45..32dc777182 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remotestore/RemoteCacheSessionListener.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remotestore/RemoteCacheSessionListener.java @@ -32,12 +32,20 @@ import org.jboss.logging.Logger; import org.keycloak.connections.infinispan.TopologyInfo; import org.keycloak.executors.ExecutorsProvider; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; import org.keycloak.models.sessions.infinispan.changes.SessionEntityWrapper; import org.keycloak.models.sessions.infinispan.entities.SessionEntity; import org.keycloak.models.sessions.infinispan.util.InfinispanUtil; import java.util.Random; import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiFunction; + import org.infinispan.client.hotrod.VersionedValue; +import org.keycloak.models.utils.KeycloakModelUtils; /** * @author Marek Posolda @@ -53,18 +61,26 @@ public class RemoteCacheSessionListener { private RemoteCache> remoteCache; private TopologyInfo topologyInfo; private ClientListenerExecutorDecorator executor; + private BiFunction lifespanMsLoader; + private BiFunction maxIdleTimeMsLoader; + private KeycloakSessionFactory sessionFactory; protected RemoteCacheSessionListener() { } - protected void init(KeycloakSession session, Cache> cache, RemoteCache> remoteCache) { + protected void init(KeycloakSession session, Cache> cache, RemoteCache> remoteCache, + BiFunction lifespanMsLoader, BiFunction maxIdleTimeMsLoader) { this.cache = cache; this.remoteCache = remoteCache; this.topologyInfo = InfinispanUtil.getTopologyInfo(session); + this.lifespanMsLoader = lifespanMsLoader; + this.maxIdleTimeMsLoader = maxIdleTimeMsLoader; + this.sessionFactory = session.getKeycloakSessionFactory(); + ExecutorService executor = session.getProvider(ExecutorsProvider.class).getExecutor("client-listener-" + cache.getName()); this.executor = new ClientListenerExecutorDecorator<>(executor); } @@ -107,8 +123,7 @@ public class RemoteCacheSessionListener { // Maybe can happen under some circumstances that remoteCache doesn't yet contain the value sent in the event (maybe just theoretically...) if (remoteSessionVersioned == null || remoteSessionVersioned.getValue() == null) { - logger.debugf("Entity '%s' not present in remoteCache. Ignoring create", - key.toString()); + logger.debugf("Entity '%s' not present in remoteCache. Ignoring create", key); return; } @@ -116,36 +131,46 @@ public class RemoteCacheSessionListener { V remoteSession = remoteSessionVersioned.getValue().getEntity(); SessionEntityWrapper newWrapper = new SessionEntityWrapper<>(remoteSession); - logger.debugf("Read session entity wrapper from the remote cache: %s", remoteSession.toString()); + logger.debugf("Read session entity wrapper from the remote cache: %s", remoteSession); - // Using putIfAbsent. Theoretic possibility that entity was already put to cache by someone else - cache.getAdvancedCache().withFlags(Flag.SKIP_CACHE_STORE, Flag.SKIP_CACHE_LOAD, Flag.IGNORE_RETURN_VALUES) - .putIfAbsent(key, newWrapper); + KeycloakModelUtils.runJobInTransaction(sessionFactory, (session -> { + + RealmModel realm = session.realms().getRealm(newWrapper.getEntity().getRealmId()); + long lifespanMs = lifespanMsLoader.apply(realm, newWrapper.getEntity()); + long maxIdleTimeMs = maxIdleTimeMsLoader.apply(realm, newWrapper.getEntity()); + + logger.tracef("Calling putIfAbsent for entity '%s' in the cache '%s' . lifespan: %d ms, maxIdleTime: %d ms", key, remoteCache.getName(), lifespanMs, maxIdleTimeMs); + + // Using putIfAbsent. Theoretic possibility that entity was already put to cache by someone else + cache.getAdvancedCache().withFlags(Flag.SKIP_CACHE_STORE, Flag.SKIP_CACHE_LOAD, Flag.IGNORE_RETURN_VALUES) + .putIfAbsent(key, newWrapper, lifespanMs, TimeUnit.MILLISECONDS, maxIdleTimeMs, TimeUnit.MILLISECONDS); + + })); } protected void replaceRemoteEntityInCache(K key, long eventVersion) { // TODO can be optimized and remoteSession sent in the event itself? - boolean replaced = false; + AtomicBoolean replaced = new AtomicBoolean(false); int replaceRetries = 0; int sleepInterval = 25; do { replaceRetries++; - + SessionEntityWrapper localEntityWrapper = cache.get(key); VersionedValue> remoteSessionVersioned = remoteCache.getWithMetadata(key); // Probably already removed if (remoteSessionVersioned == null || remoteSessionVersioned.getValue() == null) { logger.debugf("Entity '%s' not present in remoteCache. Ignoring replace", - key.toString()); + key); return; } if (remoteSessionVersioned.getVersion() < eventVersion) { try { logger.debugf("Got replace remote entity event prematurely for entity '%s', will try again. Event version: %d, got: %d", - key.toString(), eventVersion, remoteSessionVersioned == null ? -1 : remoteSessionVersioned.getVersion()); + key, eventVersion, remoteSessionVersioned == null ? -1 : remoteSessionVersioned.getVersion()); Thread.sleep(new Random().nextInt(sleepInterval)); // using exponential backoff continue; } catch (InterruptedException ex) { @@ -156,18 +181,26 @@ public class RemoteCacheSessionListener { } SessionEntity remoteSession = remoteSessionVersioned.getValue().getEntity(); - logger.debugf("Read session entity from the remote cache: %s . replaceRetries=%d", remoteSession.toString(), replaceRetries); + logger.debugf("Read session entity from the remote cache: %s . replaceRetries=%d", remoteSession, replaceRetries); SessionEntityWrapper sessionWrapper = remoteSession.mergeRemoteEntityWithLocalEntity(localEntityWrapper); - // We received event from remoteCache, so we won't update it back - replaced = cache.getAdvancedCache().withFlags(Flag.SKIP_CACHE_STORE, Flag.SKIP_CACHE_LOAD, Flag.IGNORE_RETURN_VALUES) - .replace(key, localEntityWrapper, sessionWrapper); + KeycloakModelUtils.runJobInTransaction(sessionFactory, (session -> { - if (! replaced) { - logger.debugf("Did not succeed in merging sessions, will try again: %s", remoteSession.toString()); + RealmModel realm = session.realms().getRealm(sessionWrapper.getEntity().getRealmId()); + long lifespanMs = lifespanMsLoader.apply(realm, sessionWrapper.getEntity()); + long maxIdleTimeMs = maxIdleTimeMsLoader.apply(realm, sessionWrapper.getEntity()); + + // We received event from remoteCache, so we won't update it back + replaced.set(cache.getAdvancedCache().withFlags(Flag.SKIP_CACHE_STORE, Flag.SKIP_CACHE_LOAD, Flag.IGNORE_RETURN_VALUES) + .replace(key, localEntityWrapper, sessionWrapper, lifespanMs, TimeUnit.MILLISECONDS, maxIdleTimeMs, TimeUnit.MILLISECONDS)); + + })); + + if (! replaced.get()) { + logger.debugf("Did not succeed in merging sessions, will try again: %s", remoteSession); } - } while (replaceRetries < MAXIMUM_REPLACE_RETRIES && ! replaced); + } while (replaceRetries < MAXIMUM_REPLACE_RETRIES && ! replaced.get()); } @@ -203,7 +236,7 @@ public class RemoteCacheSessionListener { result = topologyInfo.amIOwner(cache, key); } - logger.debugf("Received event from remote store. Event '%s', key '%s', skip '%b'", type.toString(), key, !result); + logger.debugf("Received event from remote store. Event '%s', key '%s', skip '%b'", type, key, !result); return result; } @@ -220,7 +253,8 @@ public class RemoteCacheSessionListener { } - public static RemoteCacheSessionListener createListener(KeycloakSession session, Cache> cache, RemoteCache> remoteCache) { + public static RemoteCacheSessionListener createListener(KeycloakSession session, Cache> cache, RemoteCache> remoteCache, + BiFunction lifespanMsLoader, BiFunction maxIdleTimeMsLoader) { /*boolean isCoordinator = InfinispanUtil.isCoordinator(cache); // Just cluster coordinator will fetch userSessions from remote cache. @@ -235,7 +269,7 @@ public class RemoteCacheSessionListener { }*/ RemoteCacheSessionListener listener = new RemoteCacheSessionListener<>(); - listener.init(session, cache, remoteCache); + listener.init(session, cache, remoteCache, lifespanMsLoader, maxIdleTimeMsLoader); return listener; } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/util/InfinispanUtil.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/util/InfinispanUtil.java index fb2ebe6934..0c0eeb0db1 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/util/InfinispanUtil.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/util/InfinispanUtil.java @@ -18,12 +18,16 @@ package org.keycloak.models.sessions.infinispan.util; import java.util.Set; +import java.util.concurrent.TimeUnit; import org.infinispan.Cache; +import org.infinispan.client.hotrod.ProtocolVersion; import org.infinispan.client.hotrod.RemoteCache; +import org.infinispan.commons.api.BasicCache; import org.infinispan.persistence.manager.PersistenceManager; import org.infinispan.persistence.remote.RemoteStore; import org.infinispan.remoting.transport.Transport; +import org.keycloak.common.util.Time; import org.keycloak.connections.infinispan.InfinispanConnectionProvider; import org.keycloak.connections.infinispan.TopologyInfo; import org.keycloak.models.KeycloakSession; @@ -66,4 +70,26 @@ public class InfinispanUtil { return transport == null || transport.isCoordinator(); } + /** + * Convert the given value to the proper value, which can be used when calling operations for the infinispan remoteCache. + * + * Infinispan HotRod protocol of versions older than 3.0 uses the "lifespan" or "maxIdle" as the normal expiration time when the value is 30 days or less. + * However for the bigger values, it assumes that the value is unix timestamp. + * + * @param ispnCache + * @param lifespanOrigMs + * @return + */ + public static long toHotrodTimeMs(BasicCache ispnCache, long lifespanOrigMs) { + if (ispnCache instanceof RemoteCache && lifespanOrigMs > 2592000000L) { + RemoteCache remoteCache = (RemoteCache) ispnCache; + ProtocolVersion protocolVersion = remoteCache.getRemoteCacheManager().getConfiguration().version(); + if (ProtocolVersion.PROTOCOL_VERSION_30.compareTo(protocolVersion) > 0) { + return Time.currentTimeMillis() + lifespanOrigMs; + } + } + + return lifespanOrigMs; + } + } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/util/SessionTimeouts.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/util/SessionTimeouts.java new file mode 100644 index 0000000000..8c69c50bea --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/util/SessionTimeouts.java @@ -0,0 +1,293 @@ +/* + * Copyright 2020 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.util; + +import org.keycloak.common.util.Time; +import org.keycloak.models.RealmModel; +import org.keycloak.models.sessions.infinispan.entities.AuthenticatedClientSessionEntity; +import org.keycloak.models.sessions.infinispan.entities.LoginFailureEntity; +import org.keycloak.models.sessions.infinispan.entities.UserSessionEntity; +import org.keycloak.models.utils.SessionTimeoutHelper; + +/** + * @author Marek Posolda + */ +public class SessionTimeouts { + + /** + * This indicates that entry is already expired and should be removed from the cache + */ + public static final long ENTRY_EXPIRED_FLAG = -2l; + + /** + * This is used just if timeouts are not set on the realm (usually happens just during tests when realm is created manually with the model API) + */ + public static final int MINIMAL_EXPIRATION_SEC = 300; + + /** + * Get the maximum lifespan, which this userSession can remain in the infinispan cache. + * Returned value will be used as "lifespan" when calling put/replace operation in the infinispan cache for this entity + * + * @param realm + * @param userSessionEntity + * @return + */ + public static long getUserSessionLifespanMs(RealmModel realm, UserSessionEntity userSessionEntity) { + int timeSinceSessionStart = Time.currentTime() - userSessionEntity.getStarted(); + + int sessionMaxLifespan = Math.max(realm.getSsoSessionMaxLifespan(), MINIMAL_EXPIRATION_SEC); + if (userSessionEntity.isRememberMe()) { + sessionMaxLifespan = Math.max(realm.getSsoSessionMaxLifespanRememberMe(), sessionMaxLifespan); + } + + long timeToExpire = sessionMaxLifespan - timeSinceSessionStart; + + // Indication that entry should be expired + if (timeToExpire <=0) { + return ENTRY_EXPIRED_FLAG; + } + + return Time.toMillis(timeToExpire); + } + + + /** + * Get the maximum idle time for this userSession. + * Returned value will be used when as "maxIdleTime" when calling put/replace operation in the infinispan cache for this entity + * + * @param realm + * @param userSessionEntity + * @return + */ + public static long getUserSessionMaxIdleMs(RealmModel realm, UserSessionEntity userSessionEntity) { + int timeSinceLastRefresh = Time.currentTime() - userSessionEntity.getLastSessionRefresh(); + + int sessionIdleMs = Math.max(realm.getSsoSessionIdleTimeout(), MINIMAL_EXPIRATION_SEC); + if (userSessionEntity.isRememberMe()) { + sessionIdleMs = Math.max(realm.getSsoSessionIdleTimeoutRememberMe(), sessionIdleMs); + } + + long maxIdleTime = sessionIdleMs - timeSinceLastRefresh + SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS; + + // Indication that entry should be expired + if (maxIdleTime <=0) { + return ENTRY_EXPIRED_FLAG; + } + + return Time.toMillis(maxIdleTime); + } + + + /** + * Get the maximum lifespan, which this clientSession can remain in the infinispan cache. + * Returned value will be used as "lifespan" when calling put/replace operation in the infinispan cache for this entity + * + * @param realm + * @param clientSessionEntity + * @return + */ + public static long getClientSessionLifespanMs(RealmModel realm, AuthenticatedClientSessionEntity clientSessionEntity) { + int timeSinceTimestampUpdate = Time.currentTime() - clientSessionEntity.getTimestamp(); + + int sessionMaxLifespan = Math.max(realm.getSsoSessionMaxLifespan(), realm.getSsoSessionMaxLifespanRememberMe()); + + // clientSession max lifespan has preference if set + if (realm.getClientSessionMaxLifespan() > 0) { + sessionMaxLifespan = realm.getClientSessionMaxLifespan(); + } + + sessionMaxLifespan = Math.max(sessionMaxLifespan, MINIMAL_EXPIRATION_SEC); + + long timeToExpire = sessionMaxLifespan - timeSinceTimestampUpdate; + + // Indication that entry should be expired + if (timeToExpire <=0) { + return ENTRY_EXPIRED_FLAG; + } + + return Time.toMillis(timeToExpire); + } + + + /** + * Get the maxIdle, which this clientSession will use. + * Returned value will be used as "maxIdle" when calling put/replace operation in the infinispan cache for this entity + * + * @param realm + * @param clientSessionEntity + * @return + */ + public static long getClientSessionMaxIdleMs(RealmModel realm, AuthenticatedClientSessionEntity clientSessionEntity) { + int timeSinceTimestampUpdate = Time.currentTime() - clientSessionEntity.getTimestamp(); + + int sessionIdleTimeout = Math.max(realm.getSsoSessionIdleTimeout(), realm.getSsoSessionIdleTimeoutRememberMe()); + + // clientSession idle timeout has preference if set + if (realm.getClientSessionIdleTimeout() > 0) { + sessionIdleTimeout = realm.getClientSessionIdleTimeout(); + } + + sessionIdleTimeout = Math.max(sessionIdleTimeout, MINIMAL_EXPIRATION_SEC); + + long timeToExpire = sessionIdleTimeout - timeSinceTimestampUpdate + SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS; + + // Indication that entry should be expired + if (timeToExpire <=0) { + return ENTRY_EXPIRED_FLAG; + } + + return Time.toMillis(timeToExpire); + } + + + /** + * Get the maximum lifespan, which this offline userSession can remain in the infinispan cache. + * Returned value will be used as "lifespan" when calling put/replace operation in the infinispan cache for this entity + * + * @param realm + * @param userSessionEntity + * @return + */ + public static long getOfflineSessionLifespanMs(RealmModel realm, UserSessionEntity userSessionEntity) { + // By default, this is disabled, so offlineSessions have just "maxIdle" + if (!realm.isOfflineSessionMaxLifespanEnabled()) return -1l; + + int timeSinceSessionStart = Time.currentTime() - userSessionEntity.getStarted(); + + int sessionMaxLifespan = Math.max(realm.getOfflineSessionMaxLifespan(), MINIMAL_EXPIRATION_SEC); + + long timeToExpire = sessionMaxLifespan - timeSinceSessionStart; + + // Indication that entry should be expired + if (timeToExpire <=0) { + return ENTRY_EXPIRED_FLAG; + } + + return Time.toMillis(timeToExpire); + } + + + /** + * Get the maximum idle time for this offline userSession. + * Returned value will be used when as "maxIdleTime" when calling put/replace operation in the infinispan cache for this entity + * + * @param realm + * @param userSessionEntity + * @return + */ + public static long getOfflineSessionMaxIdleMs(RealmModel realm, UserSessionEntity userSessionEntity) { + int timeSinceLastRefresh = Time.currentTime() - userSessionEntity.getLastSessionRefresh(); + + int sessionIdle = Math.max(realm.getOfflineSessionIdleTimeout(), MINIMAL_EXPIRATION_SEC); + + long maxIdleTime = sessionIdle - timeSinceLastRefresh + SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS; + + // Indication that entry should be expired + if (maxIdleTime <=0) { + return ENTRY_EXPIRED_FLAG; + } + + return Time.toMillis(maxIdleTime); + } + + /** + * Get the maximum lifespan, which this offline clientSession can remain in the infinispan cache. + * Returned value will be used as "lifespan" when calling put/replace operation in the infinispan cache for this entity + * + * @param realm + * @param authenticatedClientSessionEntity + * @return + */ + public static long getOfflineClientSessionLifespanMs(RealmModel realm, AuthenticatedClientSessionEntity authenticatedClientSessionEntity) { + // By default, this is disabled, so offlineSessions have just "maxIdle" + if (!realm.isOfflineSessionMaxLifespanEnabled() && realm.getClientOfflineSessionMaxLifespan() <= 0) return -1l; + + int timeSinceTimestamp = Time.currentTime() - authenticatedClientSessionEntity.getTimestamp(); + + int sessionMaxLifespan = Math.max(realm.getOfflineSessionMaxLifespan(), MINIMAL_EXPIRATION_SEC); + + // clientSession max lifespan has preference if set + if (realm.getClientOfflineSessionMaxLifespan() > 0) { + sessionMaxLifespan = realm.getClientOfflineSessionMaxLifespan(); + } + + long timeToExpire = sessionMaxLifespan - timeSinceTimestamp; + + // Indication that entry should be expired + if (timeToExpire <=0) { + return ENTRY_EXPIRED_FLAG; + } + + return Time.toMillis(timeToExpire); + } + + /** + * Get the maxIdle, which this offline clientSession will use. + * Returned value will be used as "maxIdle" when calling put/replace operation in the infinispan cache for this entity + * + * @param realm + * @param authenticatedClientSessionEntity + * @return + */ + public static long getOfflineClientSessionMaxIdleMs(RealmModel realm, AuthenticatedClientSessionEntity authenticatedClientSessionEntity) { + int timeSinceLastRefresh = Time.currentTime() - authenticatedClientSessionEntity.getTimestamp(); + + int sessionIdle = Math.max(realm.getOfflineSessionIdleTimeout(), MINIMAL_EXPIRATION_SEC); + + // clientSession idle timeout has preference if set + if (realm.getClientOfflineSessionIdleTimeout() > 0) { + sessionIdle = realm.getClientOfflineSessionIdleTimeout(); + } + + long maxIdleTime = sessionIdle - timeSinceLastRefresh + SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS; + + // Indication that entry should be expired + if (maxIdleTime <=0) { + return ENTRY_EXPIRED_FLAG; + } + + return Time.toMillis(maxIdleTime); + } + + + /** + * Not using lifespan for detached login failure (backwards compatibility with the background cleaner threads, which were used for cleanup of detached login failures) + * + * @param realm + * @param loginFailureEntity + * @return + */ + public static long getLoginFailuresLifespanMs(RealmModel realm, LoginFailureEntity loginFailureEntity) { + return -1l; + } + + + /** + * Not using maxIdle for detached login failure (backwards compatibility with the background cleaner threads, which were used for cleanup of detached login failures) + * + * @param realm + * @param loginFailureEntity + * @return + */ + public static long getLoginFailuresMaxIdleMs(RealmModel realm, LoginFailureEntity loginFailureEntity) { + return -1l; + } + + +} diff --git a/model/infinispan/src/test/java/org/keycloak/cluster/infinispan/ConcurrencyJDGCacheReplaceTest.java b/model/infinispan/src/test/java/org/keycloak/cluster/infinispan/ConcurrencyJDGCacheReplaceTest.java index e086fdfc0b..5562295527 100644 --- a/model/infinispan/src/test/java/org/keycloak/cluster/infinispan/ConcurrencyJDGCacheReplaceTest.java +++ b/model/infinispan/src/test/java/org/keycloak/cluster/infinispan/ConcurrencyJDGCacheReplaceTest.java @@ -21,6 +21,7 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import org.infinispan.Cache; @@ -154,6 +155,10 @@ public class ConcurrencyJDGCacheReplaceTest { // Create caches, listeners and finally worker threads remoteCache1 = InfinispanUtil.getRemoteCache(cache1); remoteCache2 = InfinispanUtil.getRemoteCache(cache2); + + // Manual test of lifespans + testLifespans(); + Thread worker1 = createWorker(cache1, 1); Thread worker2 = createWorker(cache2, 2); @@ -375,6 +380,30 @@ public class ConcurrencyJDGCacheReplaceTest { private enum ReplaceStatus { REPLACED, NOT_REPLACED, ERROR } + + + private static void testLifespans() throws Exception { + long l1 = InfinispanUtil.toHotrodTimeMs(remoteCache1, 5000); + long l2 = InfinispanUtil.toHotrodTimeMs(remoteCache2, 2592000000L); + long l3 = InfinispanUtil.toHotrodTimeMs(remoteCache2, 2592000001L); + //long l4 = InfinispanUtil.getLifespanMs(remoteCache1, Time.currentTimeMillis() + 5000); + + remoteCache1.put("k1", "v1", l1, TimeUnit.MILLISECONDS); + remoteCache1.put("k2", "v2", l2, TimeUnit.MILLISECONDS); + remoteCache1.put("k3", "v3", l3, TimeUnit.MILLISECONDS); + remoteCache1.put("k4", "v4", Time.currentTimeMillis() + 5000, TimeUnit.MILLISECONDS); + + System.out.println("l1=" + l1 + ", l2=" + l2 + ", l3=" + l3); + System.out.println("k1=" + remoteCache1.get("k1") + ", k2=" + remoteCache1.get("k2") + ", k3=" + remoteCache1.get("k3") + ", k4=" + remoteCache1.get("k4")); + + Thread.sleep(4000); + + System.out.println("k1=" + remoteCache1.get("k1") + ", k2=" + remoteCache1.get("k2") + ", k3=" + remoteCache1.get("k3") + ", k4=" + remoteCache1.get("k4")); + + Thread.sleep(2000); + + System.out.println("k1=" + remoteCache1.get("k1") + ", k2=" + remoteCache1.get("k2") + ", k3=" + remoteCache1.get("k3") + ", k4=" + remoteCache1.get("k4")); + } /* // Worker, which operates on "classic" cache and rely on operations delegated to the second cache private static class CacheWorker extends Thread { diff --git a/model/map/src/main/java/org/keycloak/models/map/authSession/MapRootAuthenticationSessionProvider.java b/model/map/src/main/java/org/keycloak/models/map/authSession/MapRootAuthenticationSessionProvider.java index 85925fbe4d..bcabc99115 100644 --- a/model/map/src/main/java/org/keycloak/models/map/authSession/MapRootAuthenticationSessionProvider.java +++ b/model/map/src/main/java/org/keycloak/models/map/authSession/MapRootAuthenticationSessionProvider.java @@ -131,6 +131,11 @@ public class MapRootAuthenticationSessionProvider implements AuthenticationSessi tx.delete(UUID.fromString(authenticationSession.getId())); } + @Override + public void removeAllExpired() { + session.realms().getRealmsStream().forEach(this::removeExpired); + } + @Override public void removeExpired(RealmModel realm) { Objects.requireNonNull(realm, "The provided realm can't be null!"); diff --git a/server-spi/src/main/java/org/keycloak/models/UserSessionProvider.java b/server-spi/src/main/java/org/keycloak/models/UserSessionProvider.java index 777714de8f..95bd857542 100755 --- a/server-spi/src/main/java/org/keycloak/models/UserSessionProvider.java +++ b/server-spi/src/main/java/org/keycloak/models/UserSessionProvider.java @@ -138,6 +138,11 @@ public interface UserSessionProvider extends Provider { void removeUserSession(RealmModel realm, UserSessionModel session); void removeUserSessions(RealmModel realm, UserModel user); + /** + * Remove expired user sessions and client sessions in all the realms + */ + void removeAllExpired(); + /** * Removes expired user sessions owned by this realm from this provider. * If this `UserSessionProvider` uses `UserSessionPersister`, the removal of the expired diff --git a/server-spi/src/main/java/org/keycloak/sessions/AuthenticationSessionProvider.java b/server-spi/src/main/java/org/keycloak/sessions/AuthenticationSessionProvider.java index cafb5934a9..8ea51aec61 100644 --- a/server-spi/src/main/java/org/keycloak/sessions/AuthenticationSessionProvider.java +++ b/server-spi/src/main/java/org/keycloak/sessions/AuthenticationSessionProvider.java @@ -18,6 +18,7 @@ package org.keycloak.sessions; import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.provider.Provider; @@ -71,6 +72,11 @@ public interface AuthenticationSessionProvider extends Provider { */ void removeRootAuthenticationSession(RealmModel realm, RootAuthenticationSessionModel authenticationSession); + /** + * Remove expired authentication sessions in all the realms + */ + void removeAllExpired(); + /** * Removes all expired root authentication sessions for the given realm. * @param realm {@code RealmModel} Can't be {@code null}. diff --git a/services/src/main/java/org/keycloak/services/scheduled/ClearExpiredUserSessions.java b/services/src/main/java/org/keycloak/services/scheduled/ClearExpiredUserSessions.java index e6741d9232..904dc1f6e3 100755 --- a/services/src/main/java/org/keycloak/services/scheduled/ClearExpiredUserSessions.java +++ b/services/src/main/java/org/keycloak/services/scheduled/ClearExpiredUserSessions.java @@ -20,7 +20,6 @@ package org.keycloak.services.scheduled; import org.jboss.logging.Logger; import org.keycloak.common.util.Time; import org.keycloak.models.KeycloakSession; -import org.keycloak.models.UserSessionProvider; import org.keycloak.timer.ScheduledTask; /** @@ -36,11 +35,8 @@ public class ClearExpiredUserSessions implements ScheduledTask { public void run(KeycloakSession session) { long currentTimeMillis = Time.currentTimeMillis(); - UserSessionProvider sessions = session.sessions(); - session.realms().getRealmsStream().forEach(realm -> { - sessions.removeExpired(realm); - session.authenticationSessions().removeExpired(realm); - }); + session.authenticationSessions().removeAllExpired(); + session.sessions().removeAllExpired(); long took = Time.currentTimeMillis() - currentTimeMillis; logger.debugf("ClearExpiredUserSessions finished in %d ms", took); diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/model/infinispan/InfinispanTestUtil.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/model/infinispan/InfinispanTestUtil.java new file mode 100644 index 0000000000..11a3450f8c --- /dev/null +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/model/infinispan/InfinispanTestUtil.java @@ -0,0 +1,94 @@ +/* + * Copyright 2020 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.keycloak.testsuite.model.infinispan; + +import org.infinispan.commons.time.TimeService; +import org.infinispan.factories.GlobalComponentRegistry; +import org.infinispan.factories.impl.BasicComponentRegistry; +import org.infinispan.factories.impl.ComponentRef; +import org.infinispan.manager.EmbeddedCacheManager; +import org.jboss.logging.Logger; +import org.keycloak.connections.infinispan.InfinispanConnectionProvider; +import org.keycloak.models.KeycloakSession; + +/** + * @author Marek Posolda + */ +public class InfinispanTestUtil { + + protected static final Logger logger = Logger.getLogger(InfinispanTestUtil.class); + + private static TimeService origTimeService = null; + + /** + * Set Keycloak test TimeService to infinispan cacheManager. This will cause that infinispan will be aware of Keycloak Time offset, which is useful + * for testing that infinispan entries are expired after moving Keycloak time forward with {@link org.keycloak.common.util.Time#setOffset} . + */ + public static void setTestingTimeService(KeycloakSession session) { + // Testing timeService already set. This shouldn't happen if this utility is properly used + if (origTimeService != null) { + throw new IllegalStateException("Calling setTestingTimeService when testing TimeService was already set"); + } + + logger.info("Will set KeycloakIspnTimeService to the infinispan cacheManager"); + + InfinispanConnectionProvider ispnProvider = session.getProvider(InfinispanConnectionProvider.class); + EmbeddedCacheManager cacheManager = ispnProvider.getCache(InfinispanConnectionProvider.USER_SESSION_CACHE_NAME).getCacheManager(); + origTimeService = replaceComponent(cacheManager, TimeService.class, new KeycloakTestTimeService(), true); + } + + public static void revertTimeService(KeycloakSession session) { + // Testing timeService not set. This shouldn't happen if this utility is properly used + if (origTimeService == null) { + throw new IllegalStateException("Calling revertTimeService when testing TimeService was not set"); + } + + logger.info("Revert set KeycloakIspnTimeService to the infinispan cacheManager"); + + InfinispanConnectionProvider ispnProvider = session.getProvider(InfinispanConnectionProvider.class); + EmbeddedCacheManager cacheManager = ispnProvider.getCache(InfinispanConnectionProvider.USER_SESSION_CACHE_NAME).getCacheManager(); + replaceComponent(cacheManager, TimeService.class, origTimeService, true); + origTimeService = null; + } + + + /** + * Forked from org.infinispan.test.TestingUtil class + * + * Replaces a component in a running cache manager (global component registry). + * + * @param cacheMgr cache in which to replace component + * @param componentType component type of which to replace + * @param replacementComponent new instance + * @param rewire if true, ComponentRegistry.rewire() is called after replacing. + * + * @return the original component that was replaced + */ + private static T replaceComponent(EmbeddedCacheManager cacheMgr, Class componentType, T replacementComponent, boolean rewire) { + GlobalComponentRegistry cr = cacheMgr.getGlobalComponentRegistry(); + BasicComponentRegistry bcr = cr.getComponent(BasicComponentRegistry.class); + ComponentRef old = bcr.getComponent(componentType); + bcr.replaceComponent(componentType.getName(), replacementComponent, true); + if (rewire) { + cr.rewire(); + cr.rewireNamedRegistries(); + } + return old != null ? old.wired() : null; + } +} diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/model/infinispan/KeycloakTestTimeService.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/model/infinispan/KeycloakTestTimeService.java new file mode 100644 index 0000000000..475bfee7ba --- /dev/null +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/model/infinispan/KeycloakTestTimeService.java @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.keycloak.testsuite.model.infinispan; + +import java.time.Instant; +import java.util.concurrent.TimeUnit; + +import org.infinispan.util.EmbeddedTimeService; +import org.keycloak.common.util.Time; + +/** + * Infinispan TimeService, which delegates to Keycloak Time.currentTime to figure current time. Useful for testing purposes. + * + * @author Marek Posolda + */ +public class KeycloakTestTimeService extends EmbeddedTimeService { + + private long getCurrentTimeMillis() { + return Time.currentTimeMillis(); + } + + @Override + public long wallClockTime() { + return getCurrentTimeMillis(); + } + + @Override + public long time() { + return TimeUnit.MILLISECONDS.toNanos(getCurrentTimeMillis()); + } + + @Override + public Instant instant() { + return Instant.ofEpochMilli(getCurrentTimeMillis()); + } +} diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java index 9a81c32bd2..a2eb0d26c2 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java @@ -66,6 +66,7 @@ import org.keycloak.testsuite.events.TestEventsListenerProvider; import org.keycloak.testsuite.federation.DummyUserFederationProviderFactory; import org.keycloak.testsuite.forms.PassThroughAuthenticator; import org.keycloak.testsuite.forms.PassThroughClientAuthenticator; +import org.keycloak.testsuite.model.infinispan.InfinispanTestUtil; import org.keycloak.testsuite.rest.representation.AuthenticatorState; import org.keycloak.testsuite.rest.resource.TestCacheResource; import org.keycloak.testsuite.rest.resource.TestJavascriptResource; @@ -181,6 +182,22 @@ public class TestingResourceProvider implements RealmResourceProvider { return Response.noContent().build(); } + @POST + @Path("/set-testing-infinispan-time-service") + @Produces(MediaType.APPLICATION_JSON) + public Response setTestingInfinispanTimeService() { + InfinispanTestUtil.setTestingTimeService(session); + return Response.noContent().build(); + } + + @POST + @Path("/revert-testing-infinispan-time-service") + @Produces(MediaType.APPLICATION_JSON) + public Response revertTestingInfinispanTimeService() { + InfinispanTestUtil.revertTimeService(session); + return Response.noContent().build(); + } + @GET @Path("/get-client-sessions-count") @Produces(MediaType.APPLICATION_JSON) diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/resource/TestCacheResource.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/resource/TestCacheResource.java index cadd2a3d7c..3b70c63454 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/resource/TestCacheResource.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/resource/TestCacheResource.java @@ -107,6 +107,13 @@ public class TestCacheResource { cache.remove(id); } + @POST + @Path("/process-expiration") + @Produces(MediaType.APPLICATION_JSON) + public void processExpiration() { + cache.getAdvancedCache().getExpirationManager().processExpiration(); + } + @GET @Path("/jgroups-stats") @Produces(MediaType.APPLICATION_JSON) diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/org/keycloak/testsuite/integration-arquillian-testsuite-providers/main/module.xml b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/org/keycloak/testsuite/integration-arquillian-testsuite-providers/main/module.xml index 00d0bc8df5..0d54eb7d67 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/org/keycloak/testsuite/integration-arquillian-testsuite-providers/main/module.xml +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/org/keycloak/testsuite/integration-arquillian-testsuite-providers/main/module.xml @@ -39,6 +39,7 @@ + diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestingCacheResource.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestingCacheResource.java index 07c3a2347b..e3f0a21cf6 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestingCacheResource.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestingCacheResource.java @@ -66,6 +66,15 @@ public interface TestingCacheResource { @Produces(MediaType.APPLICATION_JSON) void removeKey(@PathParam("id") String id); + /** + * Enforce calling of the expiration on the particular infinispan cache. This will immediately expire the expired cache entries, so that they won't be available in the cache. + * Without calling this, expired entries would be removed by the infinispan expiration (probably by infinispan periodic background cleaner task) + */ + @POST + @Path("/process-expiration") + @Produces(MediaType.APPLICATION_JSON) + void processExpiration(); + @GET @Path("/jgroups-stats") @Produces(MediaType.APPLICATION_JSON) diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestingResource.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestingResource.java index ca3fb7e40f..fd69ecacad 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestingResource.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestingResource.java @@ -196,6 +196,20 @@ public interface TestingResource { @Produces(MediaType.APPLICATION_JSON) void removeExpired(@QueryParam("realm") final String realm); + /** + * Will set {@link org.keycloak.testsuite.model.infinispan.KeycloakTestTimeService} to the infinispan CacheManager before the test. + * This will allow infinispan expiration to be aware of Keycloak {@link org.keycloak.common.util.Time#setOffset} + */ + @POST + @Path("/set-testing-infinispan-time-service") + @Produces(MediaType.APPLICATION_JSON) + void setTestingInfinispanTimeService(); + + @POST + @Path("/revert-testing-infinispan-time-service") + @Produces(MediaType.APPLICATION_JSON) + void revertTestingInfinispanTimeService(); + @GET @Path("/get-client-sessions-count") @Produces(MediaType.APPLICATION_JSON) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/AbstractCrossDCTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/AbstractCrossDCTest.java index 8b92f702eb..e5cb495e89 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/AbstractCrossDCTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/AbstractCrossDCTest.java @@ -37,6 +37,8 @@ import org.keycloak.testsuite.arquillian.CrossDCTestEnricher; import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer; import org.keycloak.testsuite.arquillian.annotation.InitialDcState; +import static org.keycloak.testsuite.arquillian.CrossDCTestEnricher.forAllBackendNodesStream; + /** * Abstract cross-data-centre test that defines primitives for handling cross-DC setup. @@ -221,4 +223,26 @@ public abstract class AbstractCrossDCTest extends AbstractTestRealmKeycloakTest super.resetTimeOffset(); setTimeOffsetOnAllStartedContainers(0); } + + protected void setInfinispanTestTimeServiceOnAllStartedAuthServers() { + forAllBackendNodesStream() + .filter(ContainerInfo::isStarted) + .forEach(this::setInfinispanTestTimeServiceonAuthServer); + } + + private void setInfinispanTestTimeServiceonAuthServer(ContainerInfo backendAuthServer) { + log.infof("Set Infinispan Test Time Service for backend server %s", backendAuthServer.getQualifier()); + getTestingClientFor(backendAuthServer).testing().setTestingInfinispanTimeService(); + } + + protected void revertInfinispanTestTimeServiceOnAllStartedAuthServers() { + forAllBackendNodesStream() + .filter(ContainerInfo::isStarted) + .forEach(this::revertInfinispanTestTimeServiceonAuthServer); + } + + private void revertInfinispanTestTimeServiceonAuthServer(ContainerInfo backendAuthServer) { + log.infof("Revert Infinispan Test Time Service for backend server %s", backendAuthServer.getQualifier()); + getTestingClientFor(backendAuthServer).testing().revertTestingInfinispanTimeService(); + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/LastSessionRefreshCrossDCTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/LastSessionRefreshCrossDCTest.java index fca0da1e3a..ca64e888fe 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/LastSessionRefreshCrossDCTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/LastSessionRefreshCrossDCTest.java @@ -124,115 +124,127 @@ public class LastSessionRefreshCrossDCTest extends AbstractAdminCrossDCTest { @JmxInfinispanCacheStatistics(dc=DC.SECOND, managementPortProperty = "cache.server.2.management.port", cacheName=InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME) InfinispanStatistics clientSessionCacheDc2Stats ) { + // Set the infinispan testTimeService on all started auth servers + setInfinispanTestTimeServiceOnAllStartedAuthServers(); - // Ensure to remove all current sessions and offline sessions - setTimeOffset(10000000); - getTestingClientForStartedNodeInDc(0).testing("test").removeExpired("test"); - setTimeOffset(0); - - sessionCacheDc1Stats.reset(); - sessionCacheDc2Stats.reset(); - clientSessionCacheDc1Stats.reset(); - clientSessionCacheDc2Stats.reset(); - - // Disable DC2 on loadbalancer - disableDcOnLoadBalancer(DC.SECOND); - - // Get statistics - AtomicLong sessionStoresDc1 = new AtomicLong(getStores(sessionCacheDc1Stats)); - AtomicLong sessionStoresDc2 = new AtomicLong(getStores(sessionCacheDc2Stats)); - AtomicLong clientSessionStoresDc1 = new AtomicLong(getStores(clientSessionCacheDc1Stats)); - AtomicLong clientSessionStoresDc2 = new AtomicLong(getStores(clientSessionCacheDc2Stats)); - AtomicInteger lsrDc1 = new AtomicInteger(-1); - AtomicInteger lsrDc2 = new AtomicInteger(-1); - - // Login - OAuthClient.AuthorizationEndpointResponse response1 = oauth.doLogin("test-user@localhost", "password"); - String code = response1.getCode(); - OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password"); - Assert.assertNotNull(tokenResponse.getAccessToken()); - String sessionId = oauth.verifyToken(tokenResponse.getAccessToken()).getSessionState(); - String refreshToken1 = tokenResponse.getRefreshToken(); - - // Assert statistics - sessions created on both DCs and created on remoteCaches too - assertStatistics("After session created", sessionId, sessionCacheDc1Stats, sessionCacheDc2Stats, clientSessionCacheDc1Stats, clientSessionCacheDc2Stats, - sessionStoresDc1, sessionStoresDc2, clientSessionStoresDc1, clientSessionStoresDc2, - lsrDc1, lsrDc2, true, true, true, false); - - - // Set time offset - setTimeOffset(100); - - // refresh token on DC1 - tokenResponse = oauth.doRefreshTokenRequest(refreshToken1, "password"); - String refreshToken3 = tokenResponse.getRefreshToken(); - Assert.assertNotNull(refreshToken3); - - // Assert statistics - sessions updated on both DC1 and DC2. RemoteCaches not updated - assertStatistics("After refresh at time 100", sessionId, sessionCacheDc1Stats, sessionCacheDc2Stats, clientSessionCacheDc1Stats, clientSessionCacheDc2Stats, - sessionStoresDc1, sessionStoresDc2, clientSessionStoresDc1, clientSessionStoresDc2, - lsrDc1, lsrDc2, true, true, false, false); - - - // Set time offset - setTimeOffset(110); - - // refresh token on DC1 - tokenResponse = oauth.doRefreshTokenRequest(refreshToken1, "password"); - String refreshToken2 = tokenResponse.getRefreshToken(); - Assert.assertNotNull(refreshToken2); - - // Assert statistics - sessions updated just on DC1. - // Update of DC2 is postponed (It's just 10 seconds since last message). RemoteCaches not updated - assertStatistics("After refresh at time 110", sessionId, sessionCacheDc1Stats, sessionCacheDc2Stats, clientSessionCacheDc1Stats, clientSessionCacheDc2Stats, - sessionStoresDc1, sessionStoresDc2, clientSessionStoresDc1, clientSessionStoresDc2, - lsrDc1, lsrDc2, true, false, false, false); - - - // 31 minutes after "100". Session should be still valid and not yet expired (RefreshToken will be invalid due the expiration on the JWT itself. Hence not testing refresh here) - setTimeOffset(1960); - - boolean sessionValid = getTestingClientForStartedNodeInDc(1).server("test").fetch((KeycloakSession session) -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserSessionModel userSession = session.sessions().getUserSession(realm, sessionId); - return AuthenticationManager.isSessionValid(realm, userSession); - }, Boolean.class); - - Assert.assertTrue(sessionValid); - - getTestingClientForStartedNodeInDc(1).testing("test").removeExpired("test"); - - // Assert statistics - nothing was updated. No refresh happened and nothing was cleared during "removeExpired" - assertStatistics("After checking valid at time 1960", sessionId, sessionCacheDc1Stats, sessionCacheDc2Stats, clientSessionCacheDc1Stats, clientSessionCacheDc2Stats, - sessionStoresDc1, sessionStoresDc2, clientSessionStoresDc1, clientSessionStoresDc2, - lsrDc1, lsrDc2, false, false, false, false); - - - // 35 minutes after "100". Session not valid and will be expired by the cleaner - setTimeOffset(2200); - - sessionValid = getTestingClientForStartedNodeInDc(1).server("test").fetch((KeycloakSession session) -> { - RealmModel realm = session.realms().getRealmByName("test"); - UserSessionModel userSession = session.sessions().getUserSession(realm, sessionId); - return AuthenticationManager.isSessionValid(realm, userSession); - }, Boolean.class); - - Assert.assertFalse(sessionValid); - - getTestingClientForStartedNodeInDc(1).testing("test").removeExpired("test"); - - // Session should be removed on both DCs try { - getTestingClientForStartedNodeInDc(0).testing("test").getLastSessionRefresh("test", sessionId, false); - Assert.fail("It wasn't expected to find the session " + sessionId); - } catch (NotFoundException nfe) { - // Expected - } - try { - getTestingClientForStartedNodeInDc(1).testing("test").getLastSessionRefresh("test", sessionId, false); - Assert.fail("It wasn't expected to find the session " + sessionId); - } catch (NotFoundException nfe) { - // Expected + // Ensure to remove all current sessions and offline sessions + setTimeOffset(10000000); + getTestingClientForStartedNodeInDc(0).testing("test").removeExpired("test"); + getTestingClientForStartedNodeInDc(1).testing("test").removeExpired("test"); + setTimeOffset(0); + + sessionCacheDc1Stats.reset(); + sessionCacheDc2Stats.reset(); + clientSessionCacheDc1Stats.reset(); + clientSessionCacheDc2Stats.reset(); + + // Disable DC2 on loadbalancer + disableDcOnLoadBalancer(DC.SECOND); + + // Get statistics + AtomicLong sessionStoresDc1 = new AtomicLong(getStores(sessionCacheDc1Stats)); + AtomicLong sessionStoresDc2 = new AtomicLong(getStores(sessionCacheDc2Stats)); + AtomicLong clientSessionStoresDc1 = new AtomicLong(getStores(clientSessionCacheDc1Stats)); + AtomicLong clientSessionStoresDc2 = new AtomicLong(getStores(clientSessionCacheDc2Stats)); + AtomicInteger lsrDc1 = new AtomicInteger(-1); + AtomicInteger lsrDc2 = new AtomicInteger(-1); + + // Login + OAuthClient.AuthorizationEndpointResponse response1 = oauth.doLogin("test-user@localhost", "password"); + String code = response1.getCode(); + OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password"); + Assert.assertNotNull(tokenResponse.getAccessToken()); + String sessionId = oauth.verifyToken(tokenResponse.getAccessToken()).getSessionState(); + String refreshToken1 = tokenResponse.getRefreshToken(); + + // Assert statistics - sessions created on both DCs and created on remoteCaches too + assertStatistics("After session created", sessionId, sessionCacheDc1Stats, sessionCacheDc2Stats, clientSessionCacheDc1Stats, clientSessionCacheDc2Stats, + sessionStoresDc1, sessionStoresDc2, clientSessionStoresDc1, clientSessionStoresDc2, + lsrDc1, lsrDc2, true, true, true, false); + + + // Set time offset + setTimeOffset(100); + + // refresh token on DC1 + tokenResponse = oauth.doRefreshTokenRequest(refreshToken1, "password"); + String refreshToken2 = tokenResponse.getRefreshToken(); + Assert.assertNotNull(refreshToken2); + + // Assert statistics - sessions updated on both DC1 and DC2. RemoteCaches not updated + assertStatistics("After refresh at time 100", sessionId, sessionCacheDc1Stats, sessionCacheDc2Stats, clientSessionCacheDc1Stats, clientSessionCacheDc2Stats, + sessionStoresDc1, sessionStoresDc2, clientSessionStoresDc1, clientSessionStoresDc2, + lsrDc1, lsrDc2, true, true, false, false); + + + // Set time offset + setTimeOffset(110); + + // refresh token on DC1 + tokenResponse = oauth.doRefreshTokenRequest(refreshToken1, "password"); + String refreshToken3 = tokenResponse.getRefreshToken(); + Assert.assertNotNull(refreshToken3); + + // Assert statistics - sessions updated just on DC1. + // Update of DC2 is postponed (It's just 10 seconds since last message). RemoteCaches not updated + assertStatistics("After refresh at time 110", sessionId, sessionCacheDc1Stats, sessionCacheDc2Stats, clientSessionCacheDc1Stats, clientSessionCacheDc2Stats, + sessionStoresDc1, sessionStoresDc2, clientSessionStoresDc1, clientSessionStoresDc2, + lsrDc1, lsrDc2, true, false, false, false); + + + // 31 minutes after "100". Session should be still valid and not yet expired (RefreshToken will be invalid due the expiration on the JWT itself. Hence not testing refresh here) + setTimeOffset(1960); + + boolean sessionValid = getTestingClientForStartedNodeInDc(1).server("test").fetch((KeycloakSession session) -> { + RealmModel realm = session.realms().getRealmByName("test"); + UserSessionModel userSession = session.sessions().getUserSession(realm, sessionId); + return AuthenticationManager.isSessionValid(realm, userSession); + }, Boolean.class); + + Assert.assertTrue(sessionValid); + + getTestingClientForStartedNodeInDc(1).testing("test").removeExpired("test"); + + // Assert statistics - nothing was updated. No refresh happened and nothing was cleared during "removeExpired" + assertStatistics("After checking valid at time 1960", sessionId, sessionCacheDc1Stats, sessionCacheDc2Stats, clientSessionCacheDc1Stats, clientSessionCacheDc2Stats, + sessionStoresDc1, sessionStoresDc2, clientSessionStoresDc1, clientSessionStoresDc2, + lsrDc1, lsrDc2, false, false, false, false); + + + // 35 minutes after "100". Session not valid and will be expired by the cleaner + setTimeOffset(2200); + + sessionValid = getTestingClientForStartedNodeInDc(1).server("test").fetch((KeycloakSession session) -> { + RealmModel realm = session.realms().getRealmByName("test"); + UserSessionModel userSession = session.sessions().getUserSession(realm, sessionId); + return AuthenticationManager.isSessionValid(realm, userSession); + }, Boolean.class); + + Assert.assertFalse(sessionValid); + + // 2000 seconds after the previous. This should ensure that session would be expired from the cache due the invalid maxIdle. + // Previous read at time 2200 "refreshed" the maxIdle in the infinispan cache. This shouldn't happen in reality as an attempt to call refreshToken request on invalid session does backchannelLogout + setTimeOffset(4200); + + getTestingClientForStartedNodeInDc(1).testing("test").removeExpired("test"); + + // Session should be removed on both DCs + try { + getTestingClientForStartedNodeInDc(0).testing("test").getLastSessionRefresh("test", sessionId, false); + Assert.fail("It wasn't expected to find the session " + sessionId); + } catch (NotFoundException nfe) { + // Expected + } + try { + getTestingClientForStartedNodeInDc(1).testing("test").getLastSessionRefresh("test", sessionId, false); + Assert.fail("It wasn't expected to find the session " + sessionId); + } catch (NotFoundException nfe) { + // Expected + } + } finally { + // Revert time service + revertInfinispanTestTimeServiceOnAllStartedAuthServers(); } } @@ -248,6 +260,7 @@ public class LastSessionRefreshCrossDCTest extends AbstractAdminCrossDCTest { // Ensure to remove all current sessions and offline sessions setTimeOffset(10000000); getTestingClientForStartedNodeInDc(0).testing("test").removeExpired("test"); + getTestingClientForStartedNodeInDc(1).testing("test").removeExpired("test"); setTimeOffset(0); sessionCacheDc1Stats.reset(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/SessionExpirationCrossDCTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/SessionExpirationCrossDCTest.java index 5164fa0db1..69d292d59c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/SessionExpirationCrossDCTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/SessionExpirationCrossDCTest.java @@ -27,6 +27,7 @@ import java.util.concurrent.atomic.AtomicInteger; import javax.ws.rs.NotFoundException; import org.hamcrest.Matchers; +import org.junit.After; import org.junit.Before; import org.junit.Test; import org.keycloak.OAuth2Constants; @@ -99,12 +100,30 @@ public class SessionExpirationCrossDCTest extends AbstractAdminCrossDCTest { .build(); RealmRepresentation realmRep = RealmBuilder.create() + .ssoSessionIdleTimeout(300) + .ssoSessionMaxLifespan(600) + .offlineSessionIdleTimeout(900) .name(REALM_NAME) .user(user) .client(client) .build(); adminClient.realms().create(realmRep); + + setInfinispanTestTimeServiceOnAllStartedAuthServers(); + } + + @After + public void afterTest() { + // Expire all sessions + setTimeOffset(1500); + getTestingClientForStartedNodeInDc(0).testing().cache(InfinispanConnectionProvider.USER_SESSION_CACHE_NAME).processExpiration(); + getTestingClientForStartedNodeInDc(0).testing().cache(InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME).processExpiration(); + getTestingClientForStartedNodeInDc(0).testing().cache(InfinispanConnectionProvider.OFFLINE_USER_SESSION_CACHE_NAME).processExpiration(); + getTestingClientForStartedNodeInDc(0).testing().cache(InfinispanConnectionProvider.OFFLINE_CLIENT_SESSION_CACHE_NAME).processExpiration(); + resetTimeOffset(); + + revertInfinispanTestTimeServiceOnAllStartedAuthServers(); } @@ -195,7 +214,7 @@ public class SessionExpirationCrossDCTest extends AbstractAdminCrossDCTest { int remoteSessions2 = (Integer) cacheDc2Statistics.getSingleStatistics(InfinispanStatistics.Constants.STAT_CACHE_NUMBER_OF_ENTRIES); // Needs to use "received_messages" on Infinispan 9.2.4.Final. Stats for "sent_messages" is always null long messagesCount = (Long) channelStatisticsCrossDc.getSingleStatistics(InfinispanStatistics.Constants.STAT_CHANNEL_RECEIVED_MESSAGES); - log.infof(messagePrefix + ": sessions1: %d, sessions2: %d, remoteSessions1: %d, remoteSessions2: %d, sentMessages: %d", sessions1, sessions2, remoteSessions1, remoteSessions2, messagesCount); + log.infof(messagePrefix + ": sessions1: %d, sessions2: %d, clientSessions1: %d, clientSessions2: %d, remoteSessions1: %d, remoteSessions2: %d, sentMessages: %d", sessions1, sessions2, clientSessions1, clientSessions2, remoteSessions1, remoteSessions2, messagesCount); Assert.assertEquals(sessions1, sessions1Expected); Assert.assertEquals(sessions2, sessions2Expected); @@ -275,8 +294,8 @@ public class SessionExpirationCrossDCTest extends AbstractAdminCrossDCTest { remoteSessions01 + SESSIONS_COUNT, remoteSessions02 + SESSIONS_COUNT, false); - // Set time offset - setTimeOffset(10000000); + // Increase offset to 10 minutes (SSO Session Idle is 5 minutes for the realm). To make sure that admin session from master realm won't expire + setTimeOffset(610); // Assert I am not able to refresh anymore refreshResponse = oauth.doRefreshTokenRequest(lastAccessTokenResponse.getRefreshToken(), "password"); @@ -287,7 +306,8 @@ public class SessionExpirationCrossDCTest extends AbstractAdminCrossDCTest { channelStatisticsCrossDc.reset(); // Remove expired in DC0 - getTestingClientForStartedNodeInDc(0).testing().removeExpired(REALM_NAME); + getTestingClientForStartedNodeInDc(0).testing().cache(InfinispanConnectionProvider.USER_SESSION_CACHE_NAME).processExpiration(); + getTestingClientForStartedNodeInDc(0).testing().cache(InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME).processExpiration(); // Assert sessions removed on node1 and node2 and on remote caches. assertStatisticsExpected("After remove expired - 2", InfinispanConnectionProvider.USER_SESSION_CACHE_NAME, InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME, cacheDc1Statistics, cacheDc2Statistics, channelStatisticsCrossDc, @@ -312,7 +332,7 @@ public class SessionExpirationCrossDCTest extends AbstractAdminCrossDCTest { channelStatisticsCrossDc.reset(); // Remove expired in DC0 - getTestingClientForStartedNodeInDc(0).testing().removeExpired(REALM_NAME); + getTestingClientForStartedNodeInDc(0).testing().cache(InfinispanConnectionProvider.OFFLINE_USER_SESSION_CACHE_NAME).processExpiration(); // Nothing yet expired. It may happen that no message sent between DCs assertStatisticsExpected("After remove expired - 1", InfinispanConnectionProvider.OFFLINE_USER_SESSION_CACHE_NAME, InfinispanConnectionProvider.OFFLINE_CLIENT_SESSION_CACHE_NAME, @@ -321,8 +341,8 @@ public class SessionExpirationCrossDCTest extends AbstractAdminCrossDCTest { remoteSessions01 + SESSIONS_COUNT, remoteSessions02 + SESSIONS_COUNT, false); - // Set time offset - setTimeOffset(10000000); + // Increase offset to 20 minutes. Client offline sessions should be expired from infinispan by that. Master sessions will still remain there. + setTimeOffset(1210); // Assert I am not able to refresh anymore refreshResponse = oauth.doRefreshTokenRequest(lastAccessTokenResponse.getRefreshToken(), "password"); @@ -333,7 +353,8 @@ public class SessionExpirationCrossDCTest extends AbstractAdminCrossDCTest { channelStatisticsCrossDc.reset(); // Remove expired in DC0 - getTestingClientForStartedNodeInDc(0).testing().removeExpired(REALM_NAME); + getTestingClientForStartedNodeInDc(0).testing().cache(InfinispanConnectionProvider.OFFLINE_USER_SESSION_CACHE_NAME).processExpiration(); + getTestingClientForStartedNodeInDc(0).testing().cache(InfinispanConnectionProvider.OFFLINE_CLIENT_SESSION_CACHE_NAME).processExpiration(); // Assert sessions removed on node1 and node2 and on remote caches. assertStatisticsExpected("After remove expired - 2", InfinispanConnectionProvider.OFFLINE_USER_SESSION_CACHE_NAME, InfinispanConnectionProvider.OFFLINE_CLIENT_SESSION_CACHE_NAME, @@ -555,12 +576,11 @@ public class SessionExpirationCrossDCTest extends AbstractAdminCrossDCTest { getTestingClientForStartedNodeInDc(0).testing().cache(InfinispanConnectionProvider.USER_SESSION_CACHE_NAME).removeKey(userSessionId); } - // Increase offset to big value like 100 hours - setTimeOffset(360000); + // Increase offset to 10 minutes (SSO Session Idle is 5 minutes for the realm). To make sure that admin session from master realm won't expire + setTimeOffset(610); // Trigger removeExpired - getTestingClientForStartedNodeInDc(0).testing().removeExpired(REALM_NAME); - getTestingClientForStartedNodeInDc(1).testing().removeExpired(REALM_NAME); + getTestingClientForStartedNodeInDc(0).testing().cache(InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME).processExpiration(); // Ensure clientSessions were removed assertStatisticsExpected("After remove expired", InfinispanConnectionProvider.USER_SESSION_CACHE_NAME, InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME, @@ -586,12 +606,11 @@ public class SessionExpirationCrossDCTest extends AbstractAdminCrossDCTest { getTestingClientForStartedNodeInDc(0).testing().cache(InfinispanConnectionProvider.OFFLINE_USER_SESSION_CACHE_NAME).removeKey(userSessionId); } - // Increase offset to big value like 10000 hours (400+ days) - setTimeOffset(36000000); + // Increase offset to 20 minutes. Client offline sessions should be expired from infinispan by that. Master sessions will still remain there. + setTimeOffset(1210); // Trigger removeExpired - getTestingClientForStartedNodeInDc(0).testing().removeExpired(REALM_NAME); - getTestingClientForStartedNodeInDc(1).testing().removeExpired(REALM_NAME); + getTestingClientForStartedNodeInDc(0).testing().cache(InfinispanConnectionProvider.OFFLINE_CLIENT_SESSION_CACHE_NAME).processExpiration(); // Ensure clientSessions were removed assertStatisticsExpected("After remove expired", InfinispanConnectionProvider.OFFLINE_USER_SESSION_CACHE_NAME, InfinispanConnectionProvider.OFFLINE_CLIENT_SESSION_CACHE_NAME, diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LoginTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LoginTest.java index cf40f0db6f..f02a0fd794 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LoginTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LoginTest.java @@ -770,23 +770,28 @@ public class LoginTest extends AbstractTestRealmKeycloakTest { // KEYCLOAK-1037 @Test public void loginExpiredCodeWithExplicitRemoveExpired() { - loginPage.open(); - setTimeOffset(5000); - // Explicitly call "removeExpired". Hence authSession won't exist, but will be restarted from the KC_RESTART - testingClient.testing().removeExpired("test"); + getTestingClient().testing().setTestingInfinispanTimeService(); - loginPage.login("login@test.com", "password"); + try { + loginPage.open(); + setTimeOffset(5000); + // Explicitly call "removeExpired". Hence authSession won't exist, but will be restarted from the KC_RESTART + testingClient.testing().removeExpired("test"); - //loginPage.assertCurrent(); - loginPage.assertCurrent(); + loginPage.login("login@test.com", "password"); - Assert.assertEquals("Your login attempt timed out. Login will start from the beginning.", loginPage.getError()); - setTimeOffset(0); + //loginPage.assertCurrent(); + loginPage.assertCurrent(); - events.expectLogin().user((String) null).session((String) null).error(Errors.EXPIRED_CODE).clearDetails() - .detail(Details.RESTART_AFTER_TIMEOUT, "true") - .client((String) null) - .assertEvent(); + Assert.assertEquals("Your login attempt timed out. Login will start from the beginning.", loginPage.getError()); + + events.expectLogin().user((String) null).session((String) null).error(Errors.EXPIRED_CODE).clearDetails() + .detail(Details.RESTART_AFTER_TIMEOUT, "true") + .client((String) null) + .assertEvent(); + } finally { + getTestingClient().testing().revertTestingInfinispanTimeService(); + } } @Test diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/model/AuthenticationSessionProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/model/AuthenticationSessionProviderTest.java index b6110b9549..303959d81c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/model/AuthenticationSessionProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/model/AuthenticationSessionProviderTest.java @@ -19,6 +19,7 @@ package org.keycloak.testsuite.model; import org.junit.After; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.keycloak.common.util.Time; import org.keycloak.models.ClientModel; @@ -45,7 +46,8 @@ import static org.hamcrest.core.IsNull.notNullValue; import static org.hamcrest.core.IsNull.nullValue; import static org.junit.Assert.assertThat; import org.keycloak.models.Constants; -import org.keycloak.models.RoleModel; +import org.keycloak.testsuite.util.InfinispanTestTimeServiceRule; + import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE; /** @@ -54,6 +56,9 @@ import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerEx @AuthServerContainerExclude(REMOTE) public class AuthenticationSessionProviderTest extends AbstractTestRealmKeycloakTest { + @Rule + public InfinispanTestTimeServiceRule ispnTestTimeService = new InfinispanTestTimeServiceRule(this); + @Before public void before() { testingClient.server().run(session -> { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/model/UserSessionProviderOfflineTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/model/UserSessionProviderOfflineTest.java index 0ed1d24e63..39c0d7caf6 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/model/UserSessionProviderOfflineTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/model/UserSessionProviderOfflineTest.java @@ -20,6 +20,7 @@ package org.keycloak.testsuite.model; import org.junit.After; import org.junit.Assert; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.keycloak.common.util.Time; import org.keycloak.models.AuthenticatedClientSessionModel; @@ -41,6 +42,7 @@ import org.keycloak.services.managers.UserSessionManager; import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; import org.keycloak.testsuite.arquillian.annotation.ModelTest; +import org.keycloak.testsuite.util.InfinispanTestTimeServiceRule; import org.keycloak.timer.TimerProvider; import java.util.HashMap; @@ -61,6 +63,10 @@ import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.A */ @AuthServerContainerExclude(AuthServer.REMOTE) public class UserSessionProviderOfflineTest extends AbstractTestRealmKeycloakTest { + + @Rule + public InfinispanTestTimeServiceRule ispnTestTimeService = new InfinispanTestTimeServiceRule(this); + private static KeycloakSession currentSession; private static RealmModel realm; private static UserSessionManager sessionManager; @@ -545,7 +551,7 @@ public class UserSessionProviderOfflineTest extends AbstractTestRealmKeycloakTes Assert.assertEquals(1, persister.getUserSessionsCount(true)); // Expire everything and assert nothing found - Time.setOffset(6000000); + Time.setOffset(7000000); currentSession.sessions().removeExpired(realm); persister.removeExpired(realm); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/model/UserSessionProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/model/UserSessionProviderTest.java index 8709e9d799..6c94f5755e 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/model/UserSessionProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/model/UserSessionProviderTest.java @@ -20,6 +20,7 @@ package org.keycloak.testsuite.model; import org.junit.After; import org.junit.Assert; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.keycloak.common.util.Time; import org.keycloak.models.AuthenticatedClientSessionModel; @@ -57,6 +58,7 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer; +import org.keycloak.testsuite.util.InfinispanTestTimeServiceRule; /** * @author Stian Thorgersen @@ -64,6 +66,9 @@ import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.A @AuthServerContainerExclude(AuthServer.REMOTE) public class UserSessionProviderTest extends AbstractTestRealmKeycloakTest { + @Rule + public InfinispanTestTimeServiceRule ispnTestTimeService = new InfinispanTestTimeServiceRule(this); + public static void setupRealm(KeycloakSession session){ RealmModel realm = session.realms().getRealmByName("test"); UserModel user1 = session.users().addUser(realm, "user1"); @@ -469,6 +474,7 @@ public class UserSessionProviderTest extends AbstractTestRealmKeycloakTest { assertEquals(userSession, clientSession.getUserSession()); Time.setOffset(-(realm.getSsoSessionIdleTimeout() * 2)); userSession.setLastSessionRefresh(Time.currentTime()); + clientSession.setTimestamp(Time.currentTime()); validUserSessions.add(userSession.getId()); validClientSessions.add(clientSession.getId()); }); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/InfinispanTestTimeServiceRule.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/InfinispanTestTimeServiceRule.java new file mode 100644 index 0000000000..14122dd286 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/InfinispanTestTimeServiceRule.java @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.keycloak.testsuite.util; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.jboss.logging.Logger; +import org.junit.rules.ExternalResource; +import org.keycloak.testsuite.AbstractKeycloakTest; +import org.keycloak.testsuite.arquillian.ContainerInfo; +import org.keycloak.testsuite.arquillian.CrossDCTestEnricher; + +import static org.keycloak.testsuite.arquillian.CrossDCTestEnricher.forAllBackendNodesStream; + +/** + * @author Marek Posolda + */ +public class InfinispanTestTimeServiceRule extends ExternalResource { + + private static final Logger log = Logger.getLogger(InfinispanTestTimeServiceRule.class); + + private final AbstractKeycloakTest test; + + public InfinispanTestTimeServiceRule(AbstractKeycloakTest test) { + this.test = test; + } + + @Override + protected void before() throws Throwable { + if (!this.test.getTestContext().getSuiteContext().isAuthServerCrossDc()) { + // No cross-dc environment + test.getTestingClient().testing().setTestingInfinispanTimeService(); + } else { + AtomicInteger count = new AtomicInteger(0); + // Cross-dc environment - Set on all started nodes + forAllBackendNodesStream() + .filter(ContainerInfo::isStarted) + .map(CrossDCTestEnricher.getBackendTestingClients()::get) + .forEach(testingClient -> { + testingClient.testing().setTestingInfinispanTimeService(); + count.incrementAndGet(); + }); + + // + log.infof("Totally set infinispanTimeService rule in %d servers", count.get()); + } + } + + @Override + protected void after() { + if (!this.test.getTestContext().getSuiteContext().isAuthServerCrossDc()) { + // No cross-dc environment + test.getTestingClient().testing().revertTestingInfinispanTimeService(); + } else { + // Cross-dc environment - Revert on all started nodes + forAllBackendNodesStream() + .filter(ContainerInfo::isStarted) + .map(CrossDCTestEnricher.getBackendTestingClients()::get) + .forEach(testingClient -> testingClient.testing().revertTestingInfinispanTimeService()); + + } + } +}