mirror of
https://github.com/keycloak/keycloak.git
synced 2025-12-16 20:15:46 -06:00
Add new option to schedule user session expiration
Closes #44068 Signed-off-by: Pedro Ruivo <1492066+pruivo@users.noreply.github.com> Signed-off-by: Alexander Schwartz <alexander.schwartz@ibm.com> Signed-off-by: Ryan Emerson <remerson@ibm.com> Co-authored-by: Pedro Ruivo <1492066+pruivo@users.noreply.github.com> Co-authored-by: Alexander Schwartz <alexander.schwartz@ibm.com> Co-authored-by: Ryan Emerson <remerson@ibm.com>
This commit is contained in:
@@ -41,7 +41,7 @@ As this has led to a hard-to-configure URL filtering, for example, in reverse pr
|
||||
|
||||
To analyze rejected requests in the server log, enable debug logging for `org.keycloak.quarkus.runtime.services.RejectNonNormalizedPathFilter`.
|
||||
|
||||
To revert to the previoius behavior and to accept non-normalized URLs, set the option `http-accept-non-normalized-paths` to `true`. With this configuration, enable and review the HTTP access log to identify problematic requests.
|
||||
To revert to the previous behavior and to accept non-normalized URLs, set the option `http-accept-non-normalized-paths` to `true`. With this configuration, enable and review the HTTP access log to identify problematic requests.
|
||||
|
||||
// ------------------------ Notable changes ------------------------ //
|
||||
== Notable changes
|
||||
@@ -121,7 +121,7 @@ This was only possible by granting the `admin` role to a master realm user, maki
|
||||
In this release, realm administrators with the `realm-admin` role can assign admin roles to users in their realm, allowing them to delegate administrative tasks without needing server admin privileges.
|
||||
|
||||
If you are using FGAP to delegate administration to users in a realm other than the master realm,
|
||||
make sure the users granted with the `realm-admin` role are expected to have this role to avoid privilege scalation.
|
||||
make sure the users granted with the `realm-admin` role are expected to have this role to avoid privilege escalation.
|
||||
|
||||
The documentation is also updated with additional information about the different types of realm administrators.
|
||||
For more information, see link:{adminguide_link}#_fine_grained_permissions[Delegating realm administration using permissions].
|
||||
@@ -145,7 +145,25 @@ See the link:{upgradingguide_link}[{upgradingguide_name}] for details on how to
|
||||
|
||||
=== Default authorization policies and resources are no longer auto-created
|
||||
|
||||
When enabling authorization on a new client (creating a Resource Server), Keycloak no longer automatically creates a "Default Resource", a "Default Policy," and a "Default Permission."
|
||||
When enabling authorization on a new client (creating a Resource Server), {project_name} no longer automatically creates a "Default Resource", a "Default Policy," and a "Default Permission."
|
||||
|
||||
=== Deleting expired sessions from the database
|
||||
|
||||
Previously, a single instance of {project_name} would check and remove expired entries using a cluster-aware scheduled task,
|
||||
with each node attempting to trigger this task every 15 minutes.
|
||||
This led to a situation that the more instances a cluster had, the more often this ran, although it would never run concurrently on two nodes at the same time.
|
||||
|
||||
Starting from this version, removal is handled by all {project_name} instances, with each instance handling the expiration of sessions in different realms.
|
||||
Additionally, a new SPI option has been added to control the frequency of the internal deletion task: `--spi-user-sessions--infinispan--session-expiration-period` (value in seconds). By default, this runs every three minutes.
|
||||
If you now add more instances to a clusters, the expiration is still run at the same interval.
|
||||
|
||||
Previously, {project_name} deleted sessions from the database only once the session idle time including the remember-me idle time had passed.
|
||||
|
||||
With the new setup and persistent user sessions enabled, sessions are deleted more timely based on the realm settings on session lifetime and idle time, including the remember me settings. For this, the table `OFFLINE_USER_SESSION` now has a new column `REMEMBER_ME`, that is filled for new sessions and updated incrementally for existing sessions.
|
||||
The deletion is triggered currently three minutes after the session has expired to allow for clock skew between instances. Note that this interval can change in future release as we optimize this functionality.
|
||||
|
||||
For each expired user session there is a new user event `USER_SESSION_DELETED` fired.
|
||||
As part of this change, the process now deletes rows from the table in small batches, instead of issuing a delete statements that affects the whole table. This should allow for better response times when there are a lot of sessions in the table.
|
||||
|
||||
// ------------------------ Deprecated features ------------------------ //
|
||||
== Deprecated features
|
||||
@@ -156,7 +174,7 @@ The following sections provide details on deprecated features.
|
||||
|
||||
In a scenario where {project_name} acts as a broker and connects via OpenID Connect to another identity provider, you can choose to send the client secret as *Client secret sent as HTTP Basic authentication without URL encoding* (`client_secret_basic_unencoded`). While this violates RFC 6749, it can be used to keep the default behavior of earlier versions of {project_name}.
|
||||
|
||||
This behavior is deprecated and will be removed in a future version of Keycloak.
|
||||
This behavior is deprecated and will be removed in a future version of {project_name}.
|
||||
|
||||
=== `AuthenticationManager.AuthResult` is now a record
|
||||
|
||||
@@ -169,6 +187,24 @@ The option `http-accept-non-normalized-paths` was introduced to restore the prev
|
||||
|
||||
As this behavior can be problematic for URL filtering, it is deprecated and will be removed in a future release.
|
||||
|
||||
=== Deprecation of methods for removing expired authentication sessions from `AuthenticationSessionProvider`
|
||||
|
||||
The methods `removeAllExpired()` and `removeExpired(RealmModel realm)` are annotated with the `@Deprecated` annotation.
|
||||
They have been deprecated since {project_name} 19, as the built-in implementations now use their own internal cleanup mechanisms.
|
||||
If you are providing a custom implementation of this provider, implement an internal mechanism to delete expired sessions.
|
||||
|
||||
=== Deprecation of methods for removing expired user sessions from `UserSessionProvider`
|
||||
|
||||
The methods `removeAllExpired()` and `removeExpired(RealmModel realm)` are deprecated.
|
||||
A new internal cleanup mechanism has been implemented to automatically remove expired sessions from the database.
|
||||
If you are providing a custom implementation of this provider, implement an internal mechanism to delete expired sessions.
|
||||
|
||||
=== Deprecation of cluster scheduled task `ClearExpiredUserSessions`
|
||||
|
||||
As `AuthenticationSessionProvider` and `UserSessionProvider` now have an internal mechanism to delete expired entries, the scheduled task `ClearExpiredUserSessions` has been deprecated.
|
||||
It is still triggered in this {project_name} version, but will be removed in a future release.
|
||||
|
||||
|
||||
// ------------------------ Removed features ------------------------ //
|
||||
== Removed features
|
||||
|
||||
|
||||
@@ -95,16 +95,6 @@ public class InfinispanAuthenticationSessionProvider implements AuthenticationSe
|
||||
return entityWrapper==null ? null : entityWrapper.getEntity();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAllExpired() {
|
||||
// Rely on expiration of cache entries provided by infinispan. Nothing needed here
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeExpired(RealmModel realm) {
|
||||
// Rely on expiration of cache entries provided by infinispan. Nothing needed here
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRealmRemoved(RealmModel realm) {
|
||||
// Send message to all DCs. The remoteCache will notify client listeners on all DCs for remove authentication sessions
|
||||
|
||||
@@ -539,20 +539,6 @@ public class InfinispanUserSessionProvider implements UserSessionProvider, Sessi
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
UserSessionPersisterProvider provider = session.getProvider(UserSessionPersisterProvider.class);
|
||||
session.realms().getRealmsStream().forEach(provider::removeExpired);
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeExpired(RealmModel realm) {
|
||||
// Rely on expiration of cache entries provided by infinispan. Nothing needed here besides calling persister
|
||||
session.getProvider(UserSessionPersisterProvider.class).removeExpired(realm);
|
||||
}
|
||||
|
||||
@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.
|
||||
|
||||
@@ -37,6 +37,7 @@ import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.UserSessionProvider;
|
||||
import org.keycloak.models.UserSessionProviderFactory;
|
||||
import org.keycloak.models.UserSessionSpi;
|
||||
import org.keycloak.models.sessions.infinispan.changes.CacheHolder;
|
||||
import org.keycloak.models.sessions.infinispan.changes.ClientSessionPersistentChangelogBasedTransaction;
|
||||
import org.keycloak.models.sessions.infinispan.changes.InfinispanChangelogBasedTransaction;
|
||||
@@ -52,6 +53,8 @@ import org.keycloak.models.sessions.infinispan.entities.UserSessionEntity;
|
||||
import org.keycloak.models.sessions.infinispan.events.AbstractUserSessionClusterListener;
|
||||
import org.keycloak.models.sessions.infinispan.events.RealmRemovedSessionEvent;
|
||||
import org.keycloak.models.sessions.infinispan.events.RemoveUserSessionsEvent;
|
||||
import org.keycloak.models.sessions.infinispan.expiration.ExpirationTask;
|
||||
import org.keycloak.models.sessions.infinispan.expiration.ExpirationTaskFactory;
|
||||
import org.keycloak.models.sessions.infinispan.listeners.EmbeddedUserSessionExpirationListener;
|
||||
import org.keycloak.models.sessions.infinispan.transaction.InfinispanTransactionProvider;
|
||||
import org.keycloak.models.sessions.infinispan.util.SessionTimeouts;
|
||||
@@ -86,12 +89,16 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider
|
||||
private static final boolean DEFAULT_USE_CACHES = true;
|
||||
public static final String CONFIG_USE_BATCHES = "useBatches";
|
||||
private static final boolean DEFAULT_USE_BATCHES = false;
|
||||
public static final String CONFIG_EXPIRATION_PERIOD = "sessionExpirationPeriod";
|
||||
private static final int DEFAULT_EXPIRATION_PERIOD_SECONDS = 180;
|
||||
private static final int MIN_EXPIRATION_PERIOD_SECONDS = 60; // anything below 60s may be too frequent.
|
||||
|
||||
private CacheHolder<String, UserSessionEntity> sessionCacheHolder;
|
||||
private CacheHolder<String, UserSessionEntity> offlineSessionCacheHolder;
|
||||
private CacheHolder<EmbeddedClientSessionKey, AuthenticatedClientSessionEntity> clientSessionCacheHolder;
|
||||
private CacheHolder<EmbeddedClientSessionKey, AuthenticatedClientSessionEntity> offlineClientSessionCacheHolder;
|
||||
private EmbeddedUserSessionExpirationListener expirationListener;
|
||||
private ExpirationTask expirationTask;
|
||||
|
||||
private long offlineSessionCacheEntryLifespanOverride;
|
||||
|
||||
@@ -103,6 +110,7 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider
|
||||
private int maxBatchSize;
|
||||
private boolean useCaches;
|
||||
private boolean useBatches;
|
||||
private int expirationPeriodSeconds;
|
||||
|
||||
@Override
|
||||
public UserSessionProvider create(KeycloakSession session) {
|
||||
@@ -146,6 +154,7 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider
|
||||
if (useBatches) {
|
||||
asyncQueuePersistentUpdate = new ArrayBlockingQueue<>(1000);
|
||||
}
|
||||
expirationPeriodSeconds = getExpirationPeriodSeconds(config);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -213,6 +222,11 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider
|
||||
// The expired events for offline sessions will be triggered by JpaUserSessionPersisterProvider
|
||||
sessionCacheHolder.cache().addListener(expirationListener);
|
||||
}
|
||||
// we need the expiration task running because of offline sessions
|
||||
try (var session = factory.create()) {
|
||||
expirationTask = ExpirationTaskFactory.create(session, expirationPeriodSeconds);
|
||||
}
|
||||
expirationTask.start();
|
||||
}
|
||||
|
||||
public void initializePersisterLastSessionRefreshStore(final KeycloakSessionFactory sessionFactory) {
|
||||
@@ -303,6 +317,10 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider
|
||||
sessionCacheHolder.cache().removeListener(expirationListener);
|
||||
expirationListener = null;
|
||||
}
|
||||
if (expirationTask != null) {
|
||||
expirationTask.stop();
|
||||
expirationTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -328,6 +346,7 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider
|
||||
info.put(CONFIG_MAX_BATCH_SIZE, Integer.toString(maxBatchSize));
|
||||
info.put(CONFIG_USE_CACHES, Boolean.toString(useCaches));
|
||||
info.put(CONFIG_USE_BATCHES, Boolean.toString(useBatches));
|
||||
info.put(CONFIG_EXPIRATION_PERIOD, Integer.toString(expirationPeriodSeconds));
|
||||
return info;
|
||||
}
|
||||
|
||||
@@ -367,6 +386,12 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider
|
||||
.helpText("Enable or disable caches. Enabled by default unless the external feature to use only external remote caches is used")
|
||||
.add();
|
||||
|
||||
builder.property()
|
||||
.name(CONFIG_EXPIRATION_PERIOD)
|
||||
.type("int")
|
||||
.helpText("Sets the expiration task run period, to remove the expired session.")
|
||||
.add();
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@@ -375,6 +400,27 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider
|
||||
return Set.of(InfinispanConnectionProvider.class, InfinispanTransactionProvider.class);
|
||||
}
|
||||
|
||||
public ExpirationTask getExpirationTask() {
|
||||
return expirationTask;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param outTimeUnit The {@link TimeUnit} of the return value.
|
||||
* @return The configured expiration task period, in the {@code outTimeUnit}.
|
||||
*/
|
||||
public static long getExpirationPeriod(TimeUnit outTimeUnit) {
|
||||
return outTimeUnit.convert(getExpirationPeriodSeconds(Config.scope(UserSessionSpi.NAME, InfinispanUtils.EMBEDDED_PROVIDER_ID)), TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static int getExpirationPeriodSeconds(Config.Scope config) {
|
||||
int period = config.getInt(CONFIG_EXPIRATION_PERIOD, DEFAULT_EXPIRATION_PERIOD_SECONDS);
|
||||
if (period < MIN_EXPIRATION_PERIOD_SECONDS) {
|
||||
log.warnf("Invalid user session expiration task period of %d seconds. Setting it to %d seconds", period, MIN_EXPIRATION_PERIOD_SECONDS);
|
||||
return MIN_EXPIRATION_PERIOD_SECONDS;
|
||||
}
|
||||
return period;
|
||||
}
|
||||
|
||||
private VolatileTransactions createVolatileTransaction(KeycloakSession session) {
|
||||
var sessionTx = new InfinispanChangelogBasedTransaction<>(session, sessionCacheHolder);
|
||||
var offlineSessionTx = new InfinispanChangelogBasedTransaction<>(session, offlineSessionCacheHolder);
|
||||
|
||||
@@ -404,19 +404,6 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi
|
||||
.forEach(s -> removeUserSession(realm, s));
|
||||
}
|
||||
|
||||
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) {
|
||||
// Rely on expiration of cache entries provided by infinispan. Nothing needed here besides calling persister
|
||||
session.getProvider(UserSessionPersisterProvider.class).removeExpired(realm);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeUserSessions(RealmModel realm) {
|
||||
// Send message to all DCs as each site might have different entries in the cache
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.models.sessions.infinispan.expiration;
|
||||
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.time.Duration;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.session.UserSessionPersisterProvider;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* ${@link BaseExpirationTask} contains the main logic to remove expired sessions from the database.
|
||||
* <p>
|
||||
* The implementation only need to provide a {@link Predicate}, by implementing {@link #realmFilter()}. This
|
||||
* {@link Predicate} decides if the session belonging to the {@link RealmModel} must be checked in this round.
|
||||
*/
|
||||
abstract class BaseExpirationTask implements ExpirationTask {
|
||||
|
||||
protected static final Logger log = Logger.getLogger(MethodHandles.lookup().lookupClass());
|
||||
|
||||
private final AtomicReference<PurgeExpiredTask> currentTask = new AtomicReference<>();
|
||||
private final KeycloakSessionFactory factory;
|
||||
private final int delaySeconds;
|
||||
private final ScheduledExecutorService scheduledExecutorService;
|
||||
private final Consumer<Duration> onTaskExecuted;
|
||||
private final ExecutorService executorService;
|
||||
|
||||
BaseExpirationTask(KeycloakSessionFactory factory, ScheduledExecutorService scheduledExecutorService, int delaySeconds, Consumer<Duration> onTaskExecuted) {
|
||||
this.factory = Objects.requireNonNull(factory);
|
||||
this.delaySeconds = delaySeconds;
|
||||
this.scheduledExecutorService = Objects.requireNonNull(scheduledExecutorService);
|
||||
this.onTaskExecuted = Objects.requireNonNullElse(onTaskExecuted, value -> {
|
||||
});
|
||||
this.executorService = Executors.newSingleThreadExecutor(r -> {
|
||||
//create daemon threads
|
||||
Thread t = new Thread(r);
|
||||
t.setDaemon(true);
|
||||
t.setName("user-session-purge-expired");
|
||||
return t;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
scheduleNextTask();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
var existing = currentTask.getAndSet(null);
|
||||
if (existing == null) {
|
||||
return;
|
||||
}
|
||||
existing.scheduledFuture().cancel(true);
|
||||
executorService.shutdown();
|
||||
}
|
||||
|
||||
void purgeExpired() {
|
||||
log.debug("PurgeExpired database sessions started");
|
||||
long start = System.nanoTime();
|
||||
try {
|
||||
KeycloakModelUtils.runJobInTransaction(factory, session -> {
|
||||
var provider = session.getProvider(UserSessionPersisterProvider.class);
|
||||
if (provider == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.realms().getRealmsStream()
|
||||
.filter(realmFilter())
|
||||
.forEach(provider::removeExpired);
|
||||
});
|
||||
} catch (Throwable t) {
|
||||
logUnexpectedErrorDuringDeletion(t);
|
||||
} finally {
|
||||
long duration = System.nanoTime() - start;
|
||||
onTaskExecuted.accept(Duration.of(duration, ChronoUnit.NANOS));
|
||||
log.debugf("PurgeExpired tasks completed in %s seconds", TimeUnit.NANOSECONDS.toSeconds(duration));
|
||||
}
|
||||
}
|
||||
|
||||
abstract Predicate<RealmModel> realmFilter();
|
||||
|
||||
private void scheduleNextTask() {
|
||||
var existingTask = currentTask.get();
|
||||
var newTask = createAndSchedule();
|
||||
if (currentTask.compareAndSet(existingTask, newTask)) {
|
||||
newTask.taskFuture().thenRun(this::scheduleNextTask);
|
||||
return;
|
||||
}
|
||||
newTask.scheduledFuture().cancel(true);
|
||||
}
|
||||
|
||||
private PurgeExpiredTask createAndSchedule() {
|
||||
var taskFuture = new CompletableFuture<Void>();
|
||||
var scheduleFuture = scheduledExecutorService.schedule(() -> runAndComplete(taskFuture),
|
||||
delaySeconds, TimeUnit.SECONDS);
|
||||
return new PurgeExpiredTask(scheduleFuture, taskFuture);
|
||||
}
|
||||
|
||||
private void runAndComplete(CompletableFuture<Void> toComplete) {
|
||||
CompletableFuture.runAsync(this::purgeExpired, executorService)
|
||||
.exceptionally(throwable -> {
|
||||
logUnexpectedErrorDuringDeletion(throwable);
|
||||
return null;
|
||||
})
|
||||
.thenApply(toComplete::complete);
|
||||
}
|
||||
|
||||
private static void logUnexpectedErrorDuringDeletion(Throwable throwable) {
|
||||
log.error("Unexpected error while removing expired entries from database", throwable);
|
||||
}
|
||||
|
||||
private record PurgeExpiredTask(ScheduledFuture<?> scheduledFuture, CompletionStage<Void> taskFuture) {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.models.sessions.infinispan.expiration;
|
||||
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.keycloak.models.RealmModel;
|
||||
|
||||
import org.infinispan.client.hotrod.RemoteCache;
|
||||
import org.infinispan.client.hotrod.annotation.ClientCacheEntryCreated;
|
||||
import org.infinispan.client.hotrod.annotation.ClientCacheEntryExpired;
|
||||
import org.infinispan.client.hotrod.annotation.ClientCacheEntryModified;
|
||||
import org.infinispan.client.hotrod.annotation.ClientCacheEntryRemoved;
|
||||
import org.infinispan.client.hotrod.annotation.ClientListener;
|
||||
import org.infinispan.client.hotrod.event.ClientCacheEntryCreatedEvent;
|
||||
import org.infinispan.client.hotrod.event.ClientCacheEntryExpiredEvent;
|
||||
import org.infinispan.client.hotrod.event.ClientCacheEntryModifiedEvent;
|
||||
import org.infinispan.client.hotrod.event.ClientCacheEntryRemovedEvent;
|
||||
import org.infinispan.commons.hash.MurmurHash3;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* A consistent hash for expiration, relying on the external Infinispan.
|
||||
* <p>
|
||||
* Each Keycloak instance will add itself, periodically, to the remote cache, and it relies on the Hot Rod client
|
||||
* listener to keep the membership up to date.
|
||||
* <p>
|
||||
* During network partitions, it has a probability of two or more Keycloak instances to be assigned to the same realm.
|
||||
* In this scenario, we rely on the database lock to keep data consistent.
|
||||
* <p>
|
||||
* Keycloak instances starting and stopping information may not be available in real time, and it is possible some
|
||||
* realms not being checked during an iteration.
|
||||
*/
|
||||
@ClientListener(includeCurrentState = true)
|
||||
class ConsistentHash {
|
||||
|
||||
private static final Logger log = Logger.getLogger(MethodHandles.lookup().lookupClass());
|
||||
|
||||
private static final int MIN_HEARTBEAT_PERIOD_SECONDS = 30;
|
||||
private static final int LIFESPAN_MULTIPLIER = 3;
|
||||
private static final int HEARTBEATS_PER_EXPIRATION_ROUND = 4;
|
||||
private static final int STOP_TIMEOUT_MILLISECONDS = 500;
|
||||
private static final String MEMBER_KEY_PREFIX = "node:";
|
||||
|
||||
private final Set<String> membership = ConcurrentHashMap.newKeySet();
|
||||
private final String nodeUUID;
|
||||
private final String nodeName;
|
||||
private final int heartBeatPeriodSeconds;
|
||||
private final int heartBeatLifespan;
|
||||
private final ScheduledExecutorService scheduledExecutorService;
|
||||
private final RemoteCache<String, String> cache;
|
||||
private volatile ScheduledFuture<?> schedule;
|
||||
|
||||
|
||||
private ConsistentHash(ScheduledExecutorService scheduledExecutorService, RemoteCache<String, String> cache, String nodeUUID, String nodeName, int heartBeatPeriodSeconds, int heartBeatLifespan) {
|
||||
this.scheduledExecutorService = scheduledExecutorService;
|
||||
this.nodeName = nodeName;
|
||||
this.heartBeatPeriodSeconds = heartBeatPeriodSeconds;
|
||||
this.cache = cache;
|
||||
this.nodeUUID = MEMBER_KEY_PREFIX + nodeUUID;
|
||||
this.heartBeatLifespan = heartBeatLifespan;
|
||||
}
|
||||
|
||||
static ConsistentHash create(RemoteCache<String, String> cache, ScheduledExecutorService scheduledExecutorService, String nodeUUID, String nodeName, int expirationPeriodSeconds) {
|
||||
int period = Math.max(MIN_HEARTBEAT_PERIOD_SECONDS, expirationPeriodSeconds / HEARTBEATS_PER_EXPIRATION_ROUND);
|
||||
int lifespan = period * LIFESPAN_MULTIPLIER;
|
||||
return new ConsistentHash(Objects.requireNonNull(scheduledExecutorService), Objects.requireNonNull(cache), nodeUUID, nodeName, period, lifespan);
|
||||
}
|
||||
|
||||
void start() {
|
||||
if (schedule != null) {
|
||||
return;
|
||||
}
|
||||
sendHeartBeat();
|
||||
schedule = scheduledExecutorService.scheduleAtFixedRate(this::sendHeartBeat, heartBeatPeriodSeconds, heartBeatPeriodSeconds, TimeUnit.SECONDS);
|
||||
cache.addClientListener(this);
|
||||
}
|
||||
|
||||
void stop() {
|
||||
var existing = schedule;
|
||||
if (existing == null) {
|
||||
return;
|
||||
}
|
||||
cache.removeClientListener(this);
|
||||
existing.cancel(true);
|
||||
schedule = null;
|
||||
try {
|
||||
cache.removeAsync(nodeUUID).get(STOP_TIMEOUT_MILLISECONDS, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (ExecutionException | TimeoutException e) {
|
||||
log.debugf("Exception caught during stop", e);
|
||||
}
|
||||
}
|
||||
|
||||
Predicate<RealmModel> consistentHashSnapshot() {
|
||||
return new HashingPredicate(membership.stream().sorted().toList(), nodeUUID);
|
||||
}
|
||||
|
||||
int size() {
|
||||
return membership.size();
|
||||
}
|
||||
|
||||
@ClientCacheEntryCreated
|
||||
public void onKeycloakConnected(ClientCacheEntryCreatedEvent<String> event) {
|
||||
addKeycloakNode(event.getKey());
|
||||
}
|
||||
|
||||
@ClientCacheEntryModified
|
||||
public void onHeartbeat(ClientCacheEntryModifiedEvent<String> event) {
|
||||
addKeycloakNode(event.getKey());
|
||||
}
|
||||
|
||||
@ClientCacheEntryExpired
|
||||
public void onMissingHeartbeat(ClientCacheEntryExpiredEvent<String> event) {
|
||||
removeKeycloakNode(event.getKey());
|
||||
}
|
||||
|
||||
@ClientCacheEntryRemoved
|
||||
public void onKeycloakDisconnect(ClientCacheEntryRemovedEvent<String> event) {
|
||||
removeKeycloakNode(event.getKey());
|
||||
}
|
||||
|
||||
private void addKeycloakNode(String uuid) {
|
||||
if (uuid.startsWith(MEMBER_KEY_PREFIX)) {
|
||||
log.fatalf("Adding a keycloak instance with ID: %s", uuid);
|
||||
membership.add(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
private void removeKeycloakNode(String uuid) {
|
||||
if (uuid.startsWith(MEMBER_KEY_PREFIX)) {
|
||||
log.fatalf("Removing keycloak instance with ID: %s", uuid);
|
||||
membership.remove(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendHeartBeat() {
|
||||
cache.putAsync(nodeUUID, nodeName, heartBeatLifespan, TimeUnit.SECONDS);
|
||||
addKeycloakNode(nodeUUID);
|
||||
}
|
||||
|
||||
private record HashingPredicate(List<String> members, String myUUID) implements Predicate<RealmModel> {
|
||||
|
||||
@Override
|
||||
public boolean test(RealmModel realm) {
|
||||
var size = members.size();
|
||||
assert size > 0;
|
||||
var index = Math.abs(MurmurHash3.getInstance().hash(realm.getId())) % size;
|
||||
return myUUID.equals(members.get(index));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.models.sessions.infinispan.expiration;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.RealmModel;
|
||||
|
||||
import org.infinispan.Cache;
|
||||
import org.infinispan.distribution.DistributionManager;
|
||||
|
||||
/**
|
||||
* A {@link ExpirationTask} implementation that uses the {@link DistributionManager} from an Infinispan {@link Cache}.
|
||||
* <p>
|
||||
* It takes advantage of the {@link DistributionManager} to assign Keycloak instances to distinct {@link RealmModel},
|
||||
* allowing a distributed check of expired session through the cluster.
|
||||
* <p>
|
||||
* A consistent hash is not updated at the same time in all Keycloak cluster members. It is possible some realms are not
|
||||
* checked during an iteration, or the same realms are checked by multiple Keycloak instances. In the latter case, we rely on
|
||||
* database locking.
|
||||
*/
|
||||
class DistributionAwareExpirationTask extends BaseExpirationTask implements Predicate<RealmModel> {
|
||||
|
||||
private final DistributionManager distributionManager;
|
||||
|
||||
DistributionAwareExpirationTask(KeycloakSessionFactory factory, ScheduledExecutorService scheduledExecutorService, int intervalSeconds, Consumer<Duration> onTaskExecuted, DistributionManager distributionManager) {
|
||||
super(factory, scheduledExecutorService, intervalSeconds, onTaskExecuted);
|
||||
this.distributionManager = Objects.requireNonNull(distributionManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
final Predicate<RealmModel> realmFilter() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public final boolean test(RealmModel realm) {
|
||||
return distributionManager.getCacheTopology().getDistribution(realm.getId()).isPrimary();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.models.sessions.infinispan.expiration;
|
||||
|
||||
/**
|
||||
* Lifecycle methods for the user and client session expiration task.
|
||||
* <p>
|
||||
* The task must remove the expired sessions from the database. The {@link #start()} is invoked when Keycloak starts
|
||||
* and, respectively, {@link #stop()} when Keycloak is shutdown.
|
||||
*/
|
||||
public interface ExpirationTask {
|
||||
|
||||
/**
|
||||
* Starts the expiration task.
|
||||
* <p>
|
||||
* This method is only invoked when Keycloak starts, and the implementation is responsible to schedule future
|
||||
* invocation, to prevent the database growing.
|
||||
*/
|
||||
void start();
|
||||
|
||||
/**
|
||||
* Stops the expiration task.
|
||||
*/
|
||||
void stop();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.models.sessions.infinispan.expiration;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.config.MetricsOptions;
|
||||
import org.keycloak.connections.infinispan.InfinispanConnectionProvider;
|
||||
import org.keycloak.infinispan.util.InfinispanUtils;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserSessionProvider;
|
||||
import org.keycloak.models.sessions.infinispan.InfinispanUserSessionProviderFactory;
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import org.infinispan.client.hotrod.RemoteCache;
|
||||
|
||||
import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.WORK_CACHE_NAME;
|
||||
|
||||
/**
|
||||
* Provides factory method to instantiate an {@link ExpirationTask}.
|
||||
* <p>
|
||||
* The {@link ExpirationTask} is not started.
|
||||
*/
|
||||
public final class ExpirationTaskFactory {
|
||||
|
||||
/**
|
||||
* Creates a {@link ExpirationTask} based on the Keycloak configuration.
|
||||
*
|
||||
* @param session The current {@link KeycloakSession}.
|
||||
* @return A new instance of {@link ExpirationTask}. This instance is not started yet.
|
||||
*/
|
||||
public static ExpirationTask create(KeycloakSession session, int expirationPeriodSeconds) {
|
||||
Consumer<Duration> onTaskExecuted = null;
|
||||
if (Config.scope().root().getBoolean(MetricsOptions.METRICS_ENABLED.getKey(), Boolean.FALSE)) {
|
||||
var timer = Timer.builder("keycloak.session.expiration.task")
|
||||
.description("Keycloak User and Client sessions expiration tasks duration.")
|
||||
.publishPercentileHistogram()
|
||||
.register(Metrics.globalRegistry);
|
||||
onTaskExecuted = timer::record;
|
||||
}
|
||||
return create(session, expirationPeriodSeconds, onTaskExecuted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link ExpirationTask} based on the configuration provided by the parameters.
|
||||
*
|
||||
* @param session The current {@link KeycloakSession}.
|
||||
* @param expirationTaskPeriodSeconds The period when the database is checked for expired sessions.
|
||||
* @param onTaskExecuted An optional {@link Consumer<Duration>}. It is invoked when a database expiration
|
||||
* check finishes with its duration, in nanoseconds.
|
||||
* @return A new instance of {@link ExpirationTask}. This instance is not started yet.
|
||||
*/
|
||||
public static ExpirationTask create(KeycloakSession session, int expirationTaskPeriodSeconds, Consumer<Duration> onTaskExecuted) {
|
||||
var connectionProvider = session.getProvider(InfinispanConnectionProvider.class);
|
||||
var schedulerExecutor = connectionProvider.getScheduledExecutor();
|
||||
|
||||
if (InfinispanUtils.isEmbeddedInfinispan()) {
|
||||
var workCache = connectionProvider.getCache(WORK_CACHE_NAME);
|
||||
if (workCache.getCacheConfiguration().clustering().cacheMode().isClustered()) {
|
||||
var distributionManager = workCache.getAdvancedCache().getDistributionManager();
|
||||
return new DistributionAwareExpirationTask(session.getKeycloakSessionFactory(), schedulerExecutor, expirationTaskPeriodSeconds, onTaskExecuted, distributionManager);
|
||||
}
|
||||
|
||||
return new LocalExpirationTask(session.getKeycloakSessionFactory(), schedulerExecutor, expirationTaskPeriodSeconds, onTaskExecuted);
|
||||
}
|
||||
|
||||
RemoteCache<String, String> workCache = connectionProvider.getRemoteCache(WORK_CACHE_NAME);
|
||||
String nodeName = connectionProvider.getNodeInfo().nodeName();
|
||||
return new RemoteExpirationTask(session.getKeycloakSessionFactory(), schedulerExecutor, expirationTaskPeriodSeconds, onTaskExecuted, workCache, nodeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the local instance is responsible to clean up expired sessions from {@code realm}.
|
||||
* <p>
|
||||
* Provided for testing purposes only! Do not invoke in production.
|
||||
*/
|
||||
public static boolean isSelectedForExpireSessionsInRealm(KeycloakSession session, RealmModel realm) {
|
||||
return getEventTask(session)
|
||||
.map(BaseExpirationTask::realmFilter)
|
||||
.map(filter -> filter.test(realm))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger the expiration task, bypassing any scheduling.
|
||||
* <p>
|
||||
* Provided for testing purposes only! Do not invoke in production.
|
||||
*/
|
||||
public static void manualTriggerTask(KeycloakSession session) {
|
||||
getEventTask(session).ifPresent(BaseExpirationTask::purgeExpired);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of Keycloak instance when running with an external Infinispan cluster.
|
||||
* <p>
|
||||
* Testing purpose only! Do not invoke in production.
|
||||
*/
|
||||
public static int membersSize(KeycloakSession session) {
|
||||
return getEventTask(session)
|
||||
.filter(RemoteExpirationTask.class::isInstance)
|
||||
.map(RemoteExpirationTask.class::cast)
|
||||
.map(RemoteExpirationTask::membersSize)
|
||||
.orElse(0);
|
||||
}
|
||||
|
||||
private static Optional<BaseExpirationTask> getEventTask(KeycloakSession session) {
|
||||
ProviderFactory<UserSessionProvider> provider = session.getKeycloakSessionFactory().getProviderFactory(UserSessionProvider.class);
|
||||
if (!(provider instanceof InfinispanUserSessionProviderFactory iuspf)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
ExpirationTask task = iuspf.getExpirationTask();
|
||||
if (!(task instanceof BaseExpirationTask bet)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(bet);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.models.sessions.infinispan.expiration;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.RealmModel;
|
||||
|
||||
|
||||
/**
|
||||
* A {@link ExpirationTask} for development or single instance without clustering.
|
||||
*/
|
||||
class LocalExpirationTask extends BaseExpirationTask implements Predicate<RealmModel> {
|
||||
|
||||
LocalExpirationTask(KeycloakSessionFactory factory, ScheduledExecutorService scheduledExecutorService, int intervalSeconds, Consumer<Duration> onTaskExecuted) {
|
||||
super(factory, scheduledExecutorService, intervalSeconds, onTaskExecuted);
|
||||
}
|
||||
|
||||
@Override
|
||||
final Predicate<RealmModel> realmFilter() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public final boolean test(RealmModel realmModel) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.models.sessions.infinispan.expiration;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.RealmModel;
|
||||
|
||||
import org.infinispan.client.hotrod.RemoteCache;
|
||||
|
||||
/**
|
||||
* A {@link ExpirationTask} for non clustered environment, when an external Infinispan is available.
|
||||
* <p>
|
||||
* During network partitions, it has a probability of two or more Keycloak instances to be assigned to the same realm.
|
||||
* In this scenario, we rely on the database lock to keep data consistent.
|
||||
* <p>
|
||||
* Keycloak instances starting and stopping information may not be available in real time, and it is possible some
|
||||
* realms not being checked during an iteration.
|
||||
*/
|
||||
class RemoteExpirationTask extends BaseExpirationTask {
|
||||
|
||||
private final ConsistentHash consistentHash;
|
||||
|
||||
RemoteExpirationTask(KeycloakSessionFactory factory, ScheduledExecutorService scheduledExecutorService, int intervalSeconds, Consumer<Duration> onTaskExecuted, RemoteCache<String, String> workCache, String nodeName) {
|
||||
super(factory, scheduledExecutorService, intervalSeconds, onTaskExecuted);
|
||||
this.consistentHash = ConsistentHash.create(workCache, scheduledExecutorService, UUID.randomUUID().toString(), nodeName, intervalSeconds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public final void start() {
|
||||
consistentHash.start();
|
||||
super.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public final void stop() {
|
||||
super.stop();
|
||||
consistentHash.stop();
|
||||
}
|
||||
|
||||
@Override
|
||||
final Predicate<RealmModel> realmFilter() {
|
||||
return consistentHash.consistentHashSnapshot();
|
||||
}
|
||||
|
||||
int membersSize() {
|
||||
return consistentHash.size();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -80,16 +80,6 @@ public class RemoteInfinispanAuthenticationSessionProvider implements Authentica
|
||||
transaction.remove(authenticationSession.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAllExpired() {
|
||||
// Rely on expiration of cache entries provided by infinispan. Nothing needed here.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeExpired(RealmModel realm) {
|
||||
// Rely on expiration of cache entries provided by infinispan. Nothing needed here.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRealmRemoved(RealmModel realm) {
|
||||
transaction.removeByRealmId(realm.getId());
|
||||
|
||||
@@ -183,16 +183,6 @@ public class RemoteUserSessionProvider implements UserSessionProvider {
|
||||
transaction.removeAllSessionByUserId(realm.getId(), user.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAllExpired() {
|
||||
//rely on Infinispan expiration
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeExpired(RealmModel realm) {
|
||||
//rely on Infinispan expiration
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeUserSessions(RealmModel realm) {
|
||||
transaction.removeAllSessionsByRealmId(realm.getId());
|
||||
|
||||
@@ -19,12 +19,14 @@ package org.keycloak.spi.infinispan.impl.embedded;
|
||||
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.config.CachingOptions;
|
||||
import org.keycloak.marshalling.Marshalling;
|
||||
import org.keycloak.models.sessions.infinispan.InfinispanUserSessionProviderFactory;
|
||||
import org.keycloak.models.sessions.infinispan.entities.LoginFailureEntity;
|
||||
import org.keycloak.models.sessions.infinispan.entities.RemoteAuthenticatedClientSessionEntity;
|
||||
import org.keycloak.models.sessions.infinispan.entities.RemoteUserSessionEntity;
|
||||
@@ -36,6 +38,7 @@ import org.infinispan.configuration.cache.BackupConfiguration;
|
||||
import org.infinispan.configuration.cache.BackupFailurePolicy;
|
||||
import org.infinispan.configuration.cache.CacheMode;
|
||||
import org.infinispan.configuration.cache.ConfigurationBuilder;
|
||||
import org.infinispan.configuration.cache.ExpirationConfiguration;
|
||||
import org.infinispan.configuration.cache.HashConfiguration;
|
||||
import org.infinispan.configuration.cache.HashConfigurationBuilder;
|
||||
import org.infinispan.configuration.parsing.ConfigurationBuilderHolder;
|
||||
@@ -202,7 +205,6 @@ public final class CacheConfigurator {
|
||||
* the {@code holder}. This could indicate a missing or incorrect configuration.
|
||||
*/
|
||||
public static void configureCacheMaxCount(Config.Scope keycloakConfig, ConfigurationBuilderHolder holder, Stream<String> caches) {
|
||||
boolean clustered = isClustered(holder);
|
||||
for (var it = caches.iterator(); it.hasNext(); ) {
|
||||
var name = it.next();
|
||||
var builder = holder.getNamedConfigurationBuilders().get(name);
|
||||
@@ -213,7 +215,7 @@ public final class CacheConfigurator {
|
||||
if (maxCount != null) {
|
||||
if (maxCount < 0) {
|
||||
// Prevent users setting an unbounded max-count for any cache that already has a default max-count defined
|
||||
maxCount = getCacheConfiguration(name, clustered).memory().maxCount();
|
||||
maxCount = builder.memory().maxCount();
|
||||
if (maxCount > -1)
|
||||
logger.infof("Ignoring unbounded max-count for cache '%s', reverting to default max of %d entries.", name, maxCount);
|
||||
} else {
|
||||
@@ -233,6 +235,7 @@ public final class CacheConfigurator {
|
||||
*/
|
||||
public static void configureSessionsCachesForPersistentSessions(Config.Scope keycloakConfig, ConfigurationBuilderHolder holder) {
|
||||
logger.debug("Configuring session cache (persistent user sessions)");
|
||||
var sessionCaches = Set.of(USER_SESSION_CACHE_NAME, CLIENT_SESSION_CACHE_NAME, OFFLINE_USER_SESSION_CACHE_NAME, OFFLINE_CLIENT_SESSION_CACHE_NAME);
|
||||
for (var name : CLUSTERED_MAX_COUNT_CACHES) {
|
||||
var builder = holder.getNamedConfigurationBuilders().get(name);
|
||||
if (builder == null) {
|
||||
@@ -248,6 +251,9 @@ public final class CacheConfigurator {
|
||||
While a `remove` is forwarded to the backup owner regardless if the key exists on the primary owner, a `computeIfPresent` is not, and it would leave a backup owner with an outdated key.
|
||||
With the number of owners set to `1`, there will be no backup owners, so this is the setting to choose with persistent sessions enabled to ensure consistent data in the caches. */
|
||||
builder.clustering().hash().numOwners(1);
|
||||
if (sessionCaches.contains(name)) {
|
||||
configureSessionExpirationReaper(builder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +283,7 @@ public final class CacheConfigurator {
|
||||
logger.infof("Persistent user sessions disabled with number of owners set to default value 1 for cache %s and no shared persistence store configured. Setting num_owners=2 to avoid data loss.", name);
|
||||
builder.clustering().hash().numOwners(2);
|
||||
}
|
||||
configureSessionExpirationReaper(builder);
|
||||
}
|
||||
|
||||
for (var name : Arrays.asList(OFFLINE_USER_SESSION_CACHE_NAME, OFFLINE_CLIENT_SESSION_CACHE_NAME)) {
|
||||
@@ -296,6 +303,7 @@ public final class CacheConfigurator {
|
||||
logger.infof("Setting a memory limit implies to have exactly one owner. Setting num_owners=1 to avoid data loss.", name);
|
||||
builder.clustering().hash().numOwners(1);
|
||||
}
|
||||
configureSessionExpirationReaper(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,27 +371,32 @@ public final class CacheConfigurator {
|
||||
public static ConfigurationBuilder getRemoteCacheConfiguration(String cacheName, Config.Scope config, String[] sites) {
|
||||
return switch (cacheName) {
|
||||
case CLIENT_SESSION_CACHE_NAME, OFFLINE_CLIENT_SESSION_CACHE_NAME ->
|
||||
remoteCacheConfigurationBuilder(cacheName, config, sites, RemoteAuthenticatedClientSessionEntity.class);
|
||||
remoteCacheConfigurationBuilder(cacheName, config, sites, RemoteAuthenticatedClientSessionEntity.class, InfinispanUserSessionProviderFactory.getExpirationPeriod(TimeUnit.MILLISECONDS));
|
||||
case USER_SESSION_CACHE_NAME, OFFLINE_USER_SESSION_CACHE_NAME ->
|
||||
remoteCacheConfigurationBuilder(cacheName, config, sites, RemoteUserSessionEntity.class);
|
||||
remoteCacheConfigurationBuilder(cacheName, config, sites, RemoteUserSessionEntity.class, InfinispanUserSessionProviderFactory.getExpirationPeriod(TimeUnit.MILLISECONDS));
|
||||
case AUTHENTICATION_SESSIONS_CACHE_NAME ->
|
||||
remoteCacheConfigurationBuilder(cacheName, config, sites, RootAuthenticationSessionEntity.class);
|
||||
remoteCacheConfigurationBuilder(cacheName, config, sites, RootAuthenticationSessionEntity.class, ExpirationConfiguration.WAKEUP_INTERVAL.getDefaultValue());
|
||||
case LOGIN_FAILURE_CACHE_NAME ->
|
||||
remoteCacheConfigurationBuilder(cacheName, config, sites, LoginFailureEntity.class);
|
||||
case ACTION_TOKEN_CACHE, WORK_CACHE_NAME -> remoteCacheConfigurationBuilder(cacheName, config, sites, null);
|
||||
remoteCacheConfigurationBuilder(cacheName, config, sites, LoginFailureEntity.class, ExpirationConfiguration.WAKEUP_INTERVAL.getDefaultValue());
|
||||
case ACTION_TOKEN_CACHE, WORK_CACHE_NAME -> remoteCacheConfigurationBuilder(cacheName, config, sites, null, ExpirationConfiguration.WAKEUP_INTERVAL.getDefaultValue());
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
// private methods below
|
||||
|
||||
private static ConfigurationBuilder remoteCacheConfigurationBuilder(String name, Config.Scope config, String[] sites, Class<?> indexedEntity) {
|
||||
private static void configureSessionExpirationReaper(ConfigurationBuilder builder) {
|
||||
builder.expiration().enableReaper().wakeUpInterval(InfinispanUserSessionProviderFactory.getExpirationPeriod(TimeUnit.MILLISECONDS));
|
||||
}
|
||||
|
||||
private static ConfigurationBuilder remoteCacheConfigurationBuilder(String name, Config.Scope config, String[] sites, Class<?> indexedEntity, long expirationWakeupPeriodMillis) {
|
||||
var builder = new ConfigurationBuilder();
|
||||
builder.clustering().cacheMode(CacheMode.DIST_SYNC);
|
||||
builder.clustering().hash().numOwners(Math.max(MIN_NUM_OWNERS_REMOTE_CACHE, config.getInt(numOwnerConfigKey(name), MIN_NUM_OWNERS_REMOTE_CACHE)));
|
||||
builder.clustering().stateTransfer().chunkSize(STATE_TRANSFER_CHUNK_SIZE);
|
||||
builder.encoding().mediaType(MediaType.APPLICATION_PROTOSTREAM);
|
||||
builder.statistics().enable();
|
||||
builder.expiration().enableReaper().wakeUpInterval(expirationWakeupPeriodMillis);
|
||||
|
||||
if (indexedEntity != null) {
|
||||
builder.indexing().enable().addIndexedEntities(Marshalling.protoEntity(indexedEntity));
|
||||
|
||||
@@ -25,7 +25,9 @@ import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
* @deprecated to be removed without replacement. The providers are responsible for purging the expired entries themselves.
|
||||
*/
|
||||
@Deprecated(since = "26.5", forRemoval = true)
|
||||
public class ClearExpiredUserSessions implements ScheduledTask {
|
||||
|
||||
protected static final Logger logger = Logger.getLogger(ClearExpiredUserSessions.class);
|
||||
|
||||
@@ -161,17 +161,26 @@ public interface UserSessionProvider extends Provider {
|
||||
|
||||
/**
|
||||
* Remove expired user sessions and client sessions in all the realms
|
||||
*
|
||||
* @deprecated to be removed without replacement. The providers are responsible for purging the expired entries
|
||||
* themselves.
|
||||
*/
|
||||
void removeAllExpired();
|
||||
@Deprecated(since = "26.5", forRemoval = true)
|
||||
default void removeAllExpired() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes expired user sessions owned by this realm from this provider.
|
||||
* If this `UserSessionProvider` uses `UserSessionPersister`, the removal of the expired
|
||||
* {@link UserSessionModel user sessions} is also propagated to relevant `UserSessionPersister`.
|
||||
* Removes expired user sessions owned by this realm from this provider. If this `UserSessionProvider` uses
|
||||
* `UserSessionPersister`, the removal of the expired {@link UserSessionModel user sessions} is also propagated to
|
||||
* relevant `UserSessionPersister`.
|
||||
*
|
||||
* @param realm {@link RealmModel} Realm where all the expired user sessions to be removed from.
|
||||
* @deprecated to be removed without replacement. The providers are responsible for purging the expired entries
|
||||
* themselves.
|
||||
*/
|
||||
void removeExpired(RealmModel realm);
|
||||
@Deprecated(since = "26.5", forRemoval = true)
|
||||
default void removeExpired(RealmModel realm) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all user sessions (regular and offline) from the specified realm.
|
||||
|
||||
@@ -65,7 +65,8 @@ public interface AuthenticationSessionProvider extends Provider {
|
||||
* @deprecated manual removal of expired entities should not be used anymore. It is responsibility of the store
|
||||
* implementation to handle expirable entities
|
||||
*/
|
||||
void removeAllExpired();
|
||||
@Deprecated(since = "19.0", forRemoval = true)
|
||||
default void removeAllExpired() {}
|
||||
|
||||
/**
|
||||
* Removes all expired root authentication sessions for the given realm.
|
||||
@@ -75,7 +76,8 @@ public interface AuthenticationSessionProvider extends Provider {
|
||||
* @deprecated manual removal of expired entities should not be used anymore. It is responsibility of the store
|
||||
* implementation to handle expirable entities
|
||||
*/
|
||||
void removeExpired(RealmModel realm);
|
||||
@Deprecated(since = "19.0", forRemoval = true)
|
||||
default void removeExpired(RealmModel realm) {}
|
||||
|
||||
/**
|
||||
* Removes all associated root authentication sessions to the given realm which was removed.
|
||||
@@ -87,7 +89,9 @@ public interface AuthenticationSessionProvider extends Provider {
|
||||
* Removes all associated root authentication sessions to the given realm and client which was removed.
|
||||
* @param realm {@code RealmModel} Can't be {@code null}.
|
||||
* @param client {@code ClientModel} Can't be {@code null}.
|
||||
* @deprecated to remove, all implementations are empty.
|
||||
*/
|
||||
@Deprecated(since = "26.5", forRemoval = true)
|
||||
void onClientRemoved(RealmModel realm, ClientModel client);
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,6 +27,7 @@ import org.keycloak.common.util.Time;
|
||||
import org.keycloak.events.admin.OperationType;
|
||||
import org.keycloak.events.admin.ResourceType;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.session.UserSessionPersisterProvider;
|
||||
import org.keycloak.representations.idm.ClientInitialAccessCreatePresentation;
|
||||
import org.keycloak.representations.idm.ClientInitialAccessPresentation;
|
||||
import org.keycloak.representations.idm.OAuth2ErrorRepresentation;
|
||||
@@ -155,8 +156,7 @@ public class InitialAccessTokenResourceTest {
|
||||
runOnServer.run(session -> {
|
||||
RealmModel realm = session.realms().getRealm(realmUuid);
|
||||
|
||||
session.sessions().removeExpired(realm);
|
||||
session.authenticationSessions().removeExpired(realm);
|
||||
session.getProvider(UserSessionPersisterProvider.class).removeExpired(realm);
|
||||
session.realms().removeExpiredClientInitialAccess();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ import org.keycloak.models.UserCredentialModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.UserProvider;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.models.session.UserSessionPersisterProvider;
|
||||
import org.keycloak.models.utils.ModelToRepresentation;
|
||||
import org.keycloak.models.utils.ResetTimeOffsetEvent;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
|
||||
@@ -201,8 +202,7 @@ public class TestingResourceProvider implements RealmResourceProvider {
|
||||
public Response removeExpired(@QueryParam("realm") final String name) {
|
||||
RealmModel realm = getRealmByName(name);
|
||||
|
||||
session.sessions().removeExpired(realm);
|
||||
session.authenticationSessions().removeExpired(realm);
|
||||
session.getProvider(UserSessionPersisterProvider.class).removeExpired(realm);
|
||||
session.realms().removeExpiredClientInitialAccess();
|
||||
|
||||
return Response.noContent().build();
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.RoleModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.models.session.UserSessionPersisterProvider;
|
||||
import org.keycloak.testsuite.client.KeycloakTestingClient;
|
||||
import org.keycloak.testsuite.runonserver.RunOnServer;
|
||||
import org.keycloak.testsuite.util.FlowUtil;
|
||||
@@ -181,10 +182,9 @@ final class BrokerRunOnServerUtil {
|
||||
}
|
||||
|
||||
static RunOnServer removeBrokerExpiredSessions() {
|
||||
return (RunOnServer) session -> {
|
||||
return session -> {
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
session.sessions().removeExpired(realm);
|
||||
session.authenticationSessions().removeExpired(realm);
|
||||
session.getProvider(UserSessionPersisterProvider.class).removeExpired(realm);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import org.keycloak.models.UserLoginFailureModel;
|
||||
import org.keycloak.models.UserManager;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.models.session.UserSessionPersisterProvider;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.models.utils.ResetTimeOffsetEvent;
|
||||
import org.keycloak.models.utils.SessionTimeoutHelper;
|
||||
@@ -305,7 +306,7 @@ public class UserSessionProviderTest extends AbstractTestRealmKeycloakTest {
|
||||
kcSession.getContext().setRealm(realm);
|
||||
clientSessionsKept.putAll(kcSession.sessions().getUserSessionsStream(realm,
|
||||
kcSession.users().getUserByUsername(realm, "user2"))
|
||||
.collect(Collectors.toMap(model -> model.getId(), model -> model.getAuthenticatedClientSessions().keySet().size())));
|
||||
.collect(Collectors.toMap(UserSessionModel::getId, model -> model.getAuthenticatedClientSessions().size())));
|
||||
|
||||
kcSession.sessions().removeUserSessions(realm, kcSession.users().getUserByUsername(realm, "user1"));
|
||||
});
|
||||
@@ -315,9 +316,9 @@ public class UserSessionProviderTest extends AbstractTestRealmKeycloakTest {
|
||||
assertEquals(0, kcSession.sessions().getUserSessionsStream(realm, kcSession.users().getUserByUsername(realm, "user1"))
|
||||
.count());
|
||||
List<UserSessionModel> userSessions = kcSession.sessions().getUserSessionsStream(realm,
|
||||
kcSession.users().getUserByUsername(realm, "user2")).collect(Collectors.toList());
|
||||
kcSession.users().getUserByUsername(realm, "user2")).toList();
|
||||
|
||||
assertSame(userSessions.size(), 1);
|
||||
assertSame(1, userSessions.size());
|
||||
|
||||
for (UserSessionModel userSession : userSessions) {
|
||||
Assert.assertEquals((int) clientSessionsKept.get(userSession.getId()),
|
||||
@@ -450,7 +451,7 @@ public class UserSessionProviderTest extends AbstractTestRealmKeycloakTest {
|
||||
// remove the expired sessions - we expect the first two sessions to have been removed as they either expired the max lifespan or the session idle timeouts.
|
||||
KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), (KeycloakSession session1) -> {
|
||||
session1.getContext().setRealm(realm);
|
||||
session1.sessions().removeExpired(realm);
|
||||
session.getProvider(UserSessionPersisterProvider.class).removeExpired(realm);
|
||||
});
|
||||
|
||||
KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), kcSession -> {
|
||||
@@ -578,7 +579,7 @@ public class UserSessionProviderTest extends AbstractTestRealmKeycloakTest {
|
||||
KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), (KeycloakSession kcSession) -> {
|
||||
RealmModel realm = kcSession.realms().getRealmByName("test");
|
||||
kcSession.getContext().setRealm(realm);
|
||||
kcSession.sessions().removeExpired(realm);
|
||||
session.getProvider(UserSessionPersisterProvider.class).removeExpired(realm);
|
||||
});
|
||||
|
||||
KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), (KeycloakSession kcSession) -> {
|
||||
@@ -624,7 +625,7 @@ public class UserSessionProviderTest extends AbstractTestRealmKeycloakTest {
|
||||
// reload userSession in current session
|
||||
userSession = session.sessions().getUserSession(realm, userSession.getId());
|
||||
Time.setOffset(3600000);
|
||||
session.sessions().removeExpired(realm);
|
||||
session.getProvider(UserSessionPersisterProvider.class).removeExpired(realm);
|
||||
|
||||
// Assert no exception is thrown here
|
||||
session.sessions().removeUserSession(realm, userSession);
|
||||
@@ -689,7 +690,7 @@ public class UserSessionProviderTest extends AbstractTestRealmKeycloakTest {
|
||||
session.getContext().setRealm(realm);
|
||||
ClientModel client = realm.getClientByClientId("test-app");
|
||||
UserSessionModel userSession = session.sessions().createUserSession(null, realm, session.users().getUserByUsername(realm, "user1"), "user1", "127.0.0.2", "form", true, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT);
|
||||
AuthenticatedClientSessionModel clientSession = createClientSession(session, client, userSession, "http://redirect", "state");
|
||||
createClientSession(session, client, userSession, "http://redirect", "state");
|
||||
|
||||
UserSessionModel userSessionLoaded = session.sessions().getUserSession(realm, userSession.getId());
|
||||
AuthenticatedClientSessionModel clientSessionLoaded = userSessionLoaded.getAuthenticatedClientSessions().get(client.getId());
|
||||
@@ -898,8 +899,7 @@ public class UserSessionProviderTest extends AbstractTestRealmKeycloakTest {
|
||||
|
||||
Map<String, List<String>> attributes = new HashMap<>();
|
||||
ProviderEventListener providerEventListener = event -> {
|
||||
if (event instanceof UserModel.UserRemovedEvent) {
|
||||
UserModel.UserRemovedEvent userRemovedEvent = (UserModel.UserRemovedEvent) event;
|
||||
if (event instanceof UserModel.UserRemovedEvent userRemovedEvent) {
|
||||
attributes.putAll(userRemovedEvent.getUser().getAttributes());
|
||||
}
|
||||
};
|
||||
@@ -913,12 +913,11 @@ public class UserSessionProviderTest extends AbstractTestRealmKeycloakTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static AuthenticatedClientSessionModel createClientSession(KeycloakSession session, ClientModel client, UserSessionModel userSession, String redirect, String state) {
|
||||
private static void createClientSession(KeycloakSession session, ClientModel client, UserSessionModel userSession, String redirect, String state) {
|
||||
RealmModel realm = session.realms().getRealmByName("test");
|
||||
AuthenticatedClientSessionModel clientSession = session.sessions().createClientSession(realm, client, userSession);
|
||||
clientSession.setRedirectUri(redirect);
|
||||
if (state != null) clientSession.setNote(OIDCLoginProtocol.STATE_PARAM, state);
|
||||
return clientSession;
|
||||
}
|
||||
|
||||
private static UserSessionModel[] createSessions(KeycloakSession session) {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
package org.keycloak.testsuite.model.session;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
@@ -30,6 +31,7 @@ import java.util.concurrent.CyclicBarrier;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.IntFunction;
|
||||
@@ -66,6 +68,8 @@ import org.keycloak.models.sessions.infinispan.PersistentUserSessionProvider;
|
||||
import org.keycloak.models.sessions.infinispan.changes.SessionEntityWrapper;
|
||||
import org.keycloak.models.sessions.infinispan.entities.AuthenticatedClientSessionEntity;
|
||||
import org.keycloak.models.sessions.infinispan.entities.EmbeddedClientSessionKey;
|
||||
import org.keycloak.models.sessions.infinispan.expiration.ExpirationTaskFactory;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.models.utils.RealmExpiration;
|
||||
import org.keycloak.models.utils.ResetTimeOffsetEvent;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
@@ -78,6 +82,7 @@ import org.keycloak.testsuite.federation.HardcodedClientStorageProviderFactory;
|
||||
import org.keycloak.testsuite.model.KeycloakModelTest;
|
||||
import org.keycloak.testsuite.model.RequireProvider;
|
||||
|
||||
import org.awaitility.Awaitility;
|
||||
import org.hamcrest.Matchers;
|
||||
import org.infinispan.Cache;
|
||||
import org.junit.Assert;
|
||||
@@ -1047,6 +1052,130 @@ public class UserSessionPersisterProviderTest extends KeycloakModelTest {
|
||||
doExpirationWithSessions(JpaUserSessionPersisterProviderFactory.DEFAULT_EXPIRATION_BATCH * 2, initialSessions, eventCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExpirationDistribution() throws InterruptedException {
|
||||
Assume.assumeTrue(MultiSiteUtils.isPersistentSessionsEnabled());
|
||||
closeKeycloakSessionFactory();
|
||||
|
||||
final int clusterSize = 4;
|
||||
final CyclicBarrier barrier = new CyclicBarrier(clusterSize);
|
||||
final AtomicInteger indexGenerator = new AtomicInteger();
|
||||
final AtomicInteger count = new AtomicInteger();
|
||||
|
||||
inIndependentFactories(clusterSize, 60, () -> {
|
||||
try {
|
||||
final boolean primary = indexGenerator.incrementAndGet() == 1;
|
||||
final int sessionCount = 10;
|
||||
// await for the startup
|
||||
barrier.await();
|
||||
|
||||
if (primary) {
|
||||
// persist sessions
|
||||
createSessions(sessionCount, value -> false);
|
||||
}
|
||||
|
||||
barrier.await();
|
||||
assertEquals(sessionCount, countPersistedSessionsForUser1());
|
||||
if (InfinispanUtils.isRemoteInfinispan()) {
|
||||
var factory = getFactory();
|
||||
//let's make sure everybody gets the membership right.
|
||||
Awaitility.await()
|
||||
.pollDelay(Duration.ofMillis(100))
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.untilAsserted(() -> assertEquals(4, (long) KeycloakModelUtils.runJobInTransactionWithResult(factory, ExpirationTaskFactory::membersSize)));
|
||||
}
|
||||
barrier.await();
|
||||
|
||||
// let's find out who is going to do the expiration for the realm
|
||||
boolean isResponsibleForExpiration = withRealm(realmId, (session, realmModel) -> {
|
||||
if (ExpirationTaskFactory.isSelectedForExpireSessionsInRealm(session, realmModel)) {
|
||||
count.incrementAndGet();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// we should have a single node responsible to remove the expired entries for the test realm
|
||||
barrier.await();
|
||||
assertEquals(1, count.get());
|
||||
barrier.await();
|
||||
|
||||
// advance time
|
||||
if (primary) {
|
||||
withRealmConsumer(realmId, (session, realm) -> Time.setOffset(realm.getSsoSessionIdleTimeout() + PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS + 10));
|
||||
}
|
||||
|
||||
barrier.await();
|
||||
|
||||
// trigger expiration task in other nodes
|
||||
if (!isResponsibleForExpiration) {
|
||||
inComittedTransaction(ExpirationTaskFactory::manualTriggerTask);
|
||||
}
|
||||
|
||||
barrier.await();
|
||||
if (primary) {
|
||||
withRealmConsumer(realmId, (session, realm) -> Time.setOffset(0));
|
||||
}
|
||||
|
||||
// it should not delete anything.
|
||||
barrier.await();
|
||||
assertEquals(sessionCount, countPersistedSessionsForUser1());
|
||||
barrier.await();
|
||||
|
||||
// advance time
|
||||
if (primary) {
|
||||
withRealmConsumer(realmId, (session, realm) -> Time.setOffset(realm.getSsoSessionIdleTimeout() + PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS + 10));
|
||||
}
|
||||
|
||||
barrier.await();
|
||||
|
||||
// now expire it
|
||||
if (isResponsibleForExpiration) {
|
||||
inComittedTransaction(ExpirationTaskFactory::manualTriggerTask);
|
||||
}
|
||||
|
||||
barrier.await();
|
||||
assertEquals(0, countPersistedSessionsForUser1());
|
||||
barrier.await();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (BrokenBarrierException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExpirationTaskFrequency() {
|
||||
Assume.assumeTrue(MultiSiteUtils.isPersistentSessionsEnabled());
|
||||
final AtomicInteger counter = new AtomicInteger();
|
||||
final int sleepSeconds = 5;
|
||||
|
||||
// with 1 second interval between tasks, we should have at lest 4 executions.
|
||||
inComittedTransaction(session -> {
|
||||
var task = ExpirationTaskFactory.create(session, 1, value -> counter.incrementAndGet());
|
||||
task.start();
|
||||
try {
|
||||
Thread.sleep(TimeUnit.SECONDS.toMillis(sleepSeconds));
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
task.stop();
|
||||
});
|
||||
|
||||
assertThat(counter.get(), Matchers.greaterThanOrEqualTo(sleepSeconds - 1));
|
||||
assertThat(counter.get(), Matchers.lessThanOrEqualTo(sleepSeconds + 1));
|
||||
}
|
||||
|
||||
private long countPersistedSessionsForUser1() {
|
||||
return withRealm(realmId, (session, realm) -> {
|
||||
var user = session.users().getUserByUsername(realm, "user1");
|
||||
var provider = session.getProvider(UserSessionPersisterProvider.class);
|
||||
return provider.loadUserSessionsStream(realm, user, false, null, null)
|
||||
.count();
|
||||
});
|
||||
}
|
||||
|
||||
private long doExpirationWithSessions(int count, int initialSessionCount, long currentEventCount) {
|
||||
String userId = withRealm(realmId, (session, realm) -> session.users().getUserByUsername(realm, "user1").getId());
|
||||
int offset = withRealm(realmId, (session, realm) -> realm.getSsoSessionMaxLifespan() + PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS + 10);
|
||||
|
||||
@@ -49,6 +49,7 @@ import org.keycloak.models.UserProvider;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.models.UserSessionProvider;
|
||||
import org.keycloak.models.session.UserSessionPersisterProvider;
|
||||
import org.keycloak.models.sessions.infinispan.InfinispanUserSessionProviderFactory;
|
||||
import org.keycloak.models.sessions.infinispan.changes.sessions.PersisterLastSessionRefreshStoreFactory;
|
||||
import org.keycloak.models.utils.ResetTimeOffsetEvent;
|
||||
import org.keycloak.services.managers.UserSessionManager;
|
||||
@@ -547,6 +548,18 @@ public class UserSessionProviderOfflineModelTest extends KeycloakModelTest {
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExpirationTaskExists() {
|
||||
// embedded + volatile sessions store offline sessions in the database, the task must be running in this case too.
|
||||
Assume.assumeTrue(InfinispanUtils.isEmbeddedInfinispan());
|
||||
Assume.assumeFalse(MultiSiteUtils.isPersistentSessionsEnabled());
|
||||
inComittedTransaction(session -> {
|
||||
var providerFactory = session.getKeycloakSessionFactory().getProviderFactory(UserSessionProvider.class);
|
||||
Assert.assertTrue(providerFactory instanceof InfinispanUserSessionProviderFactory);
|
||||
Assert.assertNotNull(((InfinispanUserSessionProviderFactory) providerFactory).getExpirationTask());
|
||||
});
|
||||
}
|
||||
|
||||
private static Set<String> createOfflineSessionIncludeClientSessions(KeycloakSession session, UserSessionModel
|
||||
userSession) {
|
||||
Set<String> offlineSessions = new HashSet<>();
|
||||
|
||||
Reference in New Issue
Block a user