From c580c88c93f47e20f6684236400ba869a2dc659b Mon Sep 17 00:00:00 2001 From: Alexander Schwartz Date: Thu, 28 Mar 2024 09:17:07 +0100 Subject: [PATCH] Persist online sessions to the database (#27977) Adding two feature toggles for new code paths to store online sessions in the existing offline sessions table. Separate the code which is due to be changed in the next iteration in new classes/providers which used instead of the old one. Closes #27976 Signed-off-by: Alexander Schwartz Signed-off-by: Michal Hajas Co-authored-by: Michal Hajas --- .github/workflows/ci.yml | 58 + .../java/org/keycloak/common/Profile.java | 3 + .../AuthenticatedClientSessionAdapter.java | 7 +- .../InfinispanUserSessionProvider.java | 13 +- .../InfinispanUserSessionProviderFactory.java | 36 +- .../PersistentUserSessionProvider.java | 1128 +++++++++++++++++ .../infinispan/SessionRefreshStore.java | 29 + .../infinispan/UserSessionAdapter.java | 16 +- ...onPersistentChangelogBasedTransaction.java | 191 +++ .../EmbeddedCachesChangesPerformer.java | 140 ++ .../InfinispanChangelogBasedTransaction.java | 28 +- .../changes/JpaChangesPerformer.java | 707 +++++++++++ ...tentSessionsChangelogBasedTransaction.java | 89 ++ .../changes/RemoteCachesChangesPerformer.java | 53 + .../changes/SessionChangesPerformer.java | 28 + ...onPersistentChangelogBasedTransaction.java | 151 +++ .../AuthenticatedClientSessionEntity.java | 10 + .../stream/UserSessionPredicate.java | 46 + .../JpaUserSessionPersisterProvider.java | 222 +++- .../PersistentClientSessionEntity.java | 5 + .../session/PersistentUserSessionEntity.java | 24 +- .../org/keycloak/storage/jpa/KeyUtils.java | 3 + .../META-INF/jpa-changelog-25.0.0.xml | 57 + .../META-INF/queries-default.properties | 6 +- .../META-INF/queries-mariadb.properties | 5 +- .../META-INF/queries-mysql.properties | 5 +- ...tentAuthenticatedClientSessionAdapter.java | 69 +- .../session/PersistentClientSessionModel.java | 49 +- .../session/PersistentUserSessionAdapter.java | 142 ++- .../session/PersistentUserSessionModel.java | 53 +- .../session/UserSessionPersisterProvider.java | 12 + .../it/cli/dist/FeaturesDistTest.java | 11 +- .../models/OfflineUserSessionModel.java | 2 + .../models/utils/RealmModelDelegate.java | 1123 ++++++++++++++++ .../utils/UserSessionModelDelegate.java | 134 ++ .../AbstractQuarkusDeployableContainer.java | 15 +- .../model/UserSessionProviderTest.java | 5 +- .../testsuite/oauth/OfflineTokenTest.java | 1 + .../base/testsuites/persistent-sessions-suite | 22 + testsuite/model/pom.xml | 7 + .../parameters/PersistentUserSessions.java | 41 + .../OfflineSessionPersistenceTest.java | 9 +- .../session/UserSessionInitializerTest.java | 6 + .../UserSessionPersisterProviderTest.java | 31 +- .../session/UserSessionProviderModelTest.java | 4 +- .../UserSessionProviderOfflineModelTest.java | 15 +- 46 files changed, 4633 insertions(+), 178 deletions(-) create mode 100755 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/PersistentUserSessionProvider.java create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/SessionRefreshStore.java create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/ClientSessionPersistentChangelogBasedTransaction.java create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/EmbeddedCachesChangesPerformer.java create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/JpaChangesPerformer.java create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/PersistentSessionsChangelogBasedTransaction.java create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/RemoteCachesChangesPerformer.java create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/SessionChangesPerformer.java create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/UserSessionPersistentChangelogBasedTransaction.java create mode 100644 server-spi-private/src/main/java/org/keycloak/models/utils/RealmModelDelegate.java create mode 100644 server-spi-private/src/main/java/org/keycloak/models/utils/UserSessionModelDelegate.java create mode 100644 testsuite/integration-arquillian/tests/base/testsuites/persistent-sessions-suite create mode 100644 testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/PersistentUserSessions.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5402375bb8..c8149dd031 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -303,6 +303,63 @@ jobs: with: job-id: jdk-integration-tests-${{ matrix.os }}-${{ matrix.dist }}-${{ matrix.version }} + persistent-sessions-tests: + name: Persistent Sessions IT + needs: [build, conditional] + if: needs.conditional.outputs.ci-store == 'true' + runs-on: ubuntu-latest + timeout-minutes: 150 + steps: + - uses: actions/checkout@v4 + + - id: integration-test-setup + name: Integration test setup + uses: ./.github/actions/integration-test-setup + + - name: Run base tests + run: | + TESTS=`testsuite/integration-arquillian/tests/base/testsuites/suite.sh persistent-sessions` + echo "Tests: $TESTS" + ./mvnw test ${{ env.SUREFIRE_RETRY }} -Pauth-server-quarkus -Dauth.server.features=persistent-user-sessions,persistent-user-sessions-no-cache -Dtest=$TESTS -pl testsuite/integration-arquillian/tests/base 2>&1 | misc/log/trimmer.sh + + - name: Upload JVM Heapdumps + if: always() + uses: ./.github/actions/upload-heapdumps + + - uses: ./.github/actions/upload-flaky-tests + name: Upload flaky tests + env: + GH_TOKEN: ${{ github.token }} + with: + job-name: Store IT + + - name: Surefire reports + if: always() + uses: ./.github/actions/archive-surefire-reports + with: + job-id: store-integration-tests-${{ matrix.db }} + + - name: EC2 Maven Logs + if: failure() + uses: actions/upload-artifact@v3 + with: + name: store-it-mvn-logs + path: .github/scripts/ansible/files + + - name: Delete Aurora EC2 Instance + if: ${{ always() && matrix.db == 'aurora-postgres' }} + working-directory: .github/scripts/ansible + run: | + export CLUSTER_NAME=${{ steps.aurora-tests.outputs.ec2_cluster }} + ./aws_ec2.sh delete ${{ steps.aurora-init.outputs.region }} + + - name: Delete Aurora DB + if: ${{ always() && matrix.db == 'aurora-postgres' }} + uses: ./.github/actions/aurora-delete-database + with: + name: ${{ steps.aurora-init.outputs.name }} + region: ${{ steps.aurora-init.outputs.region }} + store-integration-tests: name: Store IT needs: [build, conditional] @@ -757,6 +814,7 @@ jobs: - quarkus-integration-tests - jdk-integration-tests - store-integration-tests + - persistent-sessions-tests - store-model-tests - clustering-integration-tests - fips-unit-tests diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java index 9172652e8e..a7c85b79ee 100755 --- a/common/src/main/java/org/keycloak/common/Profile.java +++ b/common/src/main/java/org/keycloak/common/Profile.java @@ -109,6 +109,9 @@ public class Profile { HOSTNAME_V1("Hostname Options V1", Type.DEFAULT), //HOSTNAME_V2("Hostname Options V2", Type.DEFAULT, 2), + PERSISTENT_USER_SESSIONS("Persistent online user sessions across restarts and upgrades", Type.EXPERIMENTAL), + PERSISTENT_USER_SESSIONS_NO_CACHE("No caching for online user sessions when they are persisted", Type.EXPERIMENTAL), + OID4VC_VCI("Support for the OID4VCI protocol as part of OID4VC.", Type.EXPERIMENTAL), DECLARATIVE_UI("declarative ui spi", Type.EXPERIMENTAL), 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 4e1ed8bee3..cf44e6b244 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 @@ -32,10 +32,9 @@ import org.keycloak.models.sessions.infinispan.changes.SessionEntityWrapper; import org.keycloak.models.sessions.infinispan.changes.ClientSessionUpdateTask; import org.keycloak.models.sessions.infinispan.changes.SessionUpdateTask; import org.keycloak.models.sessions.infinispan.changes.Tasks; -import org.keycloak.models.sessions.infinispan.changes.UserSessionUpdateTask; import org.keycloak.models.sessions.infinispan.changes.sessions.CrossDCLastSessionRefreshChecker; import org.keycloak.models.sessions.infinispan.entities.AuthenticatedClientSessionEntity; -import org.keycloak.models.sessions.infinispan.entities.UserSessionEntity; + import java.util.UUID; /** @@ -44,14 +43,14 @@ import java.util.UUID; public class AuthenticatedClientSessionAdapter implements AuthenticatedClientSessionModel { private final KeycloakSession kcSession; - private final InfinispanUserSessionProvider provider; + private final SessionRefreshStore provider; private AuthenticatedClientSessionEntity entity; private final ClientModel client; private final InfinispanChangelogBasedTransaction clientSessionUpdateTx; private UserSessionModel userSession; private boolean offline; - public AuthenticatedClientSessionAdapter(KeycloakSession kcSession, InfinispanUserSessionProvider provider, + public AuthenticatedClientSessionAdapter(KeycloakSession kcSession, SessionRefreshStore provider, AuthenticatedClientSessionEntity entity, ClientModel client, UserSessionModel userSession, InfinispanChangelogBasedTransaction clientSessionUpdateTx, boolean offline) { if (userSession == null) { 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 de604041fc..442d861c6a 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 @@ -90,7 +90,7 @@ import static org.keycloak.utils.StreamsUtil.paginatedStream; /** * @author Stian Thorgersen */ -public class InfinispanUserSessionProvider implements UserSessionProvider { +public class InfinispanUserSessionProvider implements UserSessionProvider, SessionRefreshStore { private static final Logger log = Logger.getLogger(InfinispanUserSessionProvider.class); @@ -176,15 +176,18 @@ public class InfinispanUserSessionProvider implements UserSessionProvider { return offline ? offlineClientSessionTx : clientSessionTx; } - protected CrossDCLastSessionRefreshStore getLastSessionRefreshStore() { + @Override + public CrossDCLastSessionRefreshStore getLastSessionRefreshStore() { return lastSessionRefreshStore; } - protected CrossDCLastSessionRefreshStore getOfflineLastSessionRefreshStore() { + @Override + public CrossDCLastSessionRefreshStore getOfflineLastSessionRefreshStore() { return offlineLastSessionRefreshStore; } - protected PersisterLastSessionRefreshStore getPersisterLastSessionRefreshStore() { + @Override + public PersisterLastSessionRefreshStore getPersisterLastSessionRefreshStore() { return persisterLastSessionRefreshStore; } @@ -244,7 +247,7 @@ public class InfinispanUserSessionProvider implements UserSessionProvider { return adapter; } - void updateSessionEntity(UserSessionEntity entity, RealmModel realm, UserModel user, String loginUsername, String ipAddress, String authMethod, boolean rememberMe, String brokerSessionId, String brokerUserId) { + static void updateSessionEntity(UserSessionEntity entity, RealmModel realm, UserModel user, String loginUsername, String ipAddress, String authMethod, boolean rememberMe, String brokerSessionId, String brokerUserId) { entity.setRealmId(realm.getId()); entity.setUser(user.getId()); entity.setLoginUsername(loginUsername); 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 7bc470a1a5..9f79f4c733 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 @@ -23,6 +23,7 @@ import org.infinispan.persistence.remote.RemoteStore; import org.jboss.logging.Logger; import org.keycloak.Config; import org.keycloak.cluster.ClusterProvider; +import org.keycloak.common.Profile; import org.keycloak.common.util.Environment; import org.keycloak.common.util.Time; import org.keycloak.connections.infinispan.InfinispanConnectionProvider; @@ -94,13 +95,29 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider private InfinispanKeyGenerator keyGenerator; @Override - public InfinispanUserSessionProvider create(KeycloakSession session) { + public UserSessionProvider create(KeycloakSession session) { InfinispanConnectionProvider connections = session.getProvider(InfinispanConnectionProvider.class); Cache> cache = connections.getCache(InfinispanConnectionProvider.USER_SESSION_CACHE_NAME); Cache> offlineSessionsCache = connections.getCache(InfinispanConnectionProvider.OFFLINE_USER_SESSION_CACHE_NAME); Cache> clientSessionCache = connections.getCache(InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME); Cache> offlineClientSessionsCache = connections.getCache(InfinispanConnectionProvider.OFFLINE_CLIENT_SESSION_CACHE_NAME); + if (Profile.isFeatureEnabled(Profile.Feature.PERSISTENT_USER_SESSIONS)) { + return new PersistentUserSessionProvider( + session, + remoteCacheInvoker, + lastSessionRefreshStore, + offlineLastSessionRefreshStore, + persisterLastSessionRefreshStore, + keyGenerator, + cache, + offlineSessionsCache, + clientSessionCache, + offlineClientSessionsCache, + this::deriveOfflineSessionCacheEntryLifespanMs, + this::deriveOfflineClientSessionCacheEntryLifespanOverrideMs + ); + } return new InfinispanUserSessionProvider( session, remoteCacheInvoker, @@ -148,8 +165,15 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider } else if (event instanceof UserModel.UserRemovedEvent) { UserModel.UserRemovedEvent userRemovedEvent = (UserModel.UserRemovedEvent) event; - InfinispanUserSessionProvider provider = (InfinispanUserSessionProvider) userRemovedEvent.getKeycloakSession().getProvider(UserSessionProvider.class, getId()); - provider.onUserRemoved(userRemovedEvent.getRealm(), userRemovedEvent.getUser()); + UserSessionProvider provider1 = userRemovedEvent.getKeycloakSession().getProvider(UserSessionProvider.class, getId()); + if (provider1 instanceof InfinispanUserSessionProvider) { + ((InfinispanUserSessionProvider) provider1).onUserRemoved(userRemovedEvent.getRealm(), userRemovedEvent.getUser()); + } else if (provider1 instanceof PersistentUserSessionProvider) { + ((PersistentUserSessionProvider) provider1).onUserRemoved(userRemovedEvent.getRealm(), userRemovedEvent.getUser()); + } else { + throw new IllegalStateException("Unknown provider type: " + provider1.getClass()); + } + } else if (event instanceof ResetTimeOffsetEvent) { if (persisterLastSessionRefreshStore != null) { persisterLastSessionRefreshStore.reset(); @@ -212,6 +236,8 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider protected void eventReceived(KeycloakSession session, UserSessionProvider provider, RealmRemovedSessionEvent sessionEvent) { if (provider instanceof InfinispanUserSessionProvider) { ((InfinispanUserSessionProvider) provider).onRealmRemovedEvent(sessionEvent.getRealmId()); + } else if (provider instanceof PersistentUserSessionProvider) { + ((PersistentUserSessionProvider) provider).onRealmRemovedEvent(sessionEvent.getRealmId()); } } @@ -224,6 +250,8 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider protected void eventReceived(KeycloakSession session, UserSessionProvider provider, ClientRemovedSessionEvent sessionEvent) { if (provider instanceof InfinispanUserSessionProvider) { ((InfinispanUserSessionProvider) provider).onClientRemovedEvent(sessionEvent.getRealmId(), sessionEvent.getClientUuid()); + } else if (provider instanceof PersistentUserSessionProvider) { + ((PersistentUserSessionProvider) provider).onClientRemovedEvent(sessionEvent.getRealmId(), sessionEvent.getClientUuid()); } } @@ -236,6 +264,8 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider protected void eventReceived(KeycloakSession session, UserSessionProvider provider, RemoveUserSessionsEvent sessionEvent) { if (provider instanceof InfinispanUserSessionProvider) { ((InfinispanUserSessionProvider) provider).onRemoveUserSessionsEvent(sessionEvent.getRealmId()); + } else if (provider instanceof PersistentUserSessionProvider) { + ((PersistentUserSessionProvider) provider).onRemoveUserSessionsEvent(sessionEvent.getRealmId()); } } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/PersistentUserSessionProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/PersistentUserSessionProvider.java new file mode 100755 index 0000000000..936811e442 --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/PersistentUserSessionProvider.java @@ -0,0 +1,1128 @@ +/* + * Copyright 2016 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; + +import org.infinispan.Cache; +import org.infinispan.client.hotrod.RemoteCache; +import org.infinispan.client.hotrod.exceptions.HotRodClientException; +import org.infinispan.commons.api.BasicCache; +import org.infinispan.context.Flag; +import org.jboss.logging.Logger; +import org.keycloak.cluster.ClusterProvider; +import org.keycloak.common.Profile; +import org.keycloak.common.Profile.Feature; +import org.keycloak.common.util.Retry; +import org.keycloak.common.util.Time; +import org.keycloak.connections.infinispan.InfinispanUtil; +import org.keycloak.models.AuthenticatedClientSessionModel; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelException; +import org.keycloak.models.OfflineUserSessionModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserProvider; +import org.keycloak.models.UserSessionModel; +import org.keycloak.models.UserSessionProvider; +import org.keycloak.models.light.LightweightUserAdapter; +import org.keycloak.models.session.UserSessionPersisterProvider; +import org.keycloak.models.sessions.infinispan.changes.ClientSessionPersistentChangelogBasedTransaction; +import org.keycloak.models.sessions.infinispan.changes.InfinispanChangelogBasedTransaction; +import org.keycloak.models.sessions.infinispan.changes.SessionEntityWrapper; +import org.keycloak.models.sessions.infinispan.changes.SessionUpdateTask; +import org.keycloak.models.sessions.infinispan.changes.Tasks; +import org.keycloak.models.sessions.infinispan.changes.UserSessionPersistentChangelogBasedTransaction; +import org.keycloak.models.sessions.infinispan.changes.sessions.CrossDCLastSessionRefreshStore; +import org.keycloak.models.sessions.infinispan.changes.sessions.PersisterLastSessionRefreshStore; +import org.keycloak.models.sessions.infinispan.entities.AuthenticatedClientSessionEntity; +import org.keycloak.models.sessions.infinispan.entities.AuthenticatedClientSessionStore; +import org.keycloak.models.sessions.infinispan.entities.SessionEntity; +import org.keycloak.models.sessions.infinispan.entities.UserSessionEntity; +import org.keycloak.models.sessions.infinispan.events.RealmRemovedSessionEvent; +import org.keycloak.models.sessions.infinispan.events.RemoveUserSessionsEvent; +import org.keycloak.models.sessions.infinispan.events.SessionEventsSenderTransaction; +import org.keycloak.models.sessions.infinispan.remotestore.RemoteCacheInvoker; +import org.keycloak.models.sessions.infinispan.stream.Mappers; +import org.keycloak.models.sessions.infinispan.stream.SessionPredicate; +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.SessionTimeouts; +import org.keycloak.models.utils.UserModelDelegate; + +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.keycloak.models.Constants.SESSION_NOTE_LIGHTWEIGHT_USER; +import static org.keycloak.utils.StreamsUtil.paginatedStream; + +/** + * @author Stian Thorgersen + */ +public class PersistentUserSessionProvider implements UserSessionProvider, SessionRefreshStore { + + private static final Logger log = Logger.getLogger(PersistentUserSessionProvider.class); + + protected final KeycloakSession session; + + protected final Cache> sessionCache; + protected final Cache> offlineSessionCache; + protected final Cache> clientSessionCache; + protected final Cache> offlineClientSessionCache; + + protected final UserSessionPersistentChangelogBasedTransaction sessionTx; + protected final UserSessionPersistentChangelogBasedTransaction offlineSessionTx; + protected final ClientSessionPersistentChangelogBasedTransaction clientSessionTx; + protected final ClientSessionPersistentChangelogBasedTransaction offlineClientSessionTx; + + protected final SessionEventsSenderTransaction clusterEventsSenderTx; + + protected final CrossDCLastSessionRefreshStore lastSessionRefreshStore; + protected final CrossDCLastSessionRefreshStore offlineLastSessionRefreshStore; + protected final PersisterLastSessionRefreshStore persisterLastSessionRefreshStore; + + protected final RemoteCacheInvoker remoteCacheInvoker; + protected final InfinispanKeyGenerator keyGenerator; + + protected final SessionFunction offlineSessionCacheEntryLifespanAdjuster; + + protected final SessionFunction offlineClientSessionCacheEntryLifespanAdjuster; + + public PersistentUserSessionProvider(KeycloakSession session, + RemoteCacheInvoker remoteCacheInvoker, + CrossDCLastSessionRefreshStore lastSessionRefreshStore, + CrossDCLastSessionRefreshStore offlineLastSessionRefreshStore, + PersisterLastSessionRefreshStore persisterLastSessionRefreshStore, + InfinispanKeyGenerator keyGenerator, + Cache> sessionCache, + Cache> offlineSessionCache, + Cache> clientSessionCache, + Cache> offlineClientSessionCache, + SessionFunction offlineSessionCacheEntryLifespanAdjuster, + SessionFunction offlineClientSessionCacheEntryLifespanAdjuster) { + if (!Profile.isFeatureEnabled(Profile.Feature.PERSISTENT_USER_SESSIONS)) { + throw new IllegalStateException("Persistent user sessions are not enabled"); + } + + this.session = session; + + this.sessionCache = sessionCache; + this.clientSessionCache = clientSessionCache; + this.offlineSessionCache = offlineSessionCache; + this.offlineClientSessionCache = offlineClientSessionCache; + + this.sessionTx = new UserSessionPersistentChangelogBasedTransaction(session, sessionCache, remoteCacheInvoker, SessionTimeouts::getUserSessionLifespanMs, SessionTimeouts::getUserSessionMaxIdleMs, false); + this.offlineSessionTx = new UserSessionPersistentChangelogBasedTransaction(session, offlineSessionCache, remoteCacheInvoker, offlineSessionCacheEntryLifespanAdjuster, SessionTimeouts::getOfflineSessionMaxIdleMs, true); + + this.clientSessionTx = new ClientSessionPersistentChangelogBasedTransaction(session, clientSessionCache, remoteCacheInvoker, SessionTimeouts::getClientSessionLifespanMs, SessionTimeouts::getClientSessionMaxIdleMs, false, keyGenerator, sessionTx); + this.offlineClientSessionTx = new ClientSessionPersistentChangelogBasedTransaction(session, offlineClientSessionCache, remoteCacheInvoker, offlineClientSessionCacheEntryLifespanAdjuster, SessionTimeouts::getOfflineClientSessionMaxIdleMs, true, keyGenerator, offlineSessionTx); + + this.clusterEventsSenderTx = new SessionEventsSenderTransaction(session); + + this.lastSessionRefreshStore = lastSessionRefreshStore; + this.offlineLastSessionRefreshStore = offlineLastSessionRefreshStore; + this.persisterLastSessionRefreshStore = persisterLastSessionRefreshStore; + this.remoteCacheInvoker = remoteCacheInvoker; + this.keyGenerator = keyGenerator; + this.offlineSessionCacheEntryLifespanAdjuster = offlineSessionCacheEntryLifespanAdjuster; + this.offlineClientSessionCacheEntryLifespanAdjuster = offlineClientSessionCacheEntryLifespanAdjuster; + + session.getTransactionManager().enlistAfterCompletion(clusterEventsSenderTx); + session.getTransactionManager().enlistAfterCompletion(sessionTx); + session.getTransactionManager().enlistAfterCompletion(offlineSessionTx); + session.getTransactionManager().enlistAfterCompletion(clientSessionTx); + session.getTransactionManager().enlistAfterCompletion(offlineClientSessionTx); + } + + protected Cache> getCache(boolean offline) { + return offline ? offlineSessionCache : sessionCache; + } + + protected UserSessionPersistentChangelogBasedTransaction getTransaction(boolean offline) { + return offline ? offlineSessionTx : sessionTx; + } + + protected Cache> getClientSessionCache(boolean offline) { + return offline ? offlineClientSessionCache : clientSessionCache; + } + + protected ClientSessionPersistentChangelogBasedTransaction getClientSessionTransaction(boolean offline) { + return offline ? offlineClientSessionTx : clientSessionTx; + } + + @Override + public CrossDCLastSessionRefreshStore getLastSessionRefreshStore() { + return lastSessionRefreshStore; + } + + @Override + public CrossDCLastSessionRefreshStore getOfflineLastSessionRefreshStore() { + return offlineLastSessionRefreshStore; + } + + @Override + public PersisterLastSessionRefreshStore getPersisterLastSessionRefreshStore() { + return persisterLastSessionRefreshStore; + } + + @Override + public KeycloakSession getKeycloakSession() { + return session; + } + + @Override + public AuthenticatedClientSessionModel createClientSession(RealmModel realm, ClientModel client, UserSessionModel userSession) { + final UUID clientSessionId; + if (userSession.isOffline()) { + clientSessionId = keyGenerator.generateKeyUUID(session, clientSessionCache); + } else { + clientSessionId = PersistentUserSessionProvider.createClientSessionUUID(userSession.getId(), client.getId()); + } + AuthenticatedClientSessionEntity entity = new AuthenticatedClientSessionEntity(clientSessionId); + entity.setRealmId(realm.getId()); + entity.setClientId(client.getId()); + entity.setUserSessionId(userSession.getId()); + entity.setTimestamp(Time.currentTime()); + entity.getNotes().put(AuthenticatedClientSessionModel.STARTED_AT_NOTE, String.valueOf(entity.getTimestamp())); + entity.getNotes().put(AuthenticatedClientSessionModel.USER_SESSION_STARTED_AT_NOTE, String.valueOf(userSession.getStarted())); + if (userSession.isRememberMe()) { + entity.getNotes().put(AuthenticatedClientSessionModel.USER_SESSION_REMEMBER_ME_NOTE, "true"); + } + + UserSessionPersistentChangelogBasedTransaction userSessionUpdateTx = getTransaction(false); + InfinispanChangelogBasedTransaction clientSessionUpdateTx = getClientSessionTransaction(false); + AuthenticatedClientSessionAdapter adapter = new AuthenticatedClientSessionAdapter(session, this, entity, client, userSession, clientSessionUpdateTx, false); + + if (Profile.isFeatureEnabled(Feature.PERSISTENT_USER_SESSIONS_NO_CACHE)) { + if (userSession.isOffline()) { + // If this is an offline session, and the referred online session doesn't exist anymore, don't register the client session in the transaction. + // Instead keep it transient and it will be added to the offline session only afterward. This is expected by SessionTimeoutsTest.testOfflineUserClientIdleTimeoutSmallerThanSessionOneRefresh. + if (userSessionUpdateTx.get(realm, userSession.getId()) == null) { + return adapter; + } + } + } + + // For now, the clientSession is considered transient in case that userSession was transient + UserSessionModel.SessionPersistenceState persistenceState = userSession.getPersistenceState() != null ? + userSession.getPersistenceState() : UserSessionModel.SessionPersistenceState.PERSISTENT; + + SessionUpdateTask createClientSessionTask = Tasks.addIfAbsentSync(); + clientSessionUpdateTx.addTask(clientSessionId, createClientSessionTask, entity, persistenceState); + + SessionUpdateTask registerClientSessionTask = new RegisterClientSessionTask(client.getId(), clientSessionId); + userSessionUpdateTx.addTask(userSession.getId(), registerClientSessionTask); + + return adapter; + } + + @Override + public UserSessionModel createUserSession(String id, RealmModel realm, UserModel user, String loginUsername, String ipAddress, + String authMethod, boolean rememberMe, String brokerSessionId, String brokerUserId, UserSessionModel.SessionPersistenceState persistenceState) { + if (id == null) { + id = keyGenerator.generateKeyString(session, sessionCache); + } + + UserSessionEntity entity = new UserSessionEntity(); + entity.setId(id); + updateSessionEntity(entity, realm, user, loginUsername, ipAddress, authMethod, rememberMe, brokerSessionId, brokerUserId); + + SessionUpdateTask createSessionTask = Tasks.addIfAbsentSync(); + sessionTx.addTask(id, createSessionTask, entity, persistenceState); + + UserSessionAdapter adapter = user instanceof LightweightUserAdapter + ? wrap(realm, entity, false, user) + : wrap(realm, entity, false); + adapter.setPersistenceState(persistenceState); + return adapter; + } + + void updateSessionEntity(UserSessionEntity entity, RealmModel realm, UserModel user, String loginUsername, String ipAddress, String authMethod, boolean rememberMe, String brokerSessionId, String brokerUserId) { + entity.setRealmId(realm.getId()); + entity.setUser(user.getId()); + entity.setLoginUsername(loginUsername); + entity.setIpAddress(ipAddress); + entity.setAuthMethod(authMethod); + entity.setRememberMe(rememberMe); + entity.setBrokerSessionId(brokerSessionId); + entity.setBrokerUserId(brokerUserId); + + int currentTime = Time.currentTime(); + + entity.setStarted(currentTime); + entity.setLastSessionRefresh(currentTime); + } + + @Override + public UserSessionModel getUserSession(RealmModel realm, String id) { + return getUserSession(realm, id, false); + } + + protected UserSessionAdapter getUserSession(RealmModel realm, String id, boolean offline) { + UserSessionPersistentChangelogBasedTransaction tx = getTransaction(offline); + SessionEntityWrapper entityWrapper = tx.get(realm, id); + if (entityWrapper == null) { + return null; + } + + UserSessionEntity entity = entityWrapper.getEntity(); + if (entity.getRealmId().equals(realm.getId())) { + return wrap(realm, entity, offline); + } + + return null; + } + + private UserSessionEntity getUserSessionFromTx(RealmModel realm, boolean offline, UserSessionModel persistentUserSession) { + SessionEntityWrapper userSessionEntitySessionEntityWrapper = getTransaction(offline).get(realm, persistentUserSession.getId()); + if (userSessionEntitySessionEntityWrapper != null) { + return userSessionEntitySessionEntityWrapper.getEntity(); + } + return null; + } + + private UserSessionEntity getUserSessionEntity(RealmModel realm, String id, boolean offline) { + UserSessionPersistentChangelogBasedTransaction tx = getTransaction(offline); + SessionEntityWrapper entityWrapper = tx.get(realm, id); + if (entityWrapper == null) { + return null; + } + + UserSessionEntity entity = entityWrapper.getEntity(); + if (!entity.getRealmId().equals(realm.getId())) { + return null; + } + return entity; + } + + private Stream getUserSessionsFromPersistenceProviderStream(RealmModel realm, UserModel user, boolean offline) { + UserSessionPersisterProvider persister = session.getProvider(UserSessionPersisterProvider.class); + return persister.loadUserSessionsStream(realm, user, offline, 0, null) + .map(persistentUserSession -> (UserSessionModel) getUserSession(realm, persistentUserSession.getId(), offline)) + .filter(Objects::nonNull); + } + + + protected Stream getUserSessionsStream(RealmModel realm, UserSessionPredicate predicate, boolean offline) { + // fetch the offline user-sessions from the persistence provider + UserSessionPersisterProvider persister = session.getProvider(UserSessionPersisterProvider.class); + if (predicate.getUserId() != null) { + UserModel user; + if (LightweightUserAdapter.isLightweightUser(predicate.getUserId())) { + user = new UserModelDelegate(null) { + @Override + public String getId() { + return predicate.getUserId(); + } + }; + } else { + user = session.users().getUserById(realm, predicate.getUserId()); + } + if (user != null) { + return persister.loadUserSessionsStream(realm, user, offline, 0, null) + .filter(predicate.toModelPredicate()) + .map(s -> (UserSessionModel) getUserSession(realm, s.getId(), offline)) + .filter(Objects::nonNull); + } else { + return Stream.empty(); + } + } + + if (predicate.getBrokerUserId() != null) { + String[] idpAliasSessionId = predicate.getBrokerUserId().split("\\."); + + Map attributes = new HashMap<>(); + attributes.put(UserModel.IDP_ALIAS, idpAliasSessionId[0]); + attributes.put(UserModel.IDP_USER_ID, idpAliasSessionId[1]); + + UserProvider userProvider = session.getProvider(UserProvider.class); + UserModel userModel = userProvider.searchForUserStream(realm, attributes, 0, null).findFirst().orElse(null); + return userModel != null ? + persister.loadUserSessionsStream(realm, userModel, offline, 0, null) + .filter(predicate.toModelPredicate()) + .map(s -> (UserSessionModel) getUserSession(realm, s.getId(), offline)) + .filter(Objects::nonNull) : + Stream.empty(); + } + + if (predicate.getClient() != null) { + ClientModel client = session.clients().getClientById(realm, predicate.getClient()); + return persister.loadUserSessionsStream(realm, client, offline, 0, null) + .filter(predicate.toModelPredicate()) + .map(s -> (UserSessionModel) getUserSession(realm, s.getId(), offline)) + .filter(Objects::nonNull); + } + + if (predicate.getBrokerSessionId() != null && !offline) { + // we haven't yet migrated the old offline entries, so they don't have a brokerSessionId yet + return Stream.of(persister.loadUserSessionsStreamByBrokerSessionId(realm, predicate.getBrokerSessionId(), offline)) + .filter(predicate.toModelPredicate()) + .map(s -> (UserSessionModel) getUserSession(realm, s.getId(), offline)) + .filter(Objects::nonNull); + } + + throw new ModelException("For offline sessions, only lookup by userId, brokerUserId and client is supported"); + } + + @Override + public AuthenticatedClientSessionAdapter getClientSession(UserSessionModel userSession, ClientModel client, String clientSessionId, boolean offline) { + if (clientSessionId == null) { + return null; + } + + UUID clientSessionUUID = UUID.fromString(clientSessionId); + ClientSessionPersistentChangelogBasedTransaction clientTx = getClientSessionTransaction(offline); + + SessionEntityWrapper clientSessionEntity = clientTx.get(client.getRealm(), client, userSession, clientSessionUUID); + if (clientSessionEntity != null) { + return new AuthenticatedClientSessionAdapter(session, this, clientSessionEntity.getEntity(), client, userSession, clientTx, offline); + } + + return null; + } + + + @Override + public Stream getUserSessionsStream(final RealmModel realm, UserModel user) { + return getUserSessionsStream(realm, UserSessionPredicate.create(realm.getId()).user(user.getId()), false); + } + + @Override + public Stream getUserSessionByBrokerUserIdStream(RealmModel realm, String brokerUserId) { + return getUserSessionsStream(realm, UserSessionPredicate.create(realm.getId()).brokerUserId(brokerUserId), false); + } + + @Override + public UserSessionModel getUserSessionByBrokerSessionId(RealmModel realm, String brokerSessionId) { + // TODO: consider returning a list as it is not guaranteed to be unique, and might be active for different users + return this.getUserSessionsStream(realm, UserSessionPredicate.create(realm.getId()).brokerSessionId(brokerSessionId), false) + .findFirst().orElse(null); + } + + @Override + public Stream getUserSessionsStream(RealmModel realm, ClientModel client) { + return getUserSessionsStream(realm, client, -1, -1); + } + + @Override + public Stream getUserSessionsStream(RealmModel realm, ClientModel client, Integer firstResult, Integer maxResults) { + return getUserSessionsStream(realm, client, firstResult, maxResults, false); + } + + protected Stream getUserSessionsStream(final RealmModel realm, ClientModel client, Integer firstResult, Integer maxResults, final boolean offline) { + UserSessionPredicate predicate = UserSessionPredicate.create(realm.getId()).client(client.getId()); + + return paginatedStream(getUserSessionsStream(realm, predicate, offline) + .sorted(Comparator.comparing(UserSessionModel::getLastSessionRefresh)), firstResult, maxResults); + } + + @Override + public UserSessionModel getUserSessionWithPredicate(RealmModel realm, String id, boolean offline, Predicate predicate) { + UserSessionModel userSession = getUserSession(realm, id, offline); + if (userSession == null) { + return null; + } + + // We have userSession, which passes predicate. No need for remote lookup. + if (predicate.test(userSession)) { + log.debugf("getUserSessionWithPredicate(%s): found in local cache", id); + return userSession; + } + + if (Profile.isFeatureEnabled(Feature.PERSISTENT_USER_SESSIONS_NO_CACHE)) { + return null; + } + + // Try lookup userSession from remoteCache + Cache> cache = getCache(offline); + RemoteCache remoteCache = InfinispanUtil.getRemoteCache(cache); + + if (remoteCache != null) { + SessionEntityWrapper remoteSessionEntityWrapper = (SessionEntityWrapper) remoteCache.get(id); + if (remoteSessionEntityWrapper != null) { + UserSessionEntity remoteSessionEntity = remoteSessionEntityWrapper.getEntity(); + log.debugf("getUserSessionWithPredicate(%s): remote cache contains session entity %s", id, remoteSessionEntity); + + UserSessionModel remoteSessionAdapter = wrap(realm, remoteSessionEntity, offline); + if (predicate.test(remoteSessionAdapter)) { + + InfinispanChangelogBasedTransaction tx = getTransaction(offline); + + // Remote entity contains our predicate. Update local cache with the remote entity + SessionEntityWrapper sessionWrapper = remoteSessionEntity.mergeRemoteEntityWithLocalEntity(tx.get(id)); + + // Replace entity just in ispn cache. Skip remoteStore + cache.getAdvancedCache().withFlags(Flag.SKIP_CACHE_STORE, Flag.SKIP_CACHE_LOAD, Flag.IGNORE_RETURN_VALUES) + .replace(id, sessionWrapper); + + tx.reloadEntityInCurrentTransaction(realm, id, sessionWrapper); + + // Recursion. We should have it locally now + return getUserSessionWithPredicate(realm, id, offline, predicate); + } else { + log.debugf("getUserSessionWithPredicate(%s): found, but predicate doesn't pass", id); + + return null; + } + } else { + log.debugf("getUserSessionWithPredicate(%s): not found", id); + + // Session not available on remoteCache. Was already removed there. So removing locally too. + // TODO: Can be optimized to skip calling remoteCache.remove + removeUserSession(realm, userSession); + + return null; + } + } else { + + log.debugf("getUserSessionWithPredicate(%s): remote cache not available", id); + + return null; + } + } + + + @Override + public long getActiveUserSessions(RealmModel realm, ClientModel client) { + return getUserSessionsCount(realm, client, false); + } + + @Override + public Map getActiveClientSessionStats(RealmModel realm, boolean offline) { + UserSessionPersisterProvider persister = session.getProvider(UserSessionPersisterProvider.class); + return persister.getUserSessionsCountsByClients(realm, offline); + } + + protected long getUserSessionsCount(RealmModel realm, ClientModel client, boolean offline) { + // fetch the actual offline user session count from the database + UserSessionPersisterProvider persister = session.getProvider(UserSessionPersisterProvider.class); + return persister.getUserSessionsCount(realm, client, offline); + } + + @Override + public void removeUserSession(RealmModel realm, UserSessionModel session) { + UserSessionEntity entity = getUserSessionEntity(realm, session, false); + if (entity != null) { + removeUserSession(entity, false); + } + } + + @Override + public void removeUserSessions(RealmModel realm, UserModel user) { + removeUserSessions(realm, user, false); + } + + protected void removeUserSessions(RealmModel realm, UserModel user, boolean offline) { + UserSessionPredicate.create(realm.getId()).user(user.getId()); + getUserSessionsStream(realm, UserSessionPredicate.create(realm.getId()).user(user.getId()), offline) + .forEach(s -> removeUserSession(realm, s)); + } + + 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) { + // Don't send message to all DCs, just to all cluster nodes in current DC. The remoteCache will notify client listeners for removed userSessions. + clusterEventsSenderTx.addEvent( + RemoveUserSessionsEvent.createEvent(RemoveUserSessionsEvent.class, InfinispanUserSessionProviderFactory.REMOVE_USER_SESSIONS_EVENT, session, realm.getId(), true), + ClusterProvider.DCNotify.LOCAL_DC_ONLY); + + session.getProvider(UserSessionPersisterProvider.class).removeUserSessions(realm, false); + } + + protected void onRemoveUserSessionsEvent(String realmId) { + removeLocalUserSessions(realmId, false); + removeLocalUserSessions(realmId, true); + } + + // public for usage in the testsuite + public void removeLocalUserSessions(String realmId, boolean offline) { + FuturesHelper futures = new FuturesHelper(); + + Cache> cache = getCache(offline); + Cache> localCache = CacheDecorators.localCache(cache); + Cache> clientSessionCache = getClientSessionCache(offline); + Cache> localClientSessionCache = CacheDecorators.localCache(clientSessionCache); + + Cache> localCacheStoreIgnore = CacheDecorators.skipCacheLoadersIfRemoteStoreIsEnabled(localCache); + + final AtomicInteger userSessionsSize = new AtomicInteger(); + + localCacheStoreIgnore + .entrySet() + .stream() + .filter(SessionPredicate.create(realmId)) + .map(Mappers.userSessionEntity()) + .forEach(new Consumer() { + + @Override + public void accept(UserSessionEntity userSessionEntity) { + userSessionsSize.incrementAndGet(); + + // Remove session from remoteCache too. Use removeAsync for better perf + Future future = localCache.removeAsync(userSessionEntity.getId()); + futures.addTask(future); + userSessionEntity.getAuthenticatedClientSessions().forEach((clientUUID, clientSessionId) -> { + Future f = localClientSessionCache.removeAsync(clientSessionId); + futures.addTask(f); + }); + } + + }); + + + futures.waitForAllToFinish(); + + log.debugf("Removed %d sessions in realm %s. Offline: %b", (Object) userSessionsSize.get(), realmId, offline); + } + + @Override + public void onRealmRemoved(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. + clusterEventsSenderTx.addEvent( + RealmRemovedSessionEvent.createEvent(RealmRemovedSessionEvent.class, InfinispanUserSessionProviderFactory.REALM_REMOVED_SESSION_EVENT, session, realm.getId(), true), + ClusterProvider.DCNotify.LOCAL_DC_ONLY); + + UserSessionPersisterProvider sessionsPersister = session.getProvider(UserSessionPersisterProvider.class); + if (sessionsPersister != null) { + sessionsPersister.onRealmRemoved(realm); + } + } + + protected void onRealmRemovedEvent(String realmId) { + removeLocalUserSessions(realmId, true); + removeLocalUserSessions(realmId, false); + } + + @Override + public void onClientRemoved(RealmModel realm, ClientModel client) { +// clusterEventsSenderTx.addEvent( +// ClientRemovedSessionEvent.createEvent(ClientRemovedSessionEvent.class, InfinispanUserSessionProviderFactory.CLIENT_REMOVED_SESSION_EVENT, session, realm.getId(), true), +// ClusterProvider.DCNotify.LOCAL_DC_ONLY); + UserSessionPersisterProvider sessionsPersister = session.getProvider(UserSessionPersisterProvider.class); + if (sessionsPersister != null) { + sessionsPersister.onClientRemoved(realm, client); + } + } + + protected void onClientRemovedEvent(String realmId, String clientUuid) { + // Nothing for now. userSession.getAuthenticatedClientSessions() will check lazily if particular client exists and update userSession on-the-fly. + } + + + protected void onUserRemoved(RealmModel realm, UserModel user) { + removeUserSessions(realm, user, true); + removeUserSessions(realm, user, false); + + UserSessionPersisterProvider persisterProvider = session.getProvider(UserSessionPersisterProvider.class); + if (persisterProvider != null) { + persisterProvider.onUserRemoved(realm, user); + } + } + + @Override + public void close() { + } + + @Override + public int getStartupTime(RealmModel realm) { + // TODO: take realm.getNotBefore() into account? + return session.getProvider(ClusterProvider.class).getClusterStartupTime(); + } + + protected void removeUserSession(UserSessionEntity sessionEntity, boolean offline) { + InfinispanChangelogBasedTransaction userSessionUpdateTx = getTransaction(offline); + InfinispanChangelogBasedTransaction clientSessionUpdateTx = getClientSessionTransaction(offline); + sessionEntity.getAuthenticatedClientSessions().forEach((clientUUID, clientSessionId) -> clientSessionUpdateTx.addTask(clientSessionId, Tasks.removeSync())); + SessionUpdateTask removeTask = Tasks.removeSync(); + userSessionUpdateTx.addTask(sessionEntity.getId(), removeTask); + } + + UserSessionAdapter wrap(RealmModel realm, UserSessionEntity entity, boolean offline, UserModel user) { + InfinispanChangelogBasedTransaction userSessionUpdateTx = getTransaction(offline); + InfinispanChangelogBasedTransaction clientSessionUpdateTx = getClientSessionTransaction(offline); + + if (entity == null) { + return null; + } + + return new UserSessionAdapter(session, user, this, userSessionUpdateTx, clientSessionUpdateTx, realm, entity, offline); + } + + UserSessionAdapter wrap(RealmModel realm, UserSessionEntity entity, boolean offline) { + UserModel user = null; + if (Profile.isFeatureEnabled(Feature.TRANSIENT_USERS) && entity.getNotes().containsKey(SESSION_NOTE_LIGHTWEIGHT_USER)) { + LightweightUserAdapter lua = LightweightUserAdapter.fromString(session, realm, entity.getNotes().get(SESSION_NOTE_LIGHTWEIGHT_USER)); + final UserSessionAdapter us = wrap(realm, entity, offline, lua); + lua.setUpdateHandler(lua1 -> { + if (lua == lua1) { // Ensure there is no conflicting user model, only the latest lightweight user can be used + us.setNote(SESSION_NOTE_LIGHTWEIGHT_USER, lua1.serialize()); + } + }); + return us; + } + + user = session.users().getUserById(realm, entity.getUser()); + + if (user == null) { + return null; + } + + return wrap(realm, entity, offline, user); + } + + UserSessionEntity getUserSessionEntity(RealmModel realm, UserSessionModel userSession, boolean offline) { + if (userSession instanceof UserSessionAdapter) { + if (!userSession.getRealm().equals(realm)) { + return null; + } + return ((UserSessionAdapter) userSession).getEntity(); + } else { + return getUserSessionEntity(realm, userSession.getId(), offline); + } + } + + + @Override + public UserSessionModel createOfflineUserSession(UserSessionModel userSession) { + UserSessionEntity entity = createUserSessionEntityInstance(userSession); + + InfinispanChangelogBasedTransaction userSessionUpdateTx = getTransaction(true); + + SessionUpdateTask importTask = Tasks.addIfAbsentSync(); + userSessionUpdateTx.addTask(userSession.getId(), importTask, entity, UserSessionModel.SessionPersistenceState.PERSISTENT); + + UserSessionAdapter offlineUserSession = wrap(userSession.getRealm(), entity, true); + + // started and lastSessionRefresh set to current time + int currentTime = Time.currentTime(); + offlineUserSession.getEntity().setStarted(currentTime); + offlineUserSession.getEntity().setLastSessionRefresh(currentTime); + + return offlineUserSession; + } + + @Override + public UserSessionAdapter getOfflineUserSession(RealmModel realm, String userSessionId) { + return getUserSession(realm, userSessionId, true); + } + + @Override + public Stream getOfflineUserSessionByBrokerUserIdStream(RealmModel realm, String brokerUserId) { + return getUserSessionsStream(realm, UserSessionPredicate.create(realm.getId()).brokerUserId(brokerUserId), true); + } + + @Override + public void removeOfflineUserSession(RealmModel realm, UserSessionModel userSession) { + UserSessionEntity userSessionEntity = getUserSessionEntity(realm, userSession, true); + if (userSessionEntity != null) { + removeUserSession(userSessionEntity, true); + } + } + + @Override + public AuthenticatedClientSessionModel createOfflineClientSession(AuthenticatedClientSessionModel clientSession, UserSessionModel offlineUserSession) { + UserSessionAdapter userSessionAdapter = (offlineUserSession instanceof UserSessionAdapter) ? (UserSessionAdapter) offlineUserSession : + getOfflineUserSession(offlineUserSession.getRealm(), offlineUserSession.getId()); + + InfinispanChangelogBasedTransaction userSessionUpdateTx = getTransaction(true); + InfinispanChangelogBasedTransaction clientSessionUpdateTx = getClientSessionTransaction(true); + AuthenticatedClientSessionAdapter offlineClientSession = importClientSession(userSessionAdapter, clientSession, userSessionUpdateTx, clientSessionUpdateTx, true, false); + + // update timestamp to current time + offlineClientSession.setTimestamp(Time.currentTime()); + offlineClientSession.getNotes().put(AuthenticatedClientSessionModel.STARTED_AT_NOTE, String.valueOf(offlineClientSession.getTimestamp())); + offlineClientSession.getNotes().put(AuthenticatedClientSessionModel.USER_SESSION_STARTED_AT_NOTE, String.valueOf(offlineUserSession.getStarted())); + + return offlineClientSession; + } + + @Override + public Stream getOfflineUserSessionsStream(RealmModel realm, UserModel user) { + return getUserSessionsFromPersistenceProviderStream(realm, user, true); + } + + @Override + public long getOfflineSessionsCount(RealmModel realm, ClientModel client) { + return getUserSessionsCount(realm, client, true); + } + + @Override + public Stream getOfflineUserSessionsStream(RealmModel realm, ClientModel client, Integer first, Integer max) { + return getUserSessionsStream(realm, client, first, max, true); + } + + + @Override + public void importUserSessions(Collection persistentUserSessions, boolean offline) { + if (persistentUserSessions == null || persistentUserSessions.isEmpty()) { + return; + } + + Map> clientSessionsById = new HashMap<>(); + + Map> sessionsById = persistentUserSessions.stream() + .map((UserSessionModel persistentUserSession) -> { + + UserSessionEntity userSessionEntityToImport = createUserSessionEntityInstance(persistentUserSession); + + for (Map.Entry entry : persistentUserSession.getAuthenticatedClientSessions().entrySet()) { + String clientUUID = entry.getKey(); + AuthenticatedClientSessionModel clientSession = entry.getValue(); + AuthenticatedClientSessionEntity clientSessionToImport = createAuthenticatedClientSessionInstance(userSessionEntityToImport.getId(), clientSession, + userSessionEntityToImport.getRealmId(), clientUUID, offline); + clientSessionToImport.setUserSessionId(userSessionEntityToImport.getId()); + + // Update timestamp to same value as userSession. LastSessionRefresh of userSession from DB will have correct value + clientSessionToImport.setTimestamp(userSessionEntityToImport.getLastSessionRefresh()); + + clientSessionsById.put(clientSessionToImport.getId(), new SessionEntityWrapper<>(clientSessionToImport)); + + // Update userSession entity with the clientSession + AuthenticatedClientSessionStore clientSessions = userSessionEntityToImport.getAuthenticatedClientSessions(); + clientSessions.put(clientUUID, clientSessionToImport.getId()); + } + + return userSessionEntityToImport; + }) + .map(SessionEntityWrapper::new) + .collect(Collectors.toMap(sessionEntityWrapper -> sessionEntityWrapper.getEntity().getId(), Function.identity())); + + // Directly put all entities to the infinispan cache + Cache> cache = CacheDecorators.skipCacheLoadersIfRemoteStoreIsEnabled(getCache(offline)); + + boolean importWithExpiration = sessionsById.size() == 1; + if (importWithExpiration) { + importSessionsWithExpiration(sessionsById, cache, + offline ? offlineSessionCacheEntryLifespanAdjuster : SessionTimeouts::getUserSessionLifespanMs, + offline ? SessionTimeouts::getOfflineSessionMaxIdleMs : SessionTimeouts::getUserSessionMaxIdleMs); + } else { + Retry.executeWithBackoff((int iteration) -> { + cache.putAll(sessionsById); + }, 10, 10); + } + + // put all entities to the remoteCache (if exists) + RemoteCache remoteCache = InfinispanUtil.getRemoteCache(cache); + if (remoteCache != null) { + Map> sessionsByIdForTransport = sessionsById.values().stream() + .map(SessionEntityWrapper::forTransport) + .collect(Collectors.toMap(sessionEntityWrapper -> sessionEntityWrapper.getEntity().getId(), Function.identity())); + + if (importWithExpiration) { + importSessionsWithExpiration(sessionsByIdForTransport, remoteCache, + offline ? offlineSessionCacheEntryLifespanAdjuster : SessionTimeouts::getUserSessionLifespanMs, + offline ? SessionTimeouts::getOfflineSessionMaxIdleMs : SessionTimeouts::getUserSessionMaxIdleMs); + } else { + Retry.executeWithBackoff((int iteration) -> { + + try { + remoteCache.putAll(sessionsByIdForTransport); + } catch (HotRodClientException re) { + if (log.isDebugEnabled()) { + log.debugf(re, "Failed to put import %d sessions to remoteCache. Iteration '%s'. Will try to retry the task", + sessionsByIdForTransport.size(), iteration); + } + + // Rethrow the exception. Retry will take care of handle the exception and eventually retry the operation. + throw re; + } + + }, 10, 10); + } + } + + // Import client sessions + Cache> clientSessCache = + CacheDecorators.skipCacheLoadersIfRemoteStoreIsEnabled(offline ? offlineClientSessionCache : clientSessionCache); + + if (importWithExpiration) { + importSessionsWithExpiration(clientSessionsById, clientSessCache, + offline ? offlineClientSessionCacheEntryLifespanAdjuster : SessionTimeouts::getClientSessionLifespanMs, + offline ? SessionTimeouts::getOfflineClientSessionMaxIdleMs : SessionTimeouts::getClientSessionMaxIdleMs); + } else { + Retry.executeWithBackoff((int iteration) -> { + clientSessCache.putAll(clientSessionsById); + }, 10, 10); + } + + // put all entities to the remoteCache (if exists) + RemoteCache remoteCacheClientSessions = InfinispanUtil.getRemoteCache(clientSessCache); + if (remoteCacheClientSessions != null) { + Map> sessionsByIdForTransport = clientSessionsById.values().stream() + .map(SessionEntityWrapper::forTransport) + .collect(Collectors.toMap(sessionEntityWrapper -> sessionEntityWrapper.getEntity().getId(), Function.identity())); + + if (importWithExpiration) { + importSessionsWithExpiration(sessionsByIdForTransport, remoteCacheClientSessions, + offline ? offlineClientSessionCacheEntryLifespanAdjuster : SessionTimeouts::getClientSessionLifespanMs, + offline ? SessionTimeouts::getOfflineClientSessionMaxIdleMs : SessionTimeouts::getClientSessionMaxIdleMs); + } else { + Retry.executeWithBackoff((int iteration) -> { + + try { + remoteCacheClientSessions.putAll(sessionsByIdForTransport); + } catch (HotRodClientException re) { + if (log.isDebugEnabled()) { + log.debugf(re, "Failed to put import %d client sessions to remoteCache. Iteration '%s'. Will try to retry the task", + sessionsByIdForTransport.size(), iteration); + } + + // Rethrow the exception. Retry will take care of handle the exception and eventually retry the operation. + throw re; + } + + }, 10, 10); + } + } + } + + private void importSessionsWithExpiration(Map> sessionsById, + BasicCache cache, SessionFunction lifespanMsCalculator, + SessionFunction maxIdleTimeMsCalculator) { + sessionsById.forEach((id, sessionEntityWrapper) -> { + + T sessionEntity = sessionEntityWrapper.getEntity(); + RealmModel currentRealm = session.realms().getRealm(sessionEntity.getRealmId()); + ClientModel client = sessionEntityWrapper.getClientIfNeeded(currentRealm); + long lifespan = lifespanMsCalculator.apply(currentRealm, client, sessionEntity); + long maxIdle = maxIdleTimeMsCalculator.apply(currentRealm, client, sessionEntity); + + if (lifespan != SessionTimeouts.ENTRY_EXPIRED_FLAG + && maxIdle != SessionTimeouts.ENTRY_EXPIRED_FLAG) { + if (cache instanceof RemoteCache) { + Retry.executeWithBackoff((int iteration) -> { + + try { + cache.put(id, sessionEntityWrapper, lifespan, TimeUnit.MILLISECONDS, maxIdle, TimeUnit.MILLISECONDS); + } catch (HotRodClientException re) { + if (log.isDebugEnabled()) { + log.debugf(re, "Failed to put import %d sessions to remoteCache. Iteration '%s'. Will try to retry the task", + sessionsById.size(), iteration); + } + + // Rethrow the exception. Retry will take care of handle the exception and eventually retry the operation. + throw re; + } + + }, 10, 10); + } else { + cache.put(id, sessionEntityWrapper, lifespan, TimeUnit.MILLISECONDS, maxIdle, TimeUnit.MILLISECONDS); + } + } + }); + } + + private UserSessionEntity createUserSessionEntityInstance(UserSessionModel userSession) { + UserSessionEntity entity = new UserSessionEntity(); + entity.setId(userSession.getId()); + entity.setRealmId(userSession.getRealm().getId()); + + entity.setAuthMethod(userSession.getAuthMethod()); + entity.setBrokerSessionId(userSession.getBrokerSessionId()); + entity.setBrokerUserId(userSession.getBrokerUserId()); + entity.setIpAddress(userSession.getIpAddress()); + entity.setNotes(userSession.getNotes() == null ? new ConcurrentHashMap<>() : userSession.getNotes()); + entity.setAuthenticatedClientSessions(new AuthenticatedClientSessionStore()); + entity.setRememberMe(userSession.isRememberMe()); + entity.setState(userSession.getState()); + if (userSession instanceof OfflineUserSessionModel) { + // this is a hack so that UserModel doesn't have to be available when offline token is imported. + // see related JIRA - KEYCLOAK-5350 and corresponding test + OfflineUserSessionModel oline = (OfflineUserSessionModel) userSession; + entity.setUser(oline.getUserId()); + // NOTE: Hack + // We skip calling entity.setLoginUsername(userSession.getLoginUsername()) + + } else { + entity.setLoginUsername(userSession.getLoginUsername()); + entity.setUser(userSession.getUser().getId()); + } + + entity.setStarted(userSession.getStarted()); + entity.setLastSessionRefresh(userSession.getLastSessionRefresh()); + + return entity; + } + + + private AuthenticatedClientSessionAdapter importClientSession(UserSessionAdapter sessionToImportInto, AuthenticatedClientSessionModel clientSession, + InfinispanChangelogBasedTransaction userSessionUpdateTx, + InfinispanChangelogBasedTransaction clientSessionUpdateTx, + boolean offline, boolean checkExpiration) { + AuthenticatedClientSessionEntity entity = createAuthenticatedClientSessionInstance(sessionToImportInto.getId(), clientSession, + sessionToImportInto.getRealm().getId(), clientSession.getClient().getId(), offline); + entity.setUserSessionId(sessionToImportInto.getId()); + + // Update timestamp to same value as userSession. LastSessionRefresh of userSession from DB will have correct value + entity.setTimestamp(sessionToImportInto.getLastSessionRefresh()); + + if (checkExpiration) { + SessionFunction lifespanChecker = offline + ? offlineClientSessionCacheEntryLifespanAdjuster : SessionTimeouts::getClientSessionLifespanMs; + SessionFunction idleTimeoutChecker = offline + ? SessionTimeouts::getOfflineClientSessionMaxIdleMs : SessionTimeouts::getClientSessionMaxIdleMs; + if (idleTimeoutChecker.apply(sessionToImportInto.getRealm(), clientSession.getClient(), entity) == SessionTimeouts.ENTRY_EXPIRED_FLAG + || lifespanChecker.apply(sessionToImportInto.getRealm(), clientSession.getClient(), entity) == SessionTimeouts.ENTRY_EXPIRED_FLAG) { + return null; + } + } + + final UUID clientSessionId = entity.getId(); + + SessionUpdateTask createClientSessionTask = Tasks.addIfAbsentSync(); + clientSessionUpdateTx.addTask(entity.getId(), createClientSessionTask, entity, UserSessionModel.SessionPersistenceState.PERSISTENT); + + AuthenticatedClientSessionStore clientSessions = sessionToImportInto.getEntity().getAuthenticatedClientSessions(); + clientSessions.put(clientSession.getClient().getId(), clientSessionId); + + SessionUpdateTask registerClientSessionTask = new RegisterClientSessionTask(clientSession.getClient().getId(), clientSessionId); + userSessionUpdateTx.addTask(sessionToImportInto.getId(), registerClientSessionTask); + + return new AuthenticatedClientSessionAdapter(session, this, entity, clientSession.getClient(), sessionToImportInto, clientSessionUpdateTx, offline); + } + + + private AuthenticatedClientSessionEntity createAuthenticatedClientSessionInstance(String userSessionId, AuthenticatedClientSessionModel clientSession, + String realmId, String clientId, boolean offline) { + final UUID clientSessionId; + if (offline) { + clientSessionId = keyGenerator.generateKeyUUID(session, clientSessionCache); + } else { + clientSessionId = PersistentUserSessionProvider.createClientSessionUUID(userSessionId, clientId); + } + AuthenticatedClientSessionEntity entity = new AuthenticatedClientSessionEntity(clientSessionId); + entity.setRealmId(realmId); + + entity.setAction(clientSession.getAction()); + entity.setAuthMethod(clientSession.getProtocol()); + + entity.setNotes(clientSession.getNotes() == null ? new ConcurrentHashMap<>() : clientSession.getNotes()); + entity.setClientId(clientId); + entity.setRedirectUri(clientSession.getRedirectUri()); + entity.setTimestamp(clientSession.getTimestamp()); + entity.setCurrentRefreshToken(clientSession.getCurrentRefreshToken()); + entity.setCurrentRefreshTokenUseCount(clientSession.getCurrentRefreshTokenUseCount()); + + return entity; + } + + public SessionEntityWrapper wrapPersistentEntity(RealmModel realm, boolean offline, UserSessionModel persistentUserSession) { + UserSessionEntity userSessionEntity = createUserSessionEntityInstance(persistentUserSession); + + if (isUserSessionExpired(realm, userSessionEntity, offline)) { + return null; + } + + InfinispanChangelogBasedTransaction userSessionUpdateTx = getTransaction(offline); + userSessionUpdateTx.addTask(userSessionEntity.getId(), null, userSessionEntity, UserSessionModel.SessionPersistenceState.PERSISTENT); + + InfinispanChangelogBasedTransaction clientSessionUpdateTx = getClientSessionTransaction(offline); + + for (Map.Entry entry : persistentUserSession.getAuthenticatedClientSessions().entrySet()) { + String clientUUID = entry.getKey(); + AuthenticatedClientSessionEntity clientSession = createAuthenticatedClientSessionInstance(persistentUserSession.getId(), entry.getValue(), + userSessionEntity.getRealmId(), clientUUID, offline); + clientSession.setUserSessionId(userSessionEntity.getId()); + + // Update timestamp to same value as userSession. LastSessionRefresh of userSession from DB will have correct value + // clientSession.setTimestamp(userSessionEntity.getLastSessionRefresh()); + + ClientModel client = session.clients().getClientById(realm, clientSession.getClientId()); + if (isClientSessionExpired(realm, client, clientSession, offline)) { + continue; + } + + // Update userSession entity with the clientSession + AuthenticatedClientSessionStore clientSessions = userSessionEntity.getAuthenticatedClientSessions(); + clientSessions.put(clientUUID, clientSession.getId()); + clientSessionUpdateTx.addTask(clientSession.getId(), null, clientSession, UserSessionModel.SessionPersistenceState.PERSISTENT); + } + + return sessionTx.get(realm, userSessionEntity.getId()); + + } + + private boolean isClientSessionExpired(RealmModel realm, ClientModel client, AuthenticatedClientSessionEntity entity, boolean offline) { + SessionFunction idleChecker = offline ? SessionTimeouts::getOfflineClientSessionMaxIdleMs : SessionTimeouts::getClientSessionMaxIdleMs; + SessionFunction lifetimeChecker = offline ? SessionTimeouts::getOfflineClientSessionLifespanMs : SessionTimeouts::getClientSessionLifespanMs; + return idleChecker.apply(realm, client, entity) == SessionTimeouts.ENTRY_EXPIRED_FLAG || lifetimeChecker.apply(realm, client, entity) == SessionTimeouts.ENTRY_EXPIRED_FLAG; + } + + private boolean isUserSessionExpired(RealmModel realm, UserSessionEntity entity, boolean offline) { + SessionFunction idleChecker = offline ? SessionTimeouts::getOfflineSessionMaxIdleMs : SessionTimeouts::getUserSessionMaxIdleMs; + SessionFunction lifetimeChecker = offline ? SessionTimeouts::getOfflineSessionLifespanMs : SessionTimeouts::getUserSessionLifespanMs; + return idleChecker.apply(realm, null, entity) == SessionTimeouts.ENTRY_EXPIRED_FLAG || lifetimeChecker.apply(realm, null, entity) == SessionTimeouts.ENTRY_EXPIRED_FLAG; + } + + private static class RegisterClientSessionTask implements SessionUpdateTask { + + private final String clientUuid; + private final UUID clientSessionId; + + public RegisterClientSessionTask(String clientUuid, UUID clientSessionId) { + this.clientUuid = clientUuid; + this.clientSessionId = clientSessionId; + } + + @Override + public void runUpdate(UserSessionEntity session) { + AuthenticatedClientSessionStore clientSessions = session.getAuthenticatedClientSessions(); + clientSessions.put(clientUuid, clientSessionId); + } + + @Override + public CacheOperation getOperation(UserSessionEntity session) { + return CacheOperation.REPLACE; + } + + @Override + public CrossDCMessageStatus getCrossDCMessageStatus(SessionEntityWrapper sessionWrapper) { + return CrossDCMessageStatus.SYNC; + } + } + + public static UUID createClientSessionUUID(String userSessionId, String clientId) { + return UUID.nameUUIDFromBytes((userSessionId + clientId).getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/SessionRefreshStore.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/SessionRefreshStore.java new file mode 100644 index 0000000000..0b15f6eaea --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/SessionRefreshStore.java @@ -0,0 +1,29 @@ +/* + * Copyright 2024 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; + +import org.keycloak.models.sessions.infinispan.changes.sessions.CrossDCLastSessionRefreshStore; +import org.keycloak.models.sessions.infinispan.changes.sessions.PersisterLastSessionRefreshStore; + +public interface SessionRefreshStore { + CrossDCLastSessionRefreshStore getLastSessionRefreshStore(); + + CrossDCLastSessionRefreshStore getOfflineLastSessionRefreshStore(); + + PersisterLastSessionRefreshStore getPersisterLastSessionRefreshStore(); +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/UserSessionAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/UserSessionAdapter.java index e06408b91f..146873952d 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/UserSessionAdapter.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/UserSessionAdapter.java @@ -23,6 +23,7 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; +import org.keycloak.models.UserSessionProvider; import org.keycloak.models.sessions.infinispan.changes.InfinispanChangelogBasedTransaction; import org.keycloak.models.sessions.infinispan.changes.sessions.CrossDCLastSessionRefreshChecker; import org.keycloak.models.sessions.infinispan.changes.SessionEntityWrapper; @@ -43,16 +44,14 @@ import java.util.Objects; import java.util.UUID; import java.util.stream.Collectors; -import static org.keycloak.models.sessions.infinispan.changes.sessions.CrossDCLastSessionRefreshListener.IGNORE_REMOTE_CACHE_UPDATE; - /** * @author Stian Thorgersen */ -public class UserSessionAdapter implements UserSessionModel { +public class UserSessionAdapter implements UserSessionModel { private final KeycloakSession session; - private final InfinispanUserSessionProvider provider; + private final T provider; private final InfinispanChangelogBasedTransaction userSessionUpdateTx; @@ -68,7 +67,7 @@ public class UserSessionAdapter implements UserSessionModel { private SessionPersistenceState persistenceState; - public UserSessionAdapter(KeycloakSession session, UserModel user, InfinispanUserSessionProvider provider, + public UserSessionAdapter(KeycloakSession session, UserModel user, T provider, InfinispanChangelogBasedTransaction userSessionUpdateTx, InfinispanChangelogBasedTransaction clientSessionUpdateTx, RealmModel realm, UserSessionEntity entity, boolean offline) { @@ -94,7 +93,7 @@ public class UserSessionAdapter implements UserSessionModel { // Check if client still exists ClientModel client = realm.getClientById(key); if (client != null) { - final AuthenticatedClientSessionAdapter clientSession = provider.getClientSession(this, client, value.toString(), offline); + final AuthenticatedClientSessionModel clientSession = provider.getClientSession(this, client, value.toString(), offline); if (clientSession != null) { result.put(key, clientSession); } @@ -330,7 +329,7 @@ public class UserSessionAdapter implements UserSessionModel { @Override public void runUpdate(UserSessionEntity entity) { - provider.updateSessionEntity(entity, realm, user, loginUsername, ipAddress, authMethod, rememberMe, brokerSessionId, brokerUserId); + InfinispanUserSessionProvider.updateSessionEntity(entity, realm, user, loginUsername, ipAddress, authMethod, rememberMe, brokerSessionId, brokerUserId); entity.setState(null); entity.getNotes().clear(); @@ -360,7 +359,8 @@ public class UserSessionAdapter implements UserSessionModel { return getId().hashCode(); } - UserSessionEntity getEntity() { + // TODO: This should not be public + public UserSessionEntity getEntity() { return entity; } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/ClientSessionPersistentChangelogBasedTransaction.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/ClientSessionPersistentChangelogBasedTransaction.java new file mode 100644 index 0000000000..943f3d1261 --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/ClientSessionPersistentChangelogBasedTransaction.java @@ -0,0 +1,191 @@ +/* + * Copyright 2024 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.changes; + +import org.infinispan.Cache; +import org.jboss.logging.Logger; +import org.keycloak.models.AuthenticatedClientSessionModel; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.models.session.UserSessionPersisterProvider; +import org.keycloak.models.sessions.infinispan.PersistentUserSessionProvider; +import org.keycloak.models.sessions.infinispan.SessionFunction; +import org.keycloak.models.sessions.infinispan.UserSessionAdapter; +import org.keycloak.models.sessions.infinispan.entities.AuthenticatedClientSessionEntity; +import org.keycloak.models.sessions.infinispan.entities.AuthenticatedClientSessionStore; +import org.keycloak.models.sessions.infinispan.entities.UserSessionEntity; +import org.keycloak.models.sessions.infinispan.remotestore.RemoteCacheInvoker; +import org.keycloak.models.sessions.infinispan.util.InfinispanKeyGenerator; +import org.keycloak.models.sessions.infinispan.util.SessionTimeouts; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public class ClientSessionPersistentChangelogBasedTransaction extends PersistentSessionsChangelogBasedTransaction { + + private static final Logger LOG = Logger.getLogger(ClientSessionPersistentChangelogBasedTransaction.class); + private final InfinispanKeyGenerator keyGenerator; + private final UserSessionPersistentChangelogBasedTransaction userSessionTx; + + public ClientSessionPersistentChangelogBasedTransaction(KeycloakSession session, Cache> cache, RemoteCacheInvoker remoteCacheInvoker, SessionFunction lifespanMsLoader, SessionFunction maxIdleTimeMsLoader, boolean offline, InfinispanKeyGenerator keyGenerator, UserSessionPersistentChangelogBasedTransaction userSessionTx) { + super(session, cache, remoteCacheInvoker, lifespanMsLoader, maxIdleTimeMsLoader, offline); + this.keyGenerator = keyGenerator; + this.userSessionTx = userSessionTx; + } + + public SessionEntityWrapper get(RealmModel realm, ClientModel client, UserSessionModel userSession, UUID key) { + SessionUpdatesList myUpdates = updates.get(key); + if (myUpdates == null) { + SessionEntityWrapper wrappedEntity = cache.get(key); + if (wrappedEntity == null) { + wrappedEntity = getSessionEntityFromPersister(realm, client, userSession); + } + + if (wrappedEntity == null) { + return null; + } + + RealmModel realmFromSession = kcSession.realms().getRealm(wrappedEntity.getEntity().getRealmId()); + if (!realmFromSession.getId().equals(realm.getId())) { + LOG.warnf("Realm mismatch for session %s. Expected realm %s, but found realm %s", wrappedEntity.getEntity(), realm.getId(), realmFromSession.getId()); + return null; + } + + myUpdates = new SessionUpdatesList<>(realm, wrappedEntity); + updates.put(key, myUpdates); + + return wrappedEntity; + } else { + AuthenticatedClientSessionEntity entity = myUpdates.getEntityWrapper().getEntity(); + + // If entity is scheduled for remove, we don't return it. + boolean scheduledForRemove = myUpdates.getUpdateTasks().stream().filter((SessionUpdateTask task) -> { + + return task.getOperation(entity) == SessionUpdateTask.CacheOperation.REMOVE; + + }).findFirst().isPresent(); + + return scheduledForRemove ? null : myUpdates.getEntityWrapper(); + } + } + + private SessionEntityWrapper getSessionEntityFromPersister(RealmModel realm, ClientModel client, UserSessionModel userSession) { + UserSessionPersisterProvider persister = kcSession.getProvider(UserSessionPersisterProvider.class); + AuthenticatedClientSessionModel clientSession = persister.loadClientSession(realm, client, userSession, offline); + + if (clientSession == null) { + return null; + } + + SessionEntityWrapper authenticatedClientSessionEntitySessionEntityWrapper = importClientSession(realm, client, userSession, clientSession); + if (authenticatedClientSessionEntitySessionEntityWrapper == null) { + persister.removeClientSession(userSession.getId(), client.getId(), offline); + } + + return authenticatedClientSessionEntitySessionEntityWrapper; + } + + private AuthenticatedClientSessionEntity createAuthenticatedClientSessionInstance(String userSessionId, AuthenticatedClientSessionModel clientSession, + String realmId, String clientId) { + UUID clientSessionId = null; + if (clientSession.getId() != null) { + clientSessionId = UUID.fromString(clientSession.getId()); + } else { + if (offline) { + clientSessionId = keyGenerator.generateKeyUUID(kcSession, cache); + } else { + clientSessionId = PersistentUserSessionProvider.createClientSessionUUID(userSessionId, clientId); + } + } + + AuthenticatedClientSessionEntity entity = new AuthenticatedClientSessionEntity(clientSessionId); + entity.setRealmId(realmId); + + entity.setAction(clientSession.getAction()); + entity.setAuthMethod(clientSession.getProtocol()); + + entity.setNotes(clientSession.getNotes() == null ? new ConcurrentHashMap<>() : clientSession.getNotes()); + entity.setClientId(clientId); + entity.setRedirectUri(clientSession.getRedirectUri()); + entity.setTimestamp(clientSession.getTimestamp()); + + return entity; + } + + private SessionEntityWrapper importClientSession(RealmModel realm, ClientModel client, UserSessionModel userSession, AuthenticatedClientSessionModel persistentClientSession) { + AuthenticatedClientSessionEntity entity = createAuthenticatedClientSessionInstance(userSession.getId(), persistentClientSession, + realm.getId(), client.getId()); + + entity.setUserSessionId(userSession.getId()); + + // Update timestamp to same value as userSession. LastSessionRefresh of userSession from DB will have correct value + entity.setTimestamp(userSession.getLastSessionRefresh()); + + if (maxIdleTimeMsLoader.apply(realm, client, entity) == SessionTimeouts.ENTRY_EXPIRED_FLAG + || lifespanMsLoader.apply(realm, client, entity) == SessionTimeouts.ENTRY_EXPIRED_FLAG) { + return null; + } + + final UUID clientSessionId = entity.getId(); + + SessionUpdateTask createClientSessionTask = Tasks.addIfAbsentSync(); + this.addTask(entity.getId(), createClientSessionTask, entity, UserSessionModel.SessionPersistenceState.PERSISTENT); + + if (! (userSession instanceof UserSessionAdapter)) { + throw new IllegalStateException("UserSessionModel must be instance of UserSessionAdapter"); + } + + UserSessionAdapter sessionToImportInto = (UserSessionAdapter) userSession; + AuthenticatedClientSessionStore clientSessions = sessionToImportInto.getEntity().getAuthenticatedClientSessions(); + clientSessions.put(client.getId(), clientSessionId); + + SessionUpdateTask registerClientSessionTask = new RegisterClientSessionTask(client.getId(), clientSessionId); + userSessionTx.addTask(sessionToImportInto.getId(), registerClientSessionTask); + + return new SessionEntityWrapper<>(entity); + } + + private static class RegisterClientSessionTask implements SessionUpdateTask { + + private final String clientUuid; + private final UUID clientSessionId; + + public RegisterClientSessionTask(String clientUuid, UUID clientSessionId) { + this.clientUuid = clientUuid; + this.clientSessionId = clientSessionId; + } + + @Override + public void runUpdate(UserSessionEntity session) { + AuthenticatedClientSessionStore clientSessions = session.getAuthenticatedClientSessions(); + clientSessions.put(clientUuid, clientSessionId); + } + + @Override + public CacheOperation getOperation(UserSessionEntity session) { + return CacheOperation.REPLACE; + } + + @Override + public CrossDCMessageStatus getCrossDCMessageStatus(SessionEntityWrapper sessionWrapper) { + return CrossDCMessageStatus.SYNC; + } + } +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/EmbeddedCachesChangesPerformer.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/EmbeddedCachesChangesPerformer.java new file mode 100644 index 0000000000..fbd53bc94f --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/EmbeddedCachesChangesPerformer.java @@ -0,0 +1,140 @@ +/* + * Copyright 2024 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.changes; + +import org.infinispan.Cache; +import org.infinispan.context.Flag; +import org.jboss.logging.Logger; +import org.keycloak.connections.infinispan.InfinispanUtil; +import org.keycloak.models.sessions.infinispan.CacheDecorators; +import org.keycloak.models.sessions.infinispan.entities.SessionEntity; + +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +public class EmbeddedCachesChangesPerformer implements SessionChangesPerformer { + + private static final Logger LOG = Logger.getLogger(EmbeddedCachesChangesPerformer.class); + private final Cache> cache; + private final List changes = new LinkedList<>(); + + public EmbeddedCachesChangesPerformer(Cache> cache) { + this.cache = cache; + } + + private void runOperationInCluster(K key, MergedUpdate task, SessionEntityWrapper sessionWrapper) { + V session = sessionWrapper.getEntity(); + SessionUpdateTask.CacheOperation operation = task.getOperation(session); + + // Don't need to run update of underlying entity. Local updates were already run + //task.runUpdate(session); + + switch (operation) { + case REMOVE: + // Just remove it + CacheDecorators.skipCacheStoreIfRemoteCacheIsEnabled(cache) + .withFlags(Flag.IGNORE_RETURN_VALUES) + .remove(key); + break; + case ADD: + CacheDecorators.skipCacheStoreIfRemoteCacheIsEnabled(cache) + .withFlags(Flag.IGNORE_RETURN_VALUES) + .put(key, sessionWrapper, task.getLifespanMs(), TimeUnit.MILLISECONDS, task.getMaxIdleTimeMs(), TimeUnit.MILLISECONDS); + + LOG.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.skipCacheStoreIfRemoteCacheIsEnabled(cache).putIfAbsent(key, sessionWrapper, task.getLifespanMs(), TimeUnit.MILLISECONDS, task.getMaxIdleTimeMs(), TimeUnit.MILLISECONDS); + if (existing != null) { + LOG.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, task.getLifespanMs(), task.getMaxIdleTimeMs()); + } else { + LOG.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, task.getLifespanMs(), task.getMaxIdleTimeMs()); + break; + default: + throw new IllegalStateException("Unsupported state " + operation); + } + + } + + private void replace(K key, MergedUpdate task, SessionEntityWrapper oldVersionEntity, long lifespanMs, long maxIdleTimeMs) { + boolean replaced = false; + int iteration = 0; + V session = oldVersionEntity.getEntity(); + + while (!replaced && iteration < InfinispanUtil.MAXIMUM_REPLACE_RETRIES) { + iteration++; + + SessionEntityWrapper newVersionEntity = generateNewVersionAndWrapEntity(session, oldVersionEntity.getLocalMetadata()); + + // Atomic cluster-aware replace + replaced = CacheDecorators.skipCacheStoreIfRemoteCacheIsEnabled(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) { + if (LOG.isDebugEnabled()) { + LOG.debugf("Replace failed for entity: %s, old version %s, new version %s. Will try again", key, oldVersionEntity.getVersion(), newVersionEntity.getVersion()); + } + + oldVersionEntity = cache.get(key); + + if (oldVersionEntity == null) { + LOG.debugf("Entity %s not found. Maybe removed in the meantime. Replace task will be ignored", key); + return; + } + + session = oldVersionEntity.getEntity(); + + task.runUpdate(session); + } else { + if (LOG.isTraceEnabled()) { + LOG.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()); + } + } + } + + if (!replaced) { + LOG.warnf("Failed to replace entity '%s' in cache '%s'", key, cache.getName()); + } + + } + + private SessionEntityWrapper generateNewVersionAndWrapEntity(V entity, Map localMetadata) { + return new SessionEntityWrapper<>(localMetadata, entity); + } + + @Override + public void registerChange(Map.Entry> entry, MergedUpdate merged) { + changes.add(() -> runOperationInCluster(entry.getKey(), merged, entry.getValue().getEntityWrapper())); + } + + @Override + public void applyChanges() { + changes.forEach(Runnable::run); + } +} 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 2df4073a2f..c7585abbd8 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 @@ -19,11 +19,13 @@ package org.keycloak.models.sessions.infinispan.changes; import java.util.HashMap; import java.util.Map; +import java.util.Objects; import java.util.concurrent.TimeUnit; import org.infinispan.Cache; import org.infinispan.context.Flag; import org.jboss.logging.Logger; +import org.keycloak.common.Profile; import org.keycloak.models.AbstractKeycloakTransaction; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; @@ -34,6 +36,9 @@ import org.keycloak.models.sessions.infinispan.entities.SessionEntity; import org.keycloak.models.sessions.infinispan.remotestore.RemoteCacheInvoker; import org.keycloak.connections.infinispan.InfinispanUtil; +import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME; +import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.USER_SESSION_CACHE_NAME; + /** * @author Marek Posolda */ @@ -41,15 +46,15 @@ public class InfinispanChangelogBasedTransaction ext public static final Logger logger = Logger.getLogger(InfinispanChangelogBasedTransaction.class); - private final KeycloakSession kcSession; + protected final KeycloakSession kcSession; private final String cacheName; - private final Cache> cache; + protected final Cache> cache; private final RemoteCacheInvoker remoteCacheInvoker; - private final Map> updates = new HashMap<>(); + protected final Map> updates = new HashMap<>(); - private final SessionFunction lifespanMsLoader; - private final SessionFunction maxIdleTimeMsLoader; + protected final SessionFunction lifespanMsLoader; + protected final SessionFunction maxIdleTimeMsLoader; public InfinispanChangelogBasedTransaction(KeycloakSession kcSession, Cache> cache, RemoteCacheInvoker remoteCacheInvoker, SessionFunction lifespanMsLoader, SessionFunction maxIdleTimeMsLoader) { @@ -65,6 +70,10 @@ public class InfinispanChangelogBasedTransaction ext public void addTask(K key, SessionUpdateTask task) { SessionUpdatesList myUpdates = updates.get(key); if (myUpdates == null) { + if (Profile.isFeatureEnabled(Profile.Feature.PERSISTENT_USER_SESSIONS_NO_CACHE) && (Objects.equals(cacheName, USER_SESSION_CACHE_NAME) || Objects.equals(cacheName, CLIENT_SESSION_CACHE_NAME))) { + throw new IllegalStateException("Can't load from cache"); + } + // Lookup entity from cache SessionEntityWrapper wrappedEntity = cache.get(key); if (wrappedEntity == null) { @@ -95,9 +104,11 @@ public class InfinispanChangelogBasedTransaction ext SessionUpdatesList myUpdates = new SessionUpdatesList<>(realm, wrappedEntity, persistenceState); updates.put(key, myUpdates); - // Run the update now, so reader in same transaction can see it - task.runUpdate(entity); - myUpdates.add(task); + if (task != null) { + // Run the update now, so reader in same transaction can see it + task.runUpdate(entity); + myUpdates.add(task); + } } @@ -150,7 +161,6 @@ public class InfinispanChangelogBasedTransaction ext } } - @Override protected void commitImpl() { for (Map.Entry> entry : updates.entrySet()) { diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/JpaChangesPerformer.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/JpaChangesPerformer.java new file mode 100644 index 0000000000..68cd4f377e --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/JpaChangesPerformer.java @@ -0,0 +1,707 @@ +/* + * Copyright 2024 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.changes; + +import org.infinispan.util.function.TriConsumer; +import org.keycloak.common.util.Retry; +import org.keycloak.models.AuthenticatedClientSessionModel; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.models.delegate.ClientModelLazyDelegate; +import org.keycloak.models.session.PersistentAuthenticatedClientSessionAdapter; +import org.keycloak.models.session.PersistentUserSessionAdapter; +import org.keycloak.models.session.UserSessionPersisterProvider; +import org.keycloak.models.sessions.infinispan.entities.AuthenticatedClientSessionEntity; +import org.keycloak.models.sessions.infinispan.entities.AuthenticatedClientSessionStore; +import org.keycloak.models.sessions.infinispan.entities.SessionEntity; +import org.keycloak.models.sessions.infinispan.entities.UserSessionEntity; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.models.utils.RealmModelDelegate; +import org.keycloak.models.utils.UserModelDelegate; +import org.keycloak.models.utils.UserSessionModelDelegate; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; + +public class JpaChangesPerformer implements SessionChangesPerformer { + + private final KeycloakSession session; + private final String cacheName; + private final boolean offline; + private final List> changes = new LinkedList<>(); + private final TriConsumer>, MergedUpdate> processor; + + public JpaChangesPerformer(KeycloakSession session, String cacheName, boolean offline) { + this.session = session; + this.cacheName = cacheName; + this.offline = offline; + processor = processor(); + } + + @Override + public void registerChange(Map.Entry> entry, MergedUpdate merged) { + changes.add(innerSession -> processor.accept(innerSession, entry, merged)); + } + + private TriConsumer>, MergedUpdate> processor() { + return switch (cacheName) { + case "sessions", "offlineSessions" -> this::processUserSessionUpdate; + case "clientSessions", "offlineClientSessions" -> this::processClientSessionUpdate; + default -> throw new IllegalStateException("Unexpected value: " + cacheName); + }; + } + + @Override + public void applyChanges() { + Retry.executeWithBackoff(iteration -> KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), + innerSession -> changes.forEach(c -> c.accept(innerSession))), + 10, 10); + } + + private void processClientSessionUpdate(KeycloakSession innerSession, Map.Entry> entry, MergedUpdate merged) { + SessionUpdatesList sessionUpdates = entry.getValue(); + SessionEntityWrapper sessionWrapper = sessionUpdates.getEntityWrapper(); + RealmModel realm = sessionUpdates.getRealm(); + UserSessionPersisterProvider userSessionPersister = innerSession.getProvider(UserSessionPersisterProvider.class); + + if (merged.getOperation(sessionWrapper.getEntity()) == SessionUpdateTask.CacheOperation.REMOVE) { + AuthenticatedClientSessionEntity entity = (AuthenticatedClientSessionEntity) sessionWrapper.getEntity(); + userSessionPersister.removeClientSession(entity.getUserSessionId(), entity.getClientId(), offline); + } else if (merged.getOperation(sessionWrapper.getEntity()) == SessionUpdateTask.CacheOperation.ADD || merged.getOperation(sessionWrapper.getEntity()) == SessionUpdateTask.CacheOperation.ADD_IF_ABSENT){ + AuthenticatedClientSessionEntity entity = (AuthenticatedClientSessionEntity) sessionWrapper.getEntity(); + userSessionPersister.createClientSession(new AuthenticatedClientSessionModel() { + @Override + public int getStarted() { + return entity.getStarted(); + } + + @Override + public int getUserSessionStarted() { + return entity.getUserSessionStarted(); + } + + @Override + public boolean isUserSessionRememberMe() { + return entity.isUserSessionRememberMe(); + } + + @Override + public String getId() { + return entity.getId().toString(); + } + + @Override + public int getTimestamp() { + return entity.getTimestamp(); + } + + @Override + public void setTimestamp(int timestamp) { + throw new IllegalStateException("not implemented"); + } + + @Override + public void detachFromUserSession() { + throw new IllegalStateException("not implemented"); + } + + @Override + public UserSessionModel getUserSession() { + return new UserSessionModelDelegate(null) { + @Override + public String getId() { + return entity.getUserSessionId(); + } + }; + } + + @Override + public String getCurrentRefreshToken() { + return entity.getCurrentRefreshToken(); + } + + @Override + public void setCurrentRefreshToken(String currentRefreshToken) { + throw new IllegalStateException("not implemented"); + } + + @Override + public int getCurrentRefreshTokenUseCount() { + return entity.getCurrentRefreshTokenUseCount(); + } + + @Override + public void setCurrentRefreshTokenUseCount(int currentRefreshTokenUseCount) { + throw new IllegalStateException("not implemented"); + } + + @Override + public String getNote(String name) { + return entity.getNotes().get(name); + } + + @Override + public void setNote(String name, String value) { + throw new IllegalStateException("not implemented"); + } + + @Override + public void removeNote(String name) { + throw new IllegalStateException("not implemented"); + } + + @Override + public Map getNotes() { + return entity.getNotes(); + } + + @Override + public String getRedirectUri() { + return entity.getRedirectUri(); + } + + @Override + public void setRedirectUri(String uri) { + throw new IllegalStateException("not implemented"); + } + + @Override + public RealmModel getRealm() { + return innerSession.realms().getRealm(entity.getRealmId()); + } + + @Override + public ClientModel getClient() { + return new ClientModelLazyDelegate(() -> null) { + @Override + public String getId() { + return entity.getClientId(); + } + }; + } + + @Override + public String getAction() { + return entity.getAction(); + } + + @Override + public void setAction(String action) { + throw new IllegalStateException("not implemented"); + } + + @Override + public String getProtocol() { + return entity.getAuthMethod(); + } + + @Override + public void setProtocol(String method) { + throw new IllegalStateException("not implemented"); + } + }, offline); + } else { + AuthenticatedClientSessionEntity entity = (AuthenticatedClientSessionEntity) sessionWrapper.getEntity(); + ClientModel client = new ClientModelLazyDelegate(null) { + @Override + public String getId() { + return entity.getClientId(); + } + }; + UserSessionModel userSession = new UserSessionModelDelegate(null) { + @Override + public String getId() { + return entity.getUserSessionId(); + } + }; + PersistentAuthenticatedClientSessionAdapter clientSessionModel = (PersistentAuthenticatedClientSessionAdapter) userSessionPersister.loadClientSession(realm, client, userSession, offline); + if (clientSessionModel != null) { + AuthenticatedClientSessionEntity authenticatedClientSessionEntity = new AuthenticatedClientSessionEntity(entity.getId()) { + @Override + public Map getNotes() { + return new HashMap<>() { + @Override + public String get(Object key) { + return clientSessionModel.getNotes().get(key); + } + + @Override + public String put(String key, String value) { + String oldValue = clientSessionModel.getNotes().get(key); + clientSessionModel.setNote(key, value); + return oldValue; + } + }; + } + + @Override + public void setRedirectUri(String redirectUri) { + clientSessionModel.setRedirectUri(redirectUri); + } + + @Override + public void setTimestamp(int timestamp) { + clientSessionModel.setTimestamp(timestamp); + } + + @Override + public void setCurrentRefreshToken(String currentRefreshToken) { + clientSessionModel.setCurrentRefreshToken(currentRefreshToken); + } + + @Override + public void setCurrentRefreshTokenUseCount(int currentRefreshTokenUseCount) { + clientSessionModel.setCurrentRefreshTokenUseCount(currentRefreshTokenUseCount); + } + + @Override + public void setAction(String action) { + clientSessionModel.setAction(action); + } + + @Override + public void setAuthMethod(String authMethod) { + clientSessionModel.setProtocol(authMethod); + } + + @Override + public String getAuthMethod() { + throw new IllegalStateException("not implemented"); + } + + @Override + public String getRedirectUri() { + return clientSessionModel.getRedirectUri(); + } + + @Override + public int getTimestamp() { + return clientSessionModel.getTimestamp(); + } + + @Override + public int getUserSessionStarted() { + return clientSessionModel.getUserSessionStarted(); + } + + @Override + public int getStarted() { + return clientSessionModel.getStarted(); + } + + @Override + public boolean isUserSessionRememberMe() { + return clientSessionModel.isUserSessionRememberMe(); + } + + @Override + public String getClientId() { + return clientSessionModel.getClient().getClientId(); + } + + @Override + public void setClientId(String clientId) { + throw new IllegalStateException("not implemented"); + } + + @Override + public String getAction() { + return clientSessionModel.getAction(); + } + + @Override + public void setNotes(Map notes) { + clientSessionModel.getNotes().keySet().forEach(clientSessionModel::removeNote); + notes.forEach((k, v) -> clientSessionModel.setNote(k, v)); + } + + @Override + public String getCurrentRefreshToken() { + return clientSessionModel.getCurrentRefreshToken(); + } + + @Override + public int getCurrentRefreshTokenUseCount() { + return clientSessionModel.getCurrentRefreshTokenUseCount(); + } + + @Override + public UUID getId() { + return UUID.fromString(clientSessionModel.getId()); + } + + @Override + public SessionEntityWrapper mergeRemoteEntityWithLocalEntity(SessionEntityWrapper localEntityWrapper) { + throw new IllegalStateException("not implemented"); + } + + @Override + public String getUserSessionId() { + return clientSessionModel.getUserSession().getId(); + } + + @Override + public void setUserSessionId(String userSessionId) { + throw new IllegalStateException("not implemented"); + } + }; + sessionUpdates.getUpdateTasks().forEach(vSessionUpdateTask -> { + vSessionUpdateTask.runUpdate((V) authenticatedClientSessionEntity); + if (vSessionUpdateTask.getOperation((V) authenticatedClientSessionEntity) == SessionUpdateTask.CacheOperation.REMOVE) { + userSessionPersister.removeClientSession(entity.getUserSessionId(), entity.getClientId(), offline); + } + }); + clientSessionModel.getUpdatedModel(); + } + } + + } + + private void processUserSessionUpdate(KeycloakSession innerSession, Map.Entry> entry, MergedUpdate merged) { + SessionUpdatesList sessionUpdates = entry.getValue(); + SessionEntityWrapper sessionWrapper = sessionUpdates.getEntityWrapper(); + RealmModel realm = sessionUpdates.getRealm(); + UserSessionPersisterProvider userSessionPersister = innerSession.getProvider(UserSessionPersisterProvider.class); + + if (merged.getOperation(sessionWrapper.getEntity()) == SessionUpdateTask.CacheOperation.REMOVE) { + userSessionPersister.removeUserSession(entry.getKey().toString(), offline); + } else if (merged.getOperation(sessionWrapper.getEntity()) == SessionUpdateTask.CacheOperation.ADD || merged.getOperation(sessionWrapper.getEntity()) == SessionUpdateTask.CacheOperation.ADD_IF_ABSENT){ + UserSessionEntity entity = (UserSessionEntity) sessionWrapper.getEntity(); + userSessionPersister.createUserSession(new UserSessionModel() { + @Override + public String getId() { + return entity.getId(); + } + + @Override + public RealmModel getRealm() { + return new RealmModelDelegate(null) { + @Override + public String getId() { + return entity.getRealmId(); + } + }; + } + + @Override + public String getBrokerSessionId() { + return entity.getBrokerSessionId(); + } + + @Override + public String getBrokerUserId() { + return entity.getBrokerUserId(); + } + + @Override + public UserModel getUser() { + return new UserModelDelegate(null) { + @Override + public String getId() { + return entity.getUser(); + } + }; + } + + @Override + public String getLoginUsername() { + return entity.getLoginUsername(); + } + + @Override + public String getIpAddress() { + return entity.getIpAddress(); + } + + @Override + public String getAuthMethod() { + return entity.getAuthMethod(); + } + + @Override + public boolean isRememberMe() { + return entity.isRememberMe(); + } + + @Override + public int getStarted() { + return entity.getStarted(); + } + + @Override + public int getLastSessionRefresh() { + return entity.getLastSessionRefresh(); + } + + @Override + public void setLastSessionRefresh(int seconds) { + throw new IllegalStateException("not implemented"); + } + + @Override + public boolean isOffline() { + return offline; + } + + @Override + public Map getAuthenticatedClientSessions() { + // This is not used when saving this to the database. + return Collections.emptyMap(); + } + + @Override + public void removeAuthenticatedClientSessions(Collection removedClientUUIDS) { + throw new IllegalStateException("not implemented"); + } + + @Override + public String getNote(String name) { + return entity.getNotes().get(name); + } + + @Override + public void setNote(String name, String value) { + throw new IllegalStateException("not implemented"); + } + + @Override + public void removeNote(String name) { + throw new IllegalStateException("not implemented"); + } + + @Override + public Map getNotes() { + return entity.getNotes(); + } + + @Override + public State getState() { + return entity.getState(); + } + + @Override + public void setState(State state) { + throw new IllegalStateException("not implemented"); + } + + @Override + public void restartSession(RealmModel realm, UserModel user, String loginUsername, String ipAddress, String authMethod, boolean rememberMe, String brokerSessionId, String brokerUserId) { + throw new IllegalStateException("not implemented"); + } + }, offline); + } else { + PersistentUserSessionAdapter userSessionModel = (PersistentUserSessionAdapter) userSessionPersister.loadUserSession(realm, entry.getKey().toString(), offline); + if (userSessionModel != null) { + UserSessionEntity userSessionEntity = new UserSessionEntity() { + @Override + public Map getNotes() { + return new HashMap<>() { + + @Override + public String get(Object key) { + return userSessionModel.getNotes().get(key); + } + + @Override + public String put(String key, String value) { + String oldValue = userSessionModel.getNotes().get(key); + userSessionModel.setNote(key, value); + return oldValue; + } + + @Override + public String remove(Object key) { + String oldValue = userSessionModel.getNotes().get(key); + userSessionModel.removeNote(key.toString()); + return oldValue; + } + + @Override + public void clear() { + userSessionModel.getNotes().forEach((k, v) -> userSessionModel.removeNote(k)); + } + }; + } + + @Override + public void setLastSessionRefresh(int lastSessionRefresh) { + userSessionModel.setLastSessionRefresh(lastSessionRefresh); + } + + @Override + public void setState(UserSessionModel.State state) { + userSessionModel.setState(state); + } + + @Override + public AuthenticatedClientSessionStore getAuthenticatedClientSessions() { + return new AuthenticatedClientSessionStore() { + @Override + public void clear() { + userSessionModel.getAuthenticatedClientSessions().clear(); + } + }; + } + + @Override + public String getRealmId() { + return userSessionModel.getRealm().getId(); + } + + @Override + public void setRealmId(String realmId) { + userSessionModel.setRealm(innerSession.realms().getRealm(realmId)); + } + + @Override + public String getId() { + return userSessionModel.getId(); + } + + @Override + public void setId(String id) { + throw new IllegalStateException("not supported"); + } + + @Override + public String getUser() { + return userSessionModel.getUser().getId(); + } + + @Override + public void setUser(String userId) { + userSessionModel.setUser(innerSession.users().getUserById(realm, userId)); + } + + @Override + public String getLoginUsername() { + return userSessionModel.getLoginUsername(); + } + + @Override + public void setLoginUsername(String loginUsername) { + userSessionModel.setLoginUsername(loginUsername); + } + + @Override + public String getIpAddress() { + return userSessionModel.getIpAddress(); + } + + @Override + public void setIpAddress(String ipAddress) { + userSessionModel.setIpAddress(ipAddress); + } + + @Override + public String getAuthMethod() { + return userSessionModel.getAuthMethod(); + } + + @Override + public void setAuthMethod(String authMethod) { + userSessionModel.setAuthMethod(authMethod); + } + + @Override + public boolean isRememberMe() { + return userSessionModel.isRememberMe(); + } + + @Override + public void setRememberMe(boolean rememberMe) { + userSessionModel.setRememberMe(rememberMe); + } + + @Override + public int getStarted() { + return userSessionModel.getStarted(); + } + + @Override + public void setStarted(int started) { + userSessionModel.setStarted(started); + } + + @Override + public int getLastSessionRefresh() { + return userSessionModel.getLastSessionRefresh(); + } + + @Override + public void setNotes(Map notes) { + userSessionModel.getNotes().keySet().forEach(userSessionModel::removeNote); + notes.forEach((k, v) -> userSessionModel.setNote(k, v)); + } + + @Override + public void setAuthenticatedClientSessions(AuthenticatedClientSessionStore authenticatedClientSessions) { + throw new IllegalStateException("not supported"); + } + + @Override + public UserSessionModel.State getState() { + return userSessionModel.getState(); + } + + @Override + public String getBrokerSessionId() { + return userSessionModel.getBrokerSessionId(); + } + + @Override + public void setBrokerSessionId(String brokerSessionId) { + userSessionModel.setBrokerSessionId(brokerSessionId); + } + + @Override + public String getBrokerUserId() { + return userSessionModel.getBrokerUserId(); + } + + @Override + public void setBrokerUserId(String brokerUserId) { + userSessionModel.setBrokerUserId(brokerUserId); + } + + @Override + public SessionEntityWrapper mergeRemoteEntityWithLocalEntity(SessionEntityWrapper localEntityWrapper) { + throw new IllegalStateException("not supported"); + } + }; + sessionUpdates.getUpdateTasks().forEach(vSessionUpdateTask -> { + vSessionUpdateTask.runUpdate((V) userSessionEntity); + if (vSessionUpdateTask.getOperation((V)userSessionEntity) == SessionUpdateTask.CacheOperation.REMOVE) { + userSessionPersister.removeUserSession(entry.getKey().toString(), offline); + } + }); + userSessionModel.getUpdatedModel(); + } + + } + } +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/PersistentSessionsChangelogBasedTransaction.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/PersistentSessionsChangelogBasedTransaction.java new file mode 100644 index 0000000000..fbec388528 --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/PersistentSessionsChangelogBasedTransaction.java @@ -0,0 +1,89 @@ +/* + * Copyright 2024 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.changes; + +import org.infinispan.Cache; +import org.keycloak.common.Profile; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.models.sessions.infinispan.SessionFunction; +import org.keycloak.models.sessions.infinispan.entities.SessionEntity; +import org.keycloak.models.sessions.infinispan.remotestore.RemoteCacheInvoker; + +import java.util.List; +import java.util.Map; + +import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME; +import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.USER_SESSION_CACHE_NAME; + +public class PersistentSessionsChangelogBasedTransaction extends InfinispanChangelogBasedTransaction { + + private final List> changesPerformers; + protected final boolean offline; + + public PersistentSessionsChangelogBasedTransaction(KeycloakSession session, Cache> cache, RemoteCacheInvoker remoteCacheInvoker, SessionFunction lifespanMsLoader, SessionFunction maxIdleTimeMsLoader, boolean offline) { + super(session, cache, remoteCacheInvoker, lifespanMsLoader, maxIdleTimeMsLoader); + this.offline = offline; + + if (!Profile.isFeatureEnabled(Profile.Feature.PERSISTENT_USER_SESSIONS)) { + throw new IllegalStateException("Persistent user sessions are not enabled"); + } + + if (Profile.isFeatureEnabled(Profile.Feature.PERSISTENT_USER_SESSIONS_NO_CACHE) && (cache.getName().equals(USER_SESSION_CACHE_NAME) || cache.getName().equals(CLIENT_SESSION_CACHE_NAME))) { + changesPerformers = List.of( + new JpaChangesPerformer<>(session, cache.getName(), offline) + ); + } else { + changesPerformers = List.of( + new JpaChangesPerformer<>(session, cache.getName(), offline), + new EmbeddedCachesChangesPerformer<>(cache), + new RemoteCachesChangesPerformer<>(session, cache, remoteCacheInvoker) + ); + } + } + + @Override + protected void commitImpl() { + for (Map.Entry> entry : updates.entrySet()) { + SessionUpdatesList sessionUpdates = entry.getValue(); + SessionEntityWrapper sessionWrapper = sessionUpdates.getEntityWrapper(); + + // Don't save transient entities to infinispan. They are valid just for current transaction + if (sessionUpdates.getPersistenceState() == UserSessionModel.SessionPersistenceState.TRANSIENT) continue; + + RealmModel realm = sessionUpdates.getRealm(); + + long lifespanMs = lifespanMsLoader.apply(realm, sessionUpdates.getClient(), sessionWrapper.getEntity()); + long maxIdleTimeMs = maxIdleTimeMsLoader.apply(realm, sessionUpdates.getClient(), sessionWrapper.getEntity()); + + MergedUpdate merged = MergedUpdate.computeUpdate(sessionUpdates.getUpdateTasks(), sessionWrapper, lifespanMs, maxIdleTimeMs); + + if (merged != null) { + changesPerformers.forEach(p -> p.registerChange(entry, merged)); + } + } + + changesPerformers.forEach(SessionChangesPerformer::applyChanges); + } + + @Override + protected void rollbackImpl() { + + } +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/RemoteCachesChangesPerformer.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/RemoteCachesChangesPerformer.java new file mode 100644 index 0000000000..d298815526 --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/RemoteCachesChangesPerformer.java @@ -0,0 +1,53 @@ +/* + * Copyright 2024 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.changes; + +import org.infinispan.Cache; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.sessions.infinispan.entities.SessionEntity; +import org.keycloak.models.sessions.infinispan.remotestore.RemoteCacheInvoker; + +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +public class RemoteCachesChangesPerformer implements SessionChangesPerformer { + + private final KeycloakSession session; + private final Cache> cache; + private final RemoteCacheInvoker remoteCacheInvoker; + private final List changes = new LinkedList<>(); + + + public RemoteCachesChangesPerformer(KeycloakSession session, Cache> cache, RemoteCacheInvoker remoteCacheInvoker) { + this.session = session; + this.cache = cache; + this.remoteCacheInvoker = remoteCacheInvoker; + } + + @Override + public void registerChange(Map.Entry> entry, MergedUpdate merged) { + SessionUpdatesList updates = entry.getValue(); + changes.add(() -> remoteCacheInvoker.runTask(session, updates.getRealm(), cache.getName(), entry.getKey(), merged, updates.getEntityWrapper())); + } + + @Override + public void applyChanges() { + changes.forEach(Runnable::run); + } +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/SessionChangesPerformer.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/SessionChangesPerformer.java new file mode 100644 index 0000000000..51640c5d14 --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/SessionChangesPerformer.java @@ -0,0 +1,28 @@ +/* + * Copyright 2024 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.changes; + +import org.keycloak.models.sessions.infinispan.entities.SessionEntity; + +import java.util.Map; + +public interface SessionChangesPerformer { + void registerChange(Map.Entry> entry, MergedUpdate merged); + + void applyChanges(); +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/UserSessionPersistentChangelogBasedTransaction.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/UserSessionPersistentChangelogBasedTransaction.java new file mode 100644 index 0000000000..adc8fa2b6c --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/UserSessionPersistentChangelogBasedTransaction.java @@ -0,0 +1,151 @@ +/* + * Copyright 2024 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.changes; + +import org.infinispan.Cache; +import org.jboss.logging.Logger; +import org.keycloak.common.Profile; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.models.UserSessionProvider; +import org.keycloak.models.session.UserSessionPersisterProvider; +import org.keycloak.models.sessions.infinispan.PersistentUserSessionProvider; +import org.keycloak.models.sessions.infinispan.SessionFunction; +import org.keycloak.models.sessions.infinispan.entities.SessionEntity; +import org.keycloak.models.sessions.infinispan.entities.UserSessionEntity; +import org.keycloak.models.sessions.infinispan.remotestore.RemoteCacheInvoker; + +import java.util.Collections; +import java.util.Objects; + +import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME; +import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.USER_SESSION_CACHE_NAME; + +public class UserSessionPersistentChangelogBasedTransaction extends PersistentSessionsChangelogBasedTransaction { + + private static final Logger LOG = Logger.getLogger(UserSessionPersistentChangelogBasedTransaction.class); + public UserSessionPersistentChangelogBasedTransaction(KeycloakSession session, Cache> cache, RemoteCacheInvoker remoteCacheInvoker, SessionFunction lifespanMsLoader, SessionFunction maxIdleTimeMsLoader, boolean offline) { + super(session, cache, remoteCacheInvoker, lifespanMsLoader, maxIdleTimeMsLoader, offline); + } + + public SessionEntityWrapper get(RealmModel realm, String key) { + SessionUpdatesList myUpdates = updates.get(key); + if (myUpdates == null) { + SessionEntityWrapper wrappedEntity = null; + if (!((Objects.equals(cache.getName(), USER_SESSION_CACHE_NAME) || Objects.equals(cache.getName(), CLIENT_SESSION_CACHE_NAME)) && Profile.isFeatureEnabled(Profile.Feature.PERSISTENT_USER_SESSIONS_NO_CACHE))) { + wrappedEntity = cache.get(key); + } + if (wrappedEntity == null) { + wrappedEntity = getSessionEntityFromPersister(realm, key); + } + + if (wrappedEntity == null) { + return null; + } + + RealmModel realmFromSession = kcSession.realms().getRealm(wrappedEntity.getEntity().getRealmId()); + if (!realmFromSession.getId().equals(realm.getId())) { + LOG.warnf("Realm mismatch for session %s. Expected realm %s, but found realm %s", wrappedEntity.getEntity(), realm.getId(), realmFromSession.getId()); + return null; + } + + myUpdates = new SessionUpdatesList<>(realm, wrappedEntity); + updates.put(key, myUpdates); + + return wrappedEntity; + } else { + UserSessionEntity entity = myUpdates.getEntityWrapper().getEntity(); + + // If entity is scheduled for remove, we don't return it. + boolean scheduledForRemove = myUpdates.getUpdateTasks().stream().filter((SessionUpdateTask task) -> { + + return task.getOperation(entity) == SessionUpdateTask.CacheOperation.REMOVE; + + }).findFirst().isPresent(); + + return scheduledForRemove ? null : myUpdates.getEntityWrapper(); + } + } + + public SessionEntityWrapper getSessionEntityFromPersister(RealmModel realm, String key) { + UserSessionPersisterProvider persister = kcSession.getProvider(UserSessionPersisterProvider.class); + UserSessionModel persistentUserSession = persister.loadUserSession(realm, key, offline); + + if (persistentUserSession == null) { + return null; + } + + SessionEntityWrapper userSessionEntitySessionEntityWrapper = importUserSession(persistentUserSession); + if (userSessionEntitySessionEntityWrapper == null) { + removeSessionEntityFromPersister(key); + } + + return userSessionEntitySessionEntityWrapper; + } + + private void removeSessionEntityFromPersister(String key) { + UserSessionPersisterProvider persister = kcSession.getProvider(UserSessionPersisterProvider.class); + persister.removeUserSession(key, offline); + } + + private SessionEntityWrapper importUserSession(UserSessionModel persistentUserSession) { + String sessionId = persistentUserSession.getId(); + + if (isScheduledForRemove(sessionId)) { + return null; + } + + if (Profile.isFeatureEnabled(Profile.Feature.PERSISTENT_USER_SESSIONS_NO_CACHE) && (cache.getName().equals(USER_SESSION_CACHE_NAME) || cache.getName().equals(CLIENT_SESSION_CACHE_NAME))) { + return ((PersistentUserSessionProvider) kcSession.getProvider(UserSessionProvider.class)).wrapPersistentEntity(persistentUserSession.getRealm(), offline, persistentUserSession); + } + + LOG.debugf("Attempting to import user-session for sessionId=%s offline=%s", sessionId, offline); + kcSession.sessions().importUserSessions(Collections.singleton(persistentUserSession), offline); + LOG.debugf("user-session imported, trying another lookup for sessionId=%s offline=%s", sessionId, offline); + + SessionEntityWrapper ispnUserSessionEntity = cache.get(sessionId); + + if (ispnUserSessionEntity != null) { + LOG.debugf("user-session found after import for sessionId=%s offline=%s", sessionId, offline); + return ispnUserSessionEntity; + } + + LOG.debugf("user-session could not be found after import for sessionId=%s offline=%s", sessionId, offline); + return null; + } + public boolean isScheduledForRemove(String key) { + return isScheduledForRemove(updates.get(key)); + } + + private static boolean isScheduledForRemove(SessionUpdatesList myUpdates) { + if (myUpdates == null) { + return false; + } + + V entity = myUpdates.getEntityWrapper().getEntity(); + + // If entity is scheduled for remove, we don't return it. + boolean scheduledForRemove = myUpdates.getUpdateTasks() + .stream() + .anyMatch(task -> task.getOperation(entity) == SessionUpdateTask.CacheOperation.REMOVE); + + return scheduledForRemove; + } + +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/AuthenticatedClientSessionEntity.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/AuthenticatedClientSessionEntity.java index 3f49f3b985..d3fcba7c10 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/AuthenticatedClientSessionEntity.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/AuthenticatedClientSessionEntity.java @@ -57,6 +57,8 @@ public class AuthenticatedClientSessionEntity extends SessionEntity { private final UUID id; + private transient String userSessionId; + public AuthenticatedClientSessionEntity(UUID id) { this.id = id; } @@ -190,6 +192,14 @@ public class AuthenticatedClientSessionEntity extends SessionEntity { return entityWrapper; } + public String getUserSessionId() { + return userSessionId; + } + + public void setUserSessionId(String userSessionId) { + this.userSessionId = userSessionId; + } + public static class ExternalizerImpl implements Externalizer { @Override diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/stream/UserSessionPredicate.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/stream/UserSessionPredicate.java index e0e7be264b..c87b125f08 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/stream/UserSessionPredicate.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/stream/UserSessionPredicate.java @@ -17,6 +17,7 @@ package org.keycloak.models.sessions.infinispan.stream; +import org.keycloak.models.UserSessionModel; import org.keycloak.models.sessions.infinispan.AuthenticatedClientSessionAdapter; import org.keycloak.models.sessions.infinispan.changes.SessionEntityWrapper; import org.keycloak.models.sessions.infinispan.entities.UserSessionEntity; @@ -165,6 +166,51 @@ public class UserSessionPredicate implements Predicate toModelPredicate() { + + return (Predicate) entity -> { + if (!realm.equals(entity.getRealm().getId())) { + return false; + } + + if (user != null && !entity.getUser().getId().equals(user)) { + return false; + } + + if (client != null && (entity.getAuthenticatedClientSessions() == null || !entity.getAuthenticatedClientSessions().containsKey(client))) { + return false; + } + + if (brokerSessionId != null && !brokerSessionId.equals(entity.getBrokerSessionId())) { + return false; + } + + if (brokerUserId != null && !brokerUserId.equals(entity.getBrokerUserId())) { + return false; + } + + if (entity.isRememberMe()) { + if (expiredRememberMe != null && expiredRefreshRememberMe != null && entity.getStarted() > expiredRememberMe && entity.getLastSessionRefresh() > expiredRefreshRememberMe) { + return false; + } + } else { + if (expired != null && expiredRefresh != null && entity.getStarted() > expired && entity.getLastSessionRefresh() > expiredRefresh) { + return false; + } + } + + if (expired == null && expiredRefresh != null && entity.getLastSessionRefresh() > expiredRefresh) { + return false; + } + return true; + }; + } + + public static class ExternalizerImpl implements Externalizer { private static final int VERSION_1 = 1; diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaUserSessionPersisterProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaUserSessionPersisterProvider.java index 3bdfd6f76d..986b4bc633 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaUserSessionPersisterProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaUserSessionPersisterProvider.java @@ -18,10 +18,12 @@ package org.keycloak.models.jpa.session; import org.jboss.logging.Logger; +import org.keycloak.common.Profile; import org.keycloak.common.util.Time; import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OfflineUserSessionModel; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; @@ -38,7 +40,6 @@ import jakarta.persistence.Query; import jakarta.persistence.TypedQuery; import java.util.Collection; -import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -81,6 +82,7 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv entity.setOffline(offlineStr); entity.setLastSessionRefresh(model.getLastSessionRefresh()); entity.setData(model.getData()); + entity.setBrokerSessionId(userSession.getBrokerSessionId()); em.persist(entity); em.flush(); } @@ -165,7 +167,7 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv // Remove userSession if it was last clientSession List clientSessions = getClientSessionsByUserSession(sessionEntity.getUserSessionId(), offline); - if (clientSessions.size() == 0) { + if (clientSessions.size() == 0 && offline) { offlineStr = offlineToString(offline); PersistentUserSessionEntity userSessionEntity = em.find(PersistentUserSessionEntity.class, new PersistentUserSessionEntity.Key(sessionEntity.getUserSessionId(), offlineStr), LockModeType.PESSIMISTIC_WRITE); if (userSessionEntity != null) { @@ -252,7 +254,24 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv expiredClientOffline = Time.currentTime() - realm.getClientOfflineSessionIdleTimeout() - SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS; } - String offlineStr = offlineToString(true); + expire(realm, expiredClientOffline, expiredOffline, true); + + if (Profile.isFeatureEnabled(Profile.Feature.PERSISTENT_USER_SESSIONS)) { + + int expired = Time.currentTime() - Math.max(realm.getSsoSessionIdleTimeout(), realm.getSsoSessionIdleTimeoutRememberMe()) - SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS; + + // prefer client session timeout if set + int expiredClient = expired; + if (realm.getClientSessionIdleTimeout() > 0) { + expiredClient = Time.currentTime() - realm.getClientSessionIdleTimeout() - SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS; + } + + expire(realm, expiredClient, expired, false); + } + } + + private void expire(RealmModel realm, int expiredClientOffline, int expiredOffline, boolean offline) { + String offlineStr = offlineToString(offline); logger.tracef("Trigger removing expired user sessions for realm '%s'", realm.getName()); @@ -269,7 +288,6 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv .executeUpdate(); logger.debugf("Removed %d expired user sessions and %d expired client sessions in realm '%s'", us, cs, realm.getName()); - } @Override @@ -305,7 +323,7 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv userSessionQuery.setParameter("userSessionId", userSessionId); userSessionQuery.setMaxResults(1); - Stream persistentUserSessions = closing(userSessionQuery.getResultStream().map(this::toAdapter)); + Stream persistentUserSessions = closing(userSessionQuery.getResultStream().map(this::toAdapter)); return persistentUserSessions.findAny().map(userSession -> { @@ -330,6 +348,41 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv }).orElse(null); } + @Override + public UserSessionModel loadUserSessionsStreamByBrokerSessionId(RealmModel realm, String brokerSessionId, boolean offline) { + + TypedQuery userSessionQuery = em.createNamedQuery("findUserSessionsByBrokerSessionId", PersistentUserSessionEntity.class); + userSessionQuery.setParameter("realmId", realm.getId()); + userSessionQuery.setParameter("brokerSessionId", brokerSessionId); + userSessionQuery.setParameter("offline", offlineToString(offline)); + userSessionQuery.setMaxResults(1); + + Stream persistentUserSessions = closing(userSessionQuery.getResultStream().map(this::toAdapter)); + + return persistentUserSessions.findAny().map(userSession -> { + + TypedQuery clientSessionQuery = em.createNamedQuery("findClientSessionsByUserSession", PersistentClientSessionEntity.class); + clientSessionQuery.setParameter("userSessionId", userSession.getId()); + clientSessionQuery.setParameter("offline", offlineToString(userSession.isOffline())); + + Set removedClientUUIDs = new HashSet<>(); + + closing(clientSessionQuery.getResultStream()).forEach(clientSession -> { + boolean added = addClientSessionToAuthenticatedClientSessionsIfPresent(userSession, clientSession); + if (!added) { + // client was removed in the meantime + removedClientUUIDs.add(clientSession.getClientId()); + } + } + ); + + removedClientUUIDs.forEach(this::onClientRemoved); + + return userSession; + }).orElse(null); + } + + @Override public Stream loadUserSessionsStream(RealmModel realm, ClientModel client, boolean offline, Integer firstResult, Integer maxResults) { @@ -417,12 +470,12 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv * @return */ private Stream loadUserSessionsWithClientSessions(TypedQuery query, String offlineStr, boolean useExact) { - List userSessionAdapters = closing(query.getResultStream() + List userSessionAdapters = closing(query.getResultStream() .map(this::toAdapter) .filter(Objects::nonNull)) .collect(Collectors.toList()); - Map sessionsById = userSessionAdapters.stream() + Map sessionsById = userSessionAdapters.stream() .collect(Collectors.toMap(UserSessionModel::getId, Function.identity())); Set userSessionIds = sessionsById.keySet(); @@ -446,7 +499,7 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv } closing(queryClientSessions.getResultStream()).forEach(clientSession -> { - PersistentUserSessionAdapter userSession = sessionsById.get(clientSession.getUserSessionId()); + OfflineUserSessionModel userSession = sessionsById.get(clientSession.getUserSessionId()); // check if we have a user session for the client session if (userSession != null) { boolean added = addClientSessionToAuthenticatedClientSessionsIfPresent(userSession, clientSession); @@ -467,9 +520,9 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv return userSessionAdapters.stream().map(UserSessionModel.class::cast); } - private boolean addClientSessionToAuthenticatedClientSessionsIfPresent(PersistentUserSessionAdapter userSession, PersistentClientSessionEntity clientSessionEntity) { + private boolean addClientSessionToAuthenticatedClientSessionsIfPresent(OfflineUserSessionModel userSession, PersistentClientSessionEntity clientSessionEntity) { - PersistentAuthenticatedClientSessionAdapter clientSessAdapter = toAdapter(userSession.getRealm(), userSession, clientSessionEntity); + AuthenticatedClientSessionModel clientSessAdapter = toAdapter(userSession.getRealm(), userSession, clientSessionEntity); if (clientSessAdapter.getClient() == null) { logger.tracef("Not adding client session %s / %s since client is null", userSession, clientSessAdapter); @@ -487,7 +540,7 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv return true; } - private PersistentUserSessionAdapter toAdapter(PersistentUserSessionEntity entity) { + private OfflineUserSessionModel toAdapter(PersistentUserSessionEntity entity) { RealmModel realm = session.realms().getRealm(entity.getRealmId()); if (realm == null) { // Realm has been deleted concurrently, ignore the entity return null; @@ -495,19 +548,79 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv return toAdapter(realm, entity); } - private PersistentUserSessionAdapter toAdapter(RealmModel realm, PersistentUserSessionEntity entity) { - PersistentUserSessionModel model = new PersistentUserSessionModel(); - model.setUserSessionId(entity.getUserSessionId()); - model.setStarted(entity.getCreatedOn()); - model.setLastSessionRefresh(entity.getLastSessionRefresh()); - model.setData(entity.getData()); - model.setOffline(offlineFromString(entity.getOffline())); + private OfflineUserSessionModel toAdapter(RealmModel realm, PersistentUserSessionEntity entity) { + PersistentUserSessionModel model = new PersistentUserSessionModel() { + @Override + public String getUserSessionId() { + return entity.getUserSessionId(); + } + + @Override + public void setUserSessionId(String userSessionId) { + entity.setUserSessionId(userSessionId); + } + + @Override + public int getStarted() { + return entity.getCreatedOn(); + } + + @Override + public void setStarted(int started) { + entity.setCreatedOn(started); + } + + @Override + public int getLastSessionRefresh() { + return entity.getLastSessionRefresh(); + } + + @Override + public void setLastSessionRefresh(int lastSessionRefresh) { + entity.setLastSessionRefresh(lastSessionRefresh); + } + + @Override + public boolean isOffline() { + return offlineFromString(entity.getOffline()); + } + + @Override + public void setOffline(boolean offline) { + entity.setOffline(offlineToString(offline)); + } + + @Override + public String getData() { + return entity.getData(); + } + + @Override + public void setData(String data) { + entity.setData(data); + } + + @Override + public void setRealmId(String realmId) { + entity.setRealmId(realmId); + } + + @Override + public void setUserId(String userId) { + entity.setUserId(userId); + } + + @Override + public void setBrokerSessionId(String brokerSessionId) { + entity.setBrokerSessionId(brokerSessionId); + } + }; Map clientSessions = new HashMap<>(); return new PersistentUserSessionAdapter(session, model, realm, entity.getUserId(), clientSessions); } - private PersistentAuthenticatedClientSessionAdapter toAdapter(RealmModel realm, UserSessionModel userSession, PersistentClientSessionEntity entity) { + private AuthenticatedClientSessionModel toAdapter(RealmModel realm, UserSessionModel userSession, PersistentClientSessionEntity entity) { String clientId = entity.getClientId(); if (isExternalClient(entity)) { clientId = getExternalClientId(entity); @@ -515,21 +628,51 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv // can be null if client is not found anymore ClientModel client = realm.getClientById(clientId); - PersistentClientSessionModel model = new PersistentClientSessionModel(); - model.setClientId(clientId); - model.setUserSessionId(userSession.getId()); - - if (userSession instanceof PersistentUserSessionAdapter) { - model.setUserId(((PersistentUserSessionAdapter) userSession).getUserId()); - } - else { - UserModel user = userSession.getUser(); - if (user != null) { - model.setUserId(user.getId()); + PersistentClientSessionModel model = new PersistentClientSessionModel() { + @Override + public String getUserSessionId() { + return entity.getUserSessionId(); } - } - model.setTimestamp(entity.getTimestamp()); - model.setData(entity.getData()); + + @Override + public void setUserSessionId(String userSessionId) { + entity.setUserSessionId(userSessionId); + } + + @Override + public String getClientId() { + String clientId = entity.getClientId(); + if (isExternalClient(entity)) { + clientId = getExternalClientId(entity); + } + return clientId; + } + + @Override + public void setClientId(String clientId) { + throw new IllegalStateException("forbidden"); + } + + @Override + public int getTimestamp() { + return entity.getTimestamp(); + } + + @Override + public void setTimestamp(int timestamp) { + entity.setTimestamp(timestamp); + } + + @Override + public String getData() { + return entity.getData(); + } + + @Override + public void setData(String data) { + entity.setData(data); + } + }; return new PersistentAuthenticatedClientSessionAdapter(session, model, realm, client, userSession); } @@ -565,6 +708,19 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv return n.intValue(); } + @Override + public void removeUserSessions(RealmModel realm, boolean offline) { + em.createNamedQuery("deleteClientSessionsByRealmSessionType") + .setParameter("realmId", realm.getId()) + .setParameter("offline", offlineToString(offline)) + .executeUpdate(); + em.createNamedQuery("deleteUserSessionsByRealmSessionType") + .setParameter("realmId", realm.getId()) + .setParameter("offline", offlineToString(offline)) + .executeUpdate(); + } + + @Override public void close() { // NOOP diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentClientSessionEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentClientSessionEntity.java index 8524893684..832197d4f9 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentClientSessionEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentClientSessionEntity.java @@ -24,6 +24,8 @@ import jakarta.persistence.IdClass; import jakarta.persistence.NamedQueries; import jakarta.persistence.NamedQuery; import jakarta.persistence.Table; +import jakarta.persistence.Version; + import java.io.Serializable; /** @@ -77,6 +79,9 @@ public class PersistentClientSessionEntity { @Column(name="TIMESTAMP") protected int timestamp; + @Version + private int version; + @Id @Column(name = "OFFLINE_FLAG") protected String offline; diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentUserSessionEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentUserSessionEntity.java index c711a47dc4..78198658a9 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentUserSessionEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentUserSessionEntity.java @@ -17,6 +17,7 @@ package org.keycloak.models.jpa.session; +import jakarta.persistence.Version; import org.keycloak.storage.jpa.KeyUtils; import jakarta.persistence.Column; @@ -33,6 +34,7 @@ import java.io.Serializable; */ @NamedQueries({ @NamedQuery(name="deleteUserSessionsByRealm", query="delete from PersistentUserSessionEntity sess where sess.realmId = :realmId"), + @NamedQuery(name="deleteUserSessionsByRealmSessionType", query="delete from PersistentUserSessionEntity sess where sess.realmId = :realmId and sess.offline = :offline"), @NamedQuery(name="deleteUserSessionsByUser", query="delete from PersistentUserSessionEntity sess where sess.userId = :userId"), @NamedQuery(name="deleteExpiredUserSessions", query="delete from PersistentUserSessionEntity sess where sess.realmId = :realmId AND sess.offline = :offline AND sess.lastSessionRefresh < :lastSessionRefresh"), @NamedQuery(name="updateUserSessionLastSessionRefresh", query="update PersistentUserSessionEntity sess set lastSessionRefresh = :lastSessionRefresh where sess.realmId = :realmId" + @@ -45,14 +47,16 @@ import java.io.Serializable; " AND sess.userSessionId = :userSessionId AND sess.realmId = :realmId"), @NamedQuery(name="findUserSessionsByUserId", query="select sess from PersistentUserSessionEntity sess where sess.offline = :offline" + " AND sess.realmId = :realmId AND sess.userId = :userId ORDER BY sess.userSessionId"), + @NamedQuery(name="findUserSessionsByBrokerSessionId", query="select sess from PersistentUserSessionEntity sess where sess.brokerSessionId = :brokerSessionId" + + " AND sess.realmId = :realmId AND sess.offline = :offline ORDER BY sess.userSessionId"), @NamedQuery(name="findUserSessionsByClientId", query="SELECT sess FROM PersistentUserSessionEntity sess INNER JOIN PersistentClientSessionEntity clientSess " + - " ON sess.userSessionId = clientSess.userSessionId AND clientSess.clientId = :clientId WHERE sess.offline = :offline " + + " ON sess.userSessionId = clientSess.userSessionId AND sess.offline = clientSess.offline AND clientSess.clientId = :clientId WHERE sess.offline = :offline " + " AND sess.realmId = :realmId ORDER BY sess.userSessionId"), @NamedQuery(name="findUserSessionsByExternalClientId", query="SELECT sess FROM PersistentUserSessionEntity sess INNER JOIN PersistentClientSessionEntity clientSess " + - " ON sess.userSessionId = clientSess.userSessionId AND clientSess.clientStorageProvider = :clientStorageProvider AND clientSess.externalClientId = :externalClientId WHERE sess.offline = :offline " + + " ON sess.userSessionId = clientSess.userSessionId AND clientSess.clientStorageProvider = :clientStorageProvider AND sess.offline = clientSess.offline AND clientSess.externalClientId = :externalClientId WHERE sess.offline = :offline " + " AND sess.realmId = :realmId ORDER BY sess.userSessionId"), @NamedQuery(name="findClientSessionsClientIds", query="SELECT clientSess.clientId, clientSess.externalClientId, clientSess.clientStorageProvider, count(clientSess)" + - " FROM PersistentClientSessionEntity clientSess INNER JOIN PersistentUserSessionEntity sess ON clientSess.userSessionId = sess.userSessionId " + + " FROM PersistentClientSessionEntity clientSess INNER JOIN PersistentUserSessionEntity sess ON clientSess.userSessionId = sess.userSessionId AND sess.offline = clientSess.offline" + " WHERE sess.offline = :offline AND sess.realmId = :realmId " + " GROUP BY clientSess.clientId, clientSess.externalClientId, clientSess.clientStorageProvider") @@ -78,6 +82,12 @@ public class PersistentUserSessionEntity { @Column(name = "LAST_SESSION_REFRESH") protected int lastSessionRefresh; + @Column(name = "BROKER_SESSION_ID") + protected String brokerSessionId; + + @Version + private int version; + @Id @Column(name = "OFFLINE_FLAG") protected String offline; @@ -134,6 +144,14 @@ public class PersistentUserSessionEntity { this.offline = offline; } + public String getBrokerSessionId() { + return brokerSessionId; + } + + public void setBrokerSessionId(String brokerSessionId) { + this.brokerSessionId = brokerSessionId; + } + public String getData() { return data; } diff --git a/model/jpa/src/main/java/org/keycloak/storage/jpa/KeyUtils.java b/model/jpa/src/main/java/org/keycloak/storage/jpa/KeyUtils.java index ac2bc2584a..f86f9fb38d 100644 --- a/model/jpa/src/main/java/org/keycloak/storage/jpa/KeyUtils.java +++ b/model/jpa/src/main/java/org/keycloak/storage/jpa/KeyUtils.java @@ -18,6 +18,7 @@ package org.keycloak.storage.jpa; import java.util.regex.Pattern; import org.jboss.logging.Logger; +import org.keycloak.models.light.LightweightUserAdapter; /** * @@ -33,6 +34,8 @@ public class KeyUtils { UUID_PATTERN.pattern() + "|" + "f:" + UUID_PATTERN.pattern() + ":.*" + + "|" + + LightweightUserAdapter.ID_PREFIX + UUID_PATTERN.pattern() ); /** diff --git a/model/jpa/src/main/resources/META-INF/jpa-changelog-25.0.0.xml b/model/jpa/src/main/resources/META-INF/jpa-changelog-25.0.0.xml index 2780bffeb6..816cb1c1ba 100644 --- a/model/jpa/src/main/resources/META-INF/jpa-changelog-25.0.0.xml +++ b/model/jpa/src/main/resources/META-INF/jpa-changelog-25.0.0.xml @@ -17,6 +17,63 @@ --> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/model/jpa/src/main/resources/META-INF/queries-default.properties b/model/jpa/src/main/resources/META-INF/queries-default.properties index 1759216dac..749eb034cb 100644 --- a/model/jpa/src/main/resources/META-INF/queries-default.properties +++ b/model/jpa/src/main/resources/META-INF/queries-default.properties @@ -5,7 +5,7 @@ # type can be native (for native queries) or jpql (jpql syntax) # if no type is defined jpql is the default -deleteExpiredClientSessions=delete from PersistentClientSessionEntity sess where sess.userSessionId IN (\ +deleteExpiredClientSessions=delete from PersistentClientSessionEntity sess where sess.offline = :offline AND sess.userSessionId IN (\ select u.userSessionId from PersistentUserSessionEntity u \ where u.realmId = :realmId AND u.offline = :offline AND u.lastSessionRefresh < :lastSessionRefresh) @@ -13,6 +13,10 @@ deleteClientSessionsByRealm=delete from PersistentClientSessionEntity sess where select u.userSessionId from PersistentUserSessionEntity u \ where u.realmId = :realmId) +deleteClientSessionsByRealmSessionType=delete from PersistentClientSessionEntity sess where sess.offline = :offline AND sess.userSessionId IN (\ + select u.userSessionId from PersistentUserSessionEntity u \ + where u.realmId = :realmId and u.offline = :offline) + deleteClientSessionsByUser=delete from PersistentClientSessionEntity sess where sess.userSessionId IN (\ select u.userSessionId from PersistentUserSessionEntity u \ where u.userId = :userId) diff --git a/model/jpa/src/main/resources/META-INF/queries-mariadb.properties b/model/jpa/src/main/resources/META-INF/queries-mariadb.properties index cb3c69fdb1..b2422319da 100644 --- a/model/jpa/src/main/resources/META-INF/queries-mariadb.properties +++ b/model/jpa/src/main/resources/META-INF/queries-mariadb.properties @@ -5,11 +5,14 @@ # if no type is defined jpql is the default deleteExpiredClientSessions[native]=delete c from OFFLINE_CLIENT_SESSION c join OFFLINE_USER_SESSION u \ - where c.USER_SESSION_ID = u.USER_SESSION_ID and u.REALM_ID = :realmId and u.OFFLINE_FLAG = :offline \ + where c.USER_SESSION_ID = u.USER_SESSION_ID and u.REALM_ID = :realmId and u.OFFLINE_FLAG = c.OFFLINE_FLAG and u.OFFLINE_FLAG = :offline \ and u.LAST_SESSION_REFRESH < :lastSessionRefresh deleteClientSessionsByRealm[native]=delete c from OFFLINE_CLIENT_SESSION c join OFFLINE_USER_SESSION u \ where c.USER_SESSION_ID = u.USER_SESSION_ID and u.REALM_ID = :realmId +deleteClientSessionsByRealmSessionType[native]=delete c from OFFLINE_CLIENT_SESSION c join OFFLINE_USER_SESSION u \ + where c.USER_SESSION_ID = u.USER_SESSION_ID and u.OFFLINE_FLAG = c.OFFLINE_FLAG AND u.REALM_ID = :realmId and u.OFFLINE_FLAG = :offline + deleteClientSessionsByUser[native]=delete c from OFFLINE_CLIENT_SESSION c join OFFLINE_USER_SESSION u \ where c.USER_SESSION_ID = u.USER_SESSION_ID and u.USER_ID = :userId diff --git a/model/jpa/src/main/resources/META-INF/queries-mysql.properties b/model/jpa/src/main/resources/META-INF/queries-mysql.properties index cb3c69fdb1..b2422319da 100644 --- a/model/jpa/src/main/resources/META-INF/queries-mysql.properties +++ b/model/jpa/src/main/resources/META-INF/queries-mysql.properties @@ -5,11 +5,14 @@ # if no type is defined jpql is the default deleteExpiredClientSessions[native]=delete c from OFFLINE_CLIENT_SESSION c join OFFLINE_USER_SESSION u \ - where c.USER_SESSION_ID = u.USER_SESSION_ID and u.REALM_ID = :realmId and u.OFFLINE_FLAG = :offline \ + where c.USER_SESSION_ID = u.USER_SESSION_ID and u.REALM_ID = :realmId and u.OFFLINE_FLAG = c.OFFLINE_FLAG and u.OFFLINE_FLAG = :offline \ and u.LAST_SESSION_REFRESH < :lastSessionRefresh deleteClientSessionsByRealm[native]=delete c from OFFLINE_CLIENT_SESSION c join OFFLINE_USER_SESSION u \ where c.USER_SESSION_ID = u.USER_SESSION_ID and u.REALM_ID = :realmId +deleteClientSessionsByRealmSessionType[native]=delete c from OFFLINE_CLIENT_SESSION c join OFFLINE_USER_SESSION u \ + where c.USER_SESSION_ID = u.USER_SESSION_ID and u.OFFLINE_FLAG = c.OFFLINE_FLAG AND u.REALM_ID = :realmId and u.OFFLINE_FLAG = :offline + deleteClientSessionsByUser[native]=delete c from OFFLINE_CLIENT_SESSION c join OFFLINE_USER_SESSION u \ where c.USER_SESSION_ID = u.USER_SESSION_ID and u.USER_ID = :userId diff --git a/model/storage-private/src/main/java/org/keycloak/models/session/PersistentAuthenticatedClientSessionAdapter.java b/model/storage-private/src/main/java/org/keycloak/models/session/PersistentAuthenticatedClientSessionAdapter.java index 00a7155127..791c68383d 100644 --- a/model/storage-private/src/main/java/org/keycloak/models/session/PersistentAuthenticatedClientSessionAdapter.java +++ b/model/storage-private/src/main/java/org/keycloak/models/session/PersistentAuthenticatedClientSessionAdapter.java @@ -52,9 +52,45 @@ public class PersistentAuthenticatedClientSessionAdapter implements Authenticate data.setNotes(clientSession.getNotes()); data.setRedirectUri(clientSession.getRedirectUri()); - model = new PersistentClientSessionModel(); + model = new PersistentClientSessionModel() { + private String userSessionId; + private String clientId; + private int timestamp; + private String data; + + public String getUserSessionId() { + return userSessionId; + } + + public void setUserSessionId(String userSessionId) { + this.userSessionId = userSessionId; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public int getTimestamp() { + return timestamp; + } + + public void setTimestamp(int timestamp) { + this.timestamp = timestamp; + } + + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } + }; model.setClientId(clientSession.getClient().getId()); - model.setUserId(clientSession.getUserSession().getUser().getId()); model.setUserSessionId(clientSession.getUserSession().getId()); model.setTimestamp(clientSession.getTimestamp()); @@ -151,22 +187,22 @@ public class PersistentAuthenticatedClientSessionAdapter implements Authenticate @Override public String getCurrentRefreshToken() { - return null; // Information not persisted. + return getData().getCurrentRefreshToken(); } @Override public void setCurrentRefreshToken(String currentRefreshToken) { - // Information not persisted. + getData().setCurrentRefreshToken(currentRefreshToken); } @Override public int getCurrentRefreshTokenUseCount() { - return 0; // Information not persisted. + return getData().getCurrentRefreshTokenUseCount(); } @Override public void setCurrentRefreshTokenUseCount(int currentRefreshTokenUseCount) { - // Information not persisted. + getData().setCurrentRefreshTokenUseCount(currentRefreshTokenUseCount); } @Override @@ -263,7 +299,10 @@ public class PersistentAuthenticatedClientSessionAdapter implements Authenticate private Set protocolMappers; @JsonProperty("roles") private Set roles; - + @JsonProperty("currentRefreshToken") + private String currentRefreshToken; + @JsonProperty("currentRefreshTokenUseCount") + private int currentRefreshTokenUseCount; public String getAuthMethod() { return authMethod; @@ -336,5 +375,21 @@ public class PersistentAuthenticatedClientSessionAdapter implements Authenticate public void setRoles(Set roles) { this.roles = roles; } + + public String getCurrentRefreshToken() { + return currentRefreshToken; + } + + public void setCurrentRefreshToken(String currentRefreshToken) { + this.currentRefreshToken = currentRefreshToken; + } + + public int getCurrentRefreshTokenUseCount() { + return currentRefreshTokenUseCount; + } + + public void setCurrentRefreshTokenUseCount(int currentRefreshTokenUseCount) { + this.currentRefreshTokenUseCount = currentRefreshTokenUseCount; + } } } diff --git a/model/storage-private/src/main/java/org/keycloak/models/session/PersistentClientSessionModel.java b/model/storage-private/src/main/java/org/keycloak/models/session/PersistentClientSessionModel.java index ee33fedf03..648ebb9cfd 100644 --- a/model/storage-private/src/main/java/org/keycloak/models/session/PersistentClientSessionModel.java +++ b/model/storage-private/src/main/java/org/keycloak/models/session/PersistentClientSessionModel.java @@ -20,52 +20,21 @@ package org.keycloak.models.session; /** * @author Marek Posolda */ -public class PersistentClientSessionModel { +public interface PersistentClientSessionModel { - private String userSessionId; - private String clientId; - private String userId; - private int timestamp; - private String data; + String getUserSessionId(); + void setUserSessionId(String userSessionId); - public String getUserSessionId() { - return userSessionId; - } + String getClientId(); - public void setUserSessionId(String userSessionId) { - this.userSessionId = userSessionId; - } + void setClientId(String clientId); - public String getClientId() { - return clientId; - } + int getTimestamp(); - public void setClientId(String clientId) { - this.clientId = clientId; - } + void setTimestamp(int timestamp); - public String getUserId() { - return userId; - } + String getData(); - public void setUserId(String userId) { - this.userId = userId; - } - - public int getTimestamp() { - return timestamp; - } - - public void setTimestamp(int timestamp) { - this.timestamp = timestamp; - } - - public String getData() { - return data; - } - - public void setData(String data) { - this.data = data; - } + void setData(String data); } diff --git a/model/storage-private/src/main/java/org/keycloak/models/session/PersistentUserSessionAdapter.java b/model/storage-private/src/main/java/org/keycloak/models/session/PersistentUserSessionAdapter.java index da5cb96bd6..2655a5932e 100644 --- a/model/storage-private/src/main/java/org/keycloak/models/session/PersistentUserSessionAdapter.java +++ b/model/storage-private/src/main/java/org/keycloak/models/session/PersistentUserSessionAdapter.java @@ -18,6 +18,7 @@ package org.keycloak.models.session; import com.fasterxml.jackson.annotation.JsonProperty; +import org.keycloak.common.Profile; import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.ModelException; @@ -25,6 +26,7 @@ import org.keycloak.models.OfflineUserSessionModel; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; +import org.keycloak.models.light.LightweightUserAdapter; import org.keycloak.util.JsonSerialization; import java.io.IOException; @@ -32,6 +34,8 @@ import java.util.Collection; import java.util.HashMap; import java.util.Map; +import static org.keycloak.models.Constants.SESSION_NOTE_LIGHTWEIGHT_USER; + /** * @author Marek Posolda */ @@ -40,7 +44,7 @@ public class PersistentUserSessionAdapter implements OfflineUserSessionModel { private final PersistentUserSessionModel model; private UserModel user; private String userId; - private final RealmModel realm; + private RealmModel realm; private KeycloakSession session; private final Map authenticatedClientSessions; @@ -58,7 +62,78 @@ public class PersistentUserSessionAdapter implements OfflineUserSessionModel { data.setState(other.getState().toString()); } - this.model = new PersistentUserSessionModel(); + this.model = new PersistentUserSessionModel() { + private String userSessionId; + private int started; + private int lastSessionRefresh; + private boolean offline; + private String data; + + @Override + public String getUserSessionId() { + return userSessionId; + } + + @Override + public void setUserSessionId(String userSessionId) { + this.userSessionId = userSessionId; + } + + @Override + public int getStarted() { + return started; + } + + @Override + public void setStarted(int started) { + this.started = started; + } + + @Override + public int getLastSessionRefresh() { + return lastSessionRefresh; + } + + @Override + public void setLastSessionRefresh(int lastSessionRefresh) { + this.lastSessionRefresh = lastSessionRefresh; + } + + @Override + public boolean isOffline() { + return offline; + } + + @Override + public void setOffline(boolean offline) { + this.offline = offline; + } + + @Override + public String getData() { + return data; + } + + @Override + public void setData(String data) { + this.data = data; + } + + @Override + public void setRealmId(String realmId) { + /* ignored */ + } + + @Override + public void setUserId(String userId) { + /* ignored */ + } + + @Override + public void setBrokerSessionId(String brokerSessionId) { + /* ignored */ + } + }; this.model.setStarted(other.getStarted()); this.model.setUserSessionId(other.getId()); this.model.setLastSessionRefresh(other.getLastSessionRefresh()); @@ -120,7 +195,11 @@ public class PersistentUserSessionAdapter implements OfflineUserSessionModel { @Override public UserModel getUser() { if (user == null) { - user = session.users().getUserById(realm, userId); + if (LightweightUserAdapter.isLightweightUser(userId)) { + user = LightweightUserAdapter.fromString(session, realm, getData().getNotes().get(SESSION_NOTE_LIGHTWEIGHT_USER)); + } else { + user = session.users().getUserById(realm, userId); + } } return user; } @@ -137,7 +216,11 @@ public class PersistentUserSessionAdapter implements OfflineUserSessionModel { @Override public String getLoginUsername() { - return getUser().getUsername(); + if (isOffline() || !Profile.isFeatureEnabled(Profile.Feature.PERSISTENT_USER_SESSIONS)) { + return getUser().getUsername(); + } else { + return getData().getLoginUsername(); + } } @Override @@ -243,6 +326,11 @@ public class PersistentUserSessionAdapter implements OfflineUserSessionModel { throw new IllegalStateException("Not supported"); } + @Override + public void setLoginUsername(String loginUsername) { + getData().setLoginUsername(loginUsername); + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -262,6 +350,41 @@ public class PersistentUserSessionAdapter implements OfflineUserSessionModel { return getId(); } + public void setRealm(RealmModel realm) { + this.realm = realm; + model.setRealmId(realm.getId()); + } + + public void setUser(UserModel user) { + this.user = user; + model.setUserId(user.getId()); + } + + public void setIpAddress(String ipAddress) { + getData().setIpAddress(ipAddress); + } + + public void setAuthMethod(String authMethod) { + getData().setAuthMethod(authMethod); + } + + public void setRememberMe(boolean rememberMe) { + getData().setRememberMe(rememberMe); + } + + public void setStarted(int started) { + getData().setStarted(started); + } + + public void setBrokerSessionId(String brokerSessionId) { + getData().setBrokerSessionId(brokerSessionId); + model.setBrokerSessionId(brokerSessionId); + } + + public void setBrokerUserId(String brokerUserId) { + getData().setBrokerUserId(brokerUserId); + } + protected static class PersistentUserSessionData { @JsonProperty("brokerSessionId") @@ -289,6 +412,9 @@ public class PersistentUserSessionAdapter implements OfflineUserSessionModel { @JsonProperty("state") private String state; + @JsonProperty("loginUsername") + private String loginUsername; + public String getBrokerSessionId() { return brokerSessionId; } @@ -354,5 +480,13 @@ public class PersistentUserSessionAdapter implements OfflineUserSessionModel { public void setState(String state) { this.state = state; } + + public void setLoginUsername(String loginUsername) { + this.loginUsername = loginUsername; + } + + public String getLoginUsername() { + return loginUsername; + } } } diff --git a/model/storage-private/src/main/java/org/keycloak/models/session/PersistentUserSessionModel.java b/model/storage-private/src/main/java/org/keycloak/models/session/PersistentUserSessionModel.java index 508b81741b..cd70b2a4d7 100644 --- a/model/storage-private/src/main/java/org/keycloak/models/session/PersistentUserSessionModel.java +++ b/model/storage-private/src/main/java/org/keycloak/models/session/PersistentUserSessionModel.java @@ -20,51 +20,32 @@ package org.keycloak.models.session; /** * @author Marek Posolda */ -public class PersistentUserSessionModel { +public interface PersistentUserSessionModel { - private String userSessionId; - private int started; - private int lastSessionRefresh; - private boolean offline; - private String data; + String getUserSessionId(); - public String getUserSessionId() { - return userSessionId; - } + void setUserSessionId(String userSessionId); - public void setUserSessionId(String userSessionId) { - this.userSessionId = userSessionId; - } + int getStarted(); - public int getStarted() { - return started; - } + void setStarted(int started); - public void setStarted(int started) { - this.started = started; - } + int getLastSessionRefresh(); - public int getLastSessionRefresh() { - return lastSessionRefresh; - } + void setLastSessionRefresh(int lastSessionRefresh); - public void setLastSessionRefresh(int lastSessionRefresh) { - this.lastSessionRefresh = lastSessionRefresh; - } + boolean isOffline(); - public boolean isOffline() { - return offline; - } + void setOffline(boolean offline); - public void setOffline(boolean offline) { - this.offline = offline; - } + String getData(); - public String getData() { - return data; - } + void setData(String data); + + void setRealmId(String realmId); + + void setUserId(String userId); + + void setBrokerSessionId(String brokerSessionId); - public void setData(String data) { - this.data = data; - } } diff --git a/model/storage-private/src/main/java/org/keycloak/models/session/UserSessionPersisterProvider.java b/model/storage-private/src/main/java/org/keycloak/models/session/UserSessionPersisterProvider.java index c254981923..6829b81735 100644 --- a/model/storage-private/src/main/java/org/keycloak/models/session/UserSessionPersisterProvider.java +++ b/model/storage-private/src/main/java/org/keycloak/models/session/UserSessionPersisterProvider.java @@ -86,6 +86,10 @@ public interface UserSessionPersisterProvider extends Provider { */ Stream loadUserSessionsStream(RealmModel realm, ClientModel client, boolean offline, Integer firstResult, Integer maxResults); + default UserSessionModel loadUserSessionsStreamByBrokerSessionId(RealmModel realm, String brokerSessionId, boolean offline) { + throw new IllegalStateException("not implemented"); + } + /** * Called during startup. For each userSession, it loads also clientSessions. * @param firstResult {@code Integer} Index of the first desired user session. Ignored if negative or {@code null}. @@ -135,4 +139,12 @@ public interface UserSessionPersisterProvider extends Provider { */ Map getUserSessionsCountsByClients(RealmModel realm, boolean offline); + /** + * Remove the online user sessions for this realm. + */ + default void removeUserSessions(RealmModel realm, boolean offline) { + throw new IllegalArgumentException("not supported"); + // TODO: remove default implementation + } + } diff --git a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/FeaturesDistTest.java b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/FeaturesDistTest.java index bb75fae7dd..4533702cd4 100644 --- a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/FeaturesDistTest.java +++ b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/FeaturesDistTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestMethodOrder; import org.junit.jupiter.api.condition.EnabledOnOs; import org.junit.jupiter.api.condition.OS; +import org.keycloak.common.Profile; import org.keycloak.it.junit5.extension.CLIResult; import org.keycloak.it.junit5.extension.DistributionTest; import org.keycloak.it.junit5.extension.RawDistOnly; @@ -16,6 +17,9 @@ import org.keycloak.quarkus.runtime.cli.command.Build; import org.keycloak.quarkus.runtime.cli.command.Start; import org.keycloak.quarkus.runtime.cli.command.StartDev; +import java.util.Arrays; +import java.util.stream.Collectors; + import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -26,7 +30,11 @@ import static org.keycloak.quarkus.runtime.cli.command.AbstractStartCommand.OPTI @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class FeaturesDistTest { - private static final String PREVIEW_FEATURES_EXPECTED_LOG = "Preview features enabled: admin-fine-grained-authz:v1, client-secret-rotation:v1, dpop:v1, recovery-codes:v1, scripts:v1, token-exchange:v1, update-email:v1"; + private static final String PREVIEW_FEATURES_EXPECTED_LOG = "Preview features enabled: " + Arrays.stream(Profile.Feature.values()) + .filter(feature -> feature.getType() == Profile.Feature.Type.PREVIEW) + .map(Profile.Feature::getVersionedKey) + .sorted() + .collect(Collectors.joining(", ")); @Test public void testEnableOnBuild(KeycloakDistribution dist) { @@ -102,6 +110,7 @@ public class FeaturesDistTest { } private void assertPreviewFeaturesEnabled(CLIResult result) { + assertThat("expecting at least one preview feature on the list", PREVIEW_FEATURES_EXPECTED_LOG, containsString(":")); assertThat(result.getOutput(), CoreMatchers.allOf( containsString(PREVIEW_FEATURES_EXPECTED_LOG))); } diff --git a/server-spi-private/src/main/java/org/keycloak/models/OfflineUserSessionModel.java b/server-spi-private/src/main/java/org/keycloak/models/OfflineUserSessionModel.java index 7ffae7d7d6..03b37ee61e 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/OfflineUserSessionModel.java +++ b/server-spi-private/src/main/java/org/keycloak/models/OfflineUserSessionModel.java @@ -24,4 +24,6 @@ package org.keycloak.models; */ public interface OfflineUserSessionModel extends UserSessionModel { public String getUserId(); + + void setLoginUsername(String loginUsername); } diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/RealmModelDelegate.java b/server-spi-private/src/main/java/org/keycloak/models/utils/RealmModelDelegate.java new file mode 100644 index 0000000000..6353ef991d --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/RealmModelDelegate.java @@ -0,0 +1,1123 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.utils; + +import org.keycloak.common.enums.SslRequired; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.AuthenticationFlowModel; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.CibaConfig; +import org.keycloak.models.ClientInitialAccessModel; +import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientScopeModel; +import org.keycloak.models.GroupModel; +import org.keycloak.models.IdentityProviderMapperModel; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.OAuth2DeviceConfig; +import org.keycloak.models.OTPPolicy; +import org.keycloak.models.ParConfig; +import org.keycloak.models.PasswordPolicy; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RequiredActionProviderModel; +import org.keycloak.models.RequiredCredentialModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.WebAuthnPolicy; +import org.keycloak.provider.Provider; + +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +/** + * @author Alexander Schwartz + */ +public class RealmModelDelegate implements RealmModel { + private RealmModel delegate; + + public RealmModelDelegate(RealmModel delegate) { + this.delegate = delegate; + } + + public String getId() { + return delegate.getId(); + } + + public String getName() { + return delegate.getName(); + } + + public void setName(String name) { + delegate.setName(name); + } + + public String getDisplayName() { + return delegate.getDisplayName(); + } + + public void setDisplayName(String displayName) { + delegate.setDisplayName(displayName); + } + + public String getDisplayNameHtml() { + return delegate.getDisplayNameHtml(); + } + + public void setDisplayNameHtml(String displayNameHtml) { + delegate.setDisplayNameHtml(displayNameHtml); + } + + public boolean isEnabled() { + return delegate.isEnabled(); + } + + public void setEnabled(boolean enabled) { + delegate.setEnabled(enabled); + } + + public SslRequired getSslRequired() { + return delegate.getSslRequired(); + } + + public void setSslRequired(SslRequired sslRequired) { + delegate.setSslRequired(sslRequired); + } + + public boolean isRegistrationAllowed() { + return delegate.isRegistrationAllowed(); + } + + public void setRegistrationAllowed(boolean registrationAllowed) { + delegate.setRegistrationAllowed(registrationAllowed); + } + + public boolean isRegistrationEmailAsUsername() { + return delegate.isRegistrationEmailAsUsername(); + } + + public void setRegistrationEmailAsUsername(boolean registrationEmailAsUsername) { + delegate.setRegistrationEmailAsUsername(registrationEmailAsUsername); + } + + public boolean isRememberMe() { + return delegate.isRememberMe(); + } + + public void setRememberMe(boolean rememberMe) { + delegate.setRememberMe(rememberMe); + } + + public boolean isEditUsernameAllowed() { + return delegate.isEditUsernameAllowed(); + } + + public void setEditUsernameAllowed(boolean editUsernameAllowed) { + delegate.setEditUsernameAllowed(editUsernameAllowed); + } + + public boolean isUserManagedAccessAllowed() { + return delegate.isUserManagedAccessAllowed(); + } + + public void setUserManagedAccessAllowed(boolean userManagedAccessAllowed) { + delegate.setUserManagedAccessAllowed(userManagedAccessAllowed); + } + + public void setAttribute(String name, String value) { + delegate.setAttribute(name, value); + } + + public void setAttribute(String name, Boolean value) { + delegate.setAttribute(name, value); + } + + public void setAttribute(String name, Integer value) { + delegate.setAttribute(name, value); + } + + public void setAttribute(String name, Long value) { + delegate.setAttribute(name, value); + } + + public void removeAttribute(String name) { + delegate.removeAttribute(name); + } + + public String getAttribute(String name) { + return delegate.getAttribute(name); + } + + public Integer getAttribute(String name, Integer defaultValue) { + return delegate.getAttribute(name, defaultValue); + } + + public Long getAttribute(String name, Long defaultValue) { + return delegate.getAttribute(name, defaultValue); + } + + public Boolean getAttribute(String name, Boolean defaultValue) { + return delegate.getAttribute(name, defaultValue); + } + + public Map getAttributes() { + return delegate.getAttributes(); + } + + public boolean isBruteForceProtected() { + return delegate.isBruteForceProtected(); + } + + public void setBruteForceProtected(boolean value) { + delegate.setBruteForceProtected(value); + } + + public boolean isPermanentLockout() { + return delegate.isPermanentLockout(); + } + + public void setPermanentLockout(boolean val) { + delegate.setPermanentLockout(val); + } + + public int getMaxTemporaryLockouts() { + return delegate.getMaxTemporaryLockouts(); + } + + public void setMaxTemporaryLockouts(int val) { + delegate.setMaxTemporaryLockouts(val); + } + + public int getMaxFailureWaitSeconds() { + return delegate.getMaxFailureWaitSeconds(); + } + + public void setMaxFailureWaitSeconds(int val) { + delegate.setMaxFailureWaitSeconds(val); + } + + public int getWaitIncrementSeconds() { + return delegate.getWaitIncrementSeconds(); + } + + public void setWaitIncrementSeconds(int val) { + delegate.setWaitIncrementSeconds(val); + } + + public int getMinimumQuickLoginWaitSeconds() { + return delegate.getMinimumQuickLoginWaitSeconds(); + } + + public void setMinimumQuickLoginWaitSeconds(int val) { + delegate.setMinimumQuickLoginWaitSeconds(val); + } + + public long getQuickLoginCheckMilliSeconds() { + return delegate.getQuickLoginCheckMilliSeconds(); + } + + public void setQuickLoginCheckMilliSeconds(long val) { + delegate.setQuickLoginCheckMilliSeconds(val); + } + + public int getMaxDeltaTimeSeconds() { + return delegate.getMaxDeltaTimeSeconds(); + } + + public void setMaxDeltaTimeSeconds(int val) { + delegate.setMaxDeltaTimeSeconds(val); + } + + public int getFailureFactor() { + return delegate.getFailureFactor(); + } + + public void setFailureFactor(int failureFactor) { + delegate.setFailureFactor(failureFactor); + } + + public boolean isVerifyEmail() { + return delegate.isVerifyEmail(); + } + + public void setVerifyEmail(boolean verifyEmail) { + delegate.setVerifyEmail(verifyEmail); + } + + public boolean isLoginWithEmailAllowed() { + return delegate.isLoginWithEmailAllowed(); + } + + public void setLoginWithEmailAllowed(boolean loginWithEmailAllowed) { + delegate.setLoginWithEmailAllowed(loginWithEmailAllowed); + } + + public boolean isDuplicateEmailsAllowed() { + return delegate.isDuplicateEmailsAllowed(); + } + + public void setDuplicateEmailsAllowed(boolean duplicateEmailsAllowed) { + delegate.setDuplicateEmailsAllowed(duplicateEmailsAllowed); + } + + public boolean isResetPasswordAllowed() { + return delegate.isResetPasswordAllowed(); + } + + public void setResetPasswordAllowed(boolean resetPasswordAllowed) { + delegate.setResetPasswordAllowed(resetPasswordAllowed); + } + + public String getDefaultSignatureAlgorithm() { + return delegate.getDefaultSignatureAlgorithm(); + } + + public void setDefaultSignatureAlgorithm(String defaultSignatureAlgorithm) { + delegate.setDefaultSignatureAlgorithm(defaultSignatureAlgorithm); + } + + public boolean isRevokeRefreshToken() { + return delegate.isRevokeRefreshToken(); + } + + public void setRevokeRefreshToken(boolean revokeRefreshToken) { + delegate.setRevokeRefreshToken(revokeRefreshToken); + } + + public int getRefreshTokenMaxReuse() { + return delegate.getRefreshTokenMaxReuse(); + } + + public void setRefreshTokenMaxReuse(int revokeRefreshTokenCount) { + delegate.setRefreshTokenMaxReuse(revokeRefreshTokenCount); + } + + public int getSsoSessionIdleTimeout() { + return delegate.getSsoSessionIdleTimeout(); + } + + public void setSsoSessionIdleTimeout(int seconds) { + delegate.setSsoSessionIdleTimeout(seconds); + } + + public int getSsoSessionMaxLifespan() { + return delegate.getSsoSessionMaxLifespan(); + } + + public void setSsoSessionMaxLifespan(int seconds) { + delegate.setSsoSessionMaxLifespan(seconds); + } + + public int getSsoSessionIdleTimeoutRememberMe() { + return delegate.getSsoSessionIdleTimeoutRememberMe(); + } + + public void setSsoSessionIdleTimeoutRememberMe(int seconds) { + delegate.setSsoSessionIdleTimeoutRememberMe(seconds); + } + + public int getSsoSessionMaxLifespanRememberMe() { + return delegate.getSsoSessionMaxLifespanRememberMe(); + } + + public void setSsoSessionMaxLifespanRememberMe(int seconds) { + delegate.setSsoSessionMaxLifespanRememberMe(seconds); + } + + public int getOfflineSessionIdleTimeout() { + return delegate.getOfflineSessionIdleTimeout(); + } + + public void setOfflineSessionIdleTimeout(int seconds) { + delegate.setOfflineSessionIdleTimeout(seconds); + } + + public int getAccessTokenLifespan() { + return delegate.getAccessTokenLifespan(); + } + + public boolean isOfflineSessionMaxLifespanEnabled() { + return delegate.isOfflineSessionMaxLifespanEnabled(); + } + + public void setOfflineSessionMaxLifespanEnabled(boolean offlineSessionMaxLifespanEnabled) { + delegate.setOfflineSessionMaxLifespanEnabled(offlineSessionMaxLifespanEnabled); + } + + public int getOfflineSessionMaxLifespan() { + return delegate.getOfflineSessionMaxLifespan(); + } + + public void setOfflineSessionMaxLifespan(int seconds) { + delegate.setOfflineSessionMaxLifespan(seconds); + } + + public int getClientSessionIdleTimeout() { + return delegate.getClientSessionIdleTimeout(); + } + + public void setClientSessionIdleTimeout(int seconds) { + delegate.setClientSessionIdleTimeout(seconds); + } + + public int getClientSessionMaxLifespan() { + return delegate.getClientSessionMaxLifespan(); + } + + public void setClientSessionMaxLifespan(int seconds) { + delegate.setClientSessionMaxLifespan(seconds); + } + + public int getClientOfflineSessionIdleTimeout() { + return delegate.getClientOfflineSessionIdleTimeout(); + } + + public void setClientOfflineSessionIdleTimeout(int seconds) { + delegate.setClientOfflineSessionIdleTimeout(seconds); + } + + public int getClientOfflineSessionMaxLifespan() { + return delegate.getClientOfflineSessionMaxLifespan(); + } + + public void setClientOfflineSessionMaxLifespan(int seconds) { + delegate.setClientOfflineSessionMaxLifespan(seconds); + } + + public void setAccessTokenLifespan(int seconds) { + delegate.setAccessTokenLifespan(seconds); + } + + public int getAccessTokenLifespanForImplicitFlow() { + return delegate.getAccessTokenLifespanForImplicitFlow(); + } + + public void setAccessTokenLifespanForImplicitFlow(int seconds) { + delegate.setAccessTokenLifespanForImplicitFlow(seconds); + } + + public int getAccessCodeLifespan() { + return delegate.getAccessCodeLifespan(); + } + + public void setAccessCodeLifespan(int seconds) { + delegate.setAccessCodeLifespan(seconds); + } + + public int getAccessCodeLifespanUserAction() { + return delegate.getAccessCodeLifespanUserAction(); + } + + public void setAccessCodeLifespanUserAction(int seconds) { + delegate.setAccessCodeLifespanUserAction(seconds); + } + + public OAuth2DeviceConfig getOAuth2DeviceConfig() { + return delegate.getOAuth2DeviceConfig(); + } + + public CibaConfig getCibaPolicy() { + return delegate.getCibaPolicy(); + } + + public ParConfig getParPolicy() { + return delegate.getParPolicy(); + } + + public Map getUserActionTokenLifespans() { + return delegate.getUserActionTokenLifespans(); + } + + public int getAccessCodeLifespanLogin() { + return delegate.getAccessCodeLifespanLogin(); + } + + public void setAccessCodeLifespanLogin(int seconds) { + delegate.setAccessCodeLifespanLogin(seconds); + } + + public int getActionTokenGeneratedByAdminLifespan() { + return delegate.getActionTokenGeneratedByAdminLifespan(); + } + + public void setActionTokenGeneratedByAdminLifespan(int seconds) { + delegate.setActionTokenGeneratedByAdminLifespan(seconds); + } + + public int getActionTokenGeneratedByUserLifespan() { + return delegate.getActionTokenGeneratedByUserLifespan(); + } + + public void setActionTokenGeneratedByUserLifespan(int seconds) { + delegate.setActionTokenGeneratedByUserLifespan(seconds); + } + + public int getActionTokenGeneratedByUserLifespan(String actionTokenType) { + return delegate.getActionTokenGeneratedByUserLifespan(actionTokenType); + } + + public void setActionTokenGeneratedByUserLifespan(String actionTokenType, Integer seconds) { + delegate.setActionTokenGeneratedByUserLifespan(actionTokenType, seconds); + } + + public Stream getRequiredCredentialsStream() { + return delegate.getRequiredCredentialsStream(); + } + + public void addRequiredCredential(String cred) { + delegate.addRequiredCredential(cred); + } + + public PasswordPolicy getPasswordPolicy() { + return delegate.getPasswordPolicy(); + } + + public void setPasswordPolicy(PasswordPolicy policy) { + delegate.setPasswordPolicy(policy); + } + + public OTPPolicy getOTPPolicy() { + return delegate.getOTPPolicy(); + } + + public void setOTPPolicy(OTPPolicy policy) { + delegate.setOTPPolicy(policy); + } + + public WebAuthnPolicy getWebAuthnPolicy() { + return delegate.getWebAuthnPolicy(); + } + + public void setWebAuthnPolicy(WebAuthnPolicy policy) { + delegate.setWebAuthnPolicy(policy); + } + + public WebAuthnPolicy getWebAuthnPolicyPasswordless() { + return delegate.getWebAuthnPolicyPasswordless(); + } + + public void setWebAuthnPolicyPasswordless(WebAuthnPolicy policy) { + delegate.setWebAuthnPolicyPasswordless(policy); + } + + public RoleModel getRoleById(String id) { + return delegate.getRoleById(id); + } + + public Stream getDefaultGroupsStream() { + return delegate.getDefaultGroupsStream(); + } + + public void addDefaultGroup(GroupModel group) { + delegate.addDefaultGroup(group); + } + + public void removeDefaultGroup(GroupModel group) { + delegate.removeDefaultGroup(group); + } + + public Stream getClientsStream() { + return delegate.getClientsStream(); + } + + public Stream getClientsStream(Integer firstResult, Integer maxResults) { + return delegate.getClientsStream(firstResult, maxResults); + } + + public Long getClientsCount() { + return delegate.getClientsCount(); + } + + public Stream getAlwaysDisplayInConsoleClientsStream() { + return delegate.getAlwaysDisplayInConsoleClientsStream(); + } + + public ClientModel addClient(String name) { + return delegate.addClient(name); + } + + public ClientModel addClient(String id, String clientId) { + return delegate.addClient(id, clientId); + } + + public boolean removeClient(String id) { + return delegate.removeClient(id); + } + + public ClientModel getClientById(String id) { + return delegate.getClientById(id); + } + + public ClientModel getClientByClientId(String clientId) { + return delegate.getClientByClientId(clientId); + } + + public Stream searchClientByClientIdStream(String clientId, Integer firstResult, Integer maxResults) { + return delegate.searchClientByClientIdStream(clientId, firstResult, maxResults); + } + + public Stream searchClientByAttributes(Map attributes, Integer firstResult, Integer maxResults) { + return delegate.searchClientByAttributes(attributes, firstResult, maxResults); + } + + public Stream searchClientByAuthenticationFlowBindingOverrides(Map overrides, Integer firstResult, Integer maxResults) { + return delegate.searchClientByAuthenticationFlowBindingOverrides(overrides, firstResult, maxResults); + } + + public void updateRequiredCredentials(Set creds) { + delegate.updateRequiredCredentials(creds); + } + + public Map getBrowserSecurityHeaders() { + return delegate.getBrowserSecurityHeaders(); + } + + public void setBrowserSecurityHeaders(Map headers) { + delegate.setBrowserSecurityHeaders(headers); + } + + public Map getSmtpConfig() { + return delegate.getSmtpConfig(); + } + + public void setSmtpConfig(Map smtpConfig) { + delegate.setSmtpConfig(smtpConfig); + } + + public AuthenticationFlowModel getBrowserFlow() { + return delegate.getBrowserFlow(); + } + + public void setBrowserFlow(AuthenticationFlowModel flow) { + delegate.setBrowserFlow(flow); + } + + public AuthenticationFlowModel getRegistrationFlow() { + return delegate.getRegistrationFlow(); + } + + public void setRegistrationFlow(AuthenticationFlowModel flow) { + delegate.setRegistrationFlow(flow); + } + + public AuthenticationFlowModel getDirectGrantFlow() { + return delegate.getDirectGrantFlow(); + } + + public void setDirectGrantFlow(AuthenticationFlowModel flow) { + delegate.setDirectGrantFlow(flow); + } + + public AuthenticationFlowModel getResetCredentialsFlow() { + return delegate.getResetCredentialsFlow(); + } + + public void setResetCredentialsFlow(AuthenticationFlowModel flow) { + delegate.setResetCredentialsFlow(flow); + } + + public AuthenticationFlowModel getClientAuthenticationFlow() { + return delegate.getClientAuthenticationFlow(); + } + + public void setClientAuthenticationFlow(AuthenticationFlowModel flow) { + delegate.setClientAuthenticationFlow(flow); + } + + public AuthenticationFlowModel getDockerAuthenticationFlow() { + return delegate.getDockerAuthenticationFlow(); + } + + public void setDockerAuthenticationFlow(AuthenticationFlowModel flow) { + delegate.setDockerAuthenticationFlow(flow); + } + + public AuthenticationFlowModel getFirstBrokerLoginFlow() { + return delegate.getFirstBrokerLoginFlow(); + } + + public void setFirstBrokerLoginFlow(AuthenticationFlowModel flow) { + delegate.setFirstBrokerLoginFlow(flow); + } + + public Stream getAuthenticationFlowsStream() { + return delegate.getAuthenticationFlowsStream(); + } + + public AuthenticationFlowModel getFlowByAlias(String alias) { + return delegate.getFlowByAlias(alias); + } + + public AuthenticationFlowModel addAuthenticationFlow(AuthenticationFlowModel model) { + return delegate.addAuthenticationFlow(model); + } + + public AuthenticationFlowModel getAuthenticationFlowById(String id) { + return delegate.getAuthenticationFlowById(id); + } + + public void removeAuthenticationFlow(AuthenticationFlowModel model) { + delegate.removeAuthenticationFlow(model); + } + + public void updateAuthenticationFlow(AuthenticationFlowModel model) { + delegate.updateAuthenticationFlow(model); + } + + public Stream getAuthenticationExecutionsStream(String flowId) { + return delegate.getAuthenticationExecutionsStream(flowId); + } + + public AuthenticationExecutionModel getAuthenticationExecutionById(String id) { + return delegate.getAuthenticationExecutionById(id); + } + + public AuthenticationExecutionModel getAuthenticationExecutionByFlowId(String flowId) { + return delegate.getAuthenticationExecutionByFlowId(flowId); + } + + public AuthenticationExecutionModel addAuthenticatorExecution(AuthenticationExecutionModel model) { + return delegate.addAuthenticatorExecution(model); + } + + public void updateAuthenticatorExecution(AuthenticationExecutionModel model) { + delegate.updateAuthenticatorExecution(model); + } + + public void removeAuthenticatorExecution(AuthenticationExecutionModel model) { + delegate.removeAuthenticatorExecution(model); + } + + public Stream getAuthenticatorConfigsStream() { + return delegate.getAuthenticatorConfigsStream(); + } + + public AuthenticatorConfigModel addAuthenticatorConfig(AuthenticatorConfigModel model) { + return delegate.addAuthenticatorConfig(model); + } + + public void updateAuthenticatorConfig(AuthenticatorConfigModel model) { + delegate.updateAuthenticatorConfig(model); + } + + public void removeAuthenticatorConfig(AuthenticatorConfigModel model) { + delegate.removeAuthenticatorConfig(model); + } + + public AuthenticatorConfigModel getAuthenticatorConfigById(String id) { + return delegate.getAuthenticatorConfigById(id); + } + + public AuthenticatorConfigModel getAuthenticatorConfigByAlias(String alias) { + return delegate.getAuthenticatorConfigByAlias(alias); + } + + public Stream getRequiredActionProvidersStream() { + return delegate.getRequiredActionProvidersStream(); + } + + public RequiredActionProviderModel addRequiredActionProvider(RequiredActionProviderModel model) { + return delegate.addRequiredActionProvider(model); + } + + public void updateRequiredActionProvider(RequiredActionProviderModel model) { + delegate.updateRequiredActionProvider(model); + } + + public void removeRequiredActionProvider(RequiredActionProviderModel model) { + delegate.removeRequiredActionProvider(model); + } + + public RequiredActionProviderModel getRequiredActionProviderById(String id) { + return delegate.getRequiredActionProviderById(id); + } + + public RequiredActionProviderModel getRequiredActionProviderByAlias(String alias) { + return delegate.getRequiredActionProviderByAlias(alias); + } + + public Stream getIdentityProvidersStream() { + return delegate.getIdentityProvidersStream(); + } + + public IdentityProviderModel getIdentityProviderByAlias(String alias) { + return delegate.getIdentityProviderByAlias(alias); + } + + public void addIdentityProvider(IdentityProviderModel identityProvider) { + delegate.addIdentityProvider(identityProvider); + } + + public void removeIdentityProviderByAlias(String alias) { + delegate.removeIdentityProviderByAlias(alias); + } + + public void updateIdentityProvider(IdentityProviderModel identityProvider) { + delegate.updateIdentityProvider(identityProvider); + } + + public Stream getIdentityProviderMappersStream() { + return delegate.getIdentityProviderMappersStream(); + } + + public Stream getIdentityProviderMappersByAliasStream(String brokerAlias) { + return delegate.getIdentityProviderMappersByAliasStream(brokerAlias); + } + + public IdentityProviderMapperModel addIdentityProviderMapper(IdentityProviderMapperModel model) { + return delegate.addIdentityProviderMapper(model); + } + + public void removeIdentityProviderMapper(IdentityProviderMapperModel mapping) { + delegate.removeIdentityProviderMapper(mapping); + } + + public void updateIdentityProviderMapper(IdentityProviderMapperModel mapping) { + delegate.updateIdentityProviderMapper(mapping); + } + + public IdentityProviderMapperModel getIdentityProviderMapperById(String id) { + return delegate.getIdentityProviderMapperById(id); + } + + public IdentityProviderMapperModel getIdentityProviderMapperByName(String brokerAlias, String name) { + return delegate.getIdentityProviderMapperByName(brokerAlias, name); + } + + public ComponentModel addComponentModel(ComponentModel model) { + return delegate.addComponentModel(model); + } + + public ComponentModel importComponentModel(ComponentModel model) { + return delegate.importComponentModel(model); + } + + public void updateComponent(ComponentModel component) { + delegate.updateComponent(component); + } + + public void removeComponent(ComponentModel component) { + delegate.removeComponent(component); + } + + public void removeComponents(String parentId) { + delegate.removeComponents(parentId); + } + + public Stream getComponentsStream(String parentId, String providerType) { + return delegate.getComponentsStream(parentId, providerType); + } + + public Stream getComponentsStream(String parentId) { + return delegate.getComponentsStream(parentId); + } + + public Stream getComponentsStream() { + return delegate.getComponentsStream(); + } + + public ComponentModel getComponent(String id) { + return delegate.getComponent(id); + } + + public Stream getStorageProviders(Class storageProviderClass) { + return delegate.getStorageProviders(storageProviderClass); + } + + public String getLoginTheme() { + return delegate.getLoginTheme(); + } + + public void setLoginTheme(String name) { + delegate.setLoginTheme(name); + } + + public String getAccountTheme() { + return delegate.getAccountTheme(); + } + + public void setAccountTheme(String name) { + delegate.setAccountTheme(name); + } + + public String getAdminTheme() { + return delegate.getAdminTheme(); + } + + public void setAdminTheme(String name) { + delegate.setAdminTheme(name); + } + + public String getEmailTheme() { + return delegate.getEmailTheme(); + } + + public void setEmailTheme(String name) { + delegate.setEmailTheme(name); + } + + public int getNotBefore() { + return delegate.getNotBefore(); + } + + public void setNotBefore(int notBefore) { + delegate.setNotBefore(notBefore); + } + + public boolean isEventsEnabled() { + return delegate.isEventsEnabled(); + } + + public void setEventsEnabled(boolean enabled) { + delegate.setEventsEnabled(enabled); + } + + public long getEventsExpiration() { + return delegate.getEventsExpiration(); + } + + public void setEventsExpiration(long expiration) { + delegate.setEventsExpiration(expiration); + } + + public Stream getEventsListenersStream() { + return delegate.getEventsListenersStream(); + } + + public void setEventsListeners(Set listeners) { + delegate.setEventsListeners(listeners); + } + + public Stream getEnabledEventTypesStream() { + return delegate.getEnabledEventTypesStream(); + } + + public void setEnabledEventTypes(Set enabledEventTypes) { + delegate.setEnabledEventTypes(enabledEventTypes); + } + + public boolean isAdminEventsEnabled() { + return delegate.isAdminEventsEnabled(); + } + + public void setAdminEventsEnabled(boolean enabled) { + delegate.setAdminEventsEnabled(enabled); + } + + public boolean isAdminEventsDetailsEnabled() { + return delegate.isAdminEventsDetailsEnabled(); + } + + public void setAdminEventsDetailsEnabled(boolean enabled) { + delegate.setAdminEventsDetailsEnabled(enabled); + } + + public ClientModel getMasterAdminClient() { + return delegate.getMasterAdminClient(); + } + + public void setMasterAdminClient(ClientModel client) { + delegate.setMasterAdminClient(client); + } + + public RoleModel getDefaultRole() { + return delegate.getDefaultRole(); + } + + public void setDefaultRole(RoleModel role) { + delegate.setDefaultRole(role); + } + + public boolean isIdentityFederationEnabled() { + return delegate.isIdentityFederationEnabled(); + } + + public boolean isInternationalizationEnabled() { + return delegate.isInternationalizationEnabled(); + } + + public void setInternationalizationEnabled(boolean enabled) { + delegate.setInternationalizationEnabled(enabled); + } + + public Stream getSupportedLocalesStream() { + return delegate.getSupportedLocalesStream(); + } + + public void setSupportedLocales(Set locales) { + delegate.setSupportedLocales(locales); + } + + public String getDefaultLocale() { + return delegate.getDefaultLocale(); + } + + public void setDefaultLocale(String locale) { + delegate.setDefaultLocale(locale); + } + + public GroupModel createGroup(String name) { + return delegate.createGroup(name); + } + + public GroupModel createGroup(String id, String name) { + return delegate.createGroup(id, name); + } + + public GroupModel createGroup(String name, GroupModel toParent) { + return delegate.createGroup(name, toParent); + } + + public GroupModel createGroup(String id, String name, GroupModel toParent) { + return delegate.createGroup(id, name, toParent); + } + + public GroupModel getGroupById(String id) { + return delegate.getGroupById(id); + } + + public Stream getGroupsStream() { + return delegate.getGroupsStream(); + } + + public Long getGroupsCount(Boolean onlyTopGroups) { + return delegate.getGroupsCount(onlyTopGroups); + } + + public Long getGroupsCountByNameContaining(String search) { + return delegate.getGroupsCountByNameContaining(search); + } + + @Deprecated + public Stream getTopLevelGroupsStream() { + return delegate.getTopLevelGroupsStream(); + } + + @Deprecated + public Stream getTopLevelGroupsStream(Integer first, Integer max) { + return delegate.getTopLevelGroupsStream(first, max); + } + + public boolean removeGroup(GroupModel group) { + return delegate.removeGroup(group); + } + + public void moveGroup(GroupModel group, GroupModel toParent) { + delegate.moveGroup(group, toParent); + } + + public Stream getClientScopesStream() { + return delegate.getClientScopesStream(); + } + + public ClientScopeModel addClientScope(String name) { + return delegate.addClientScope(name); + } + + public ClientScopeModel addClientScope(String id, String name) { + return delegate.addClientScope(id, name); + } + + public boolean removeClientScope(String id) { + return delegate.removeClientScope(id); + } + + public ClientScopeModel getClientScopeById(String id) { + return delegate.getClientScopeById(id); + } + + public void addDefaultClientScope(ClientScopeModel clientScope, boolean defaultScope) { + delegate.addDefaultClientScope(clientScope, defaultScope); + } + + public void removeDefaultClientScope(ClientScopeModel clientScope) { + delegate.removeDefaultClientScope(clientScope); + } + + public void createOrUpdateRealmLocalizationTexts(String locale, Map localizationTexts) { + delegate.createOrUpdateRealmLocalizationTexts(locale, localizationTexts); + } + + public boolean removeRealmLocalizationTexts(String locale) { + return delegate.removeRealmLocalizationTexts(locale); + } + + public Map> getRealmLocalizationTexts() { + return delegate.getRealmLocalizationTexts(); + } + + public Map getRealmLocalizationTextsByLocale(String locale) { + return delegate.getRealmLocalizationTextsByLocale(locale); + } + + public Stream getDefaultClientScopesStream(boolean defaultScope) { + return delegate.getDefaultClientScopesStream(defaultScope); + } + + public void addToDefaultRoles(RoleModel role) { + delegate.addToDefaultRoles(role); + } + + public ClientInitialAccessModel createClientInitialAccessModel(int expiration, int count) { + return delegate.createClientInitialAccessModel(expiration, count); + } + + public ClientInitialAccessModel getClientInitialAccessModel(String id) { + return delegate.getClientInitialAccessModel(id); + } + + public void removeClientInitialAccessModel(String id) { + delegate.removeClientInitialAccessModel(id); + } + + public Stream getClientInitialAccesses() { + return delegate.getClientInitialAccesses(); + } + + public void decreaseRemainingCount(ClientInitialAccessModel clientInitialAccess) { + delegate.decreaseRemainingCount(clientInitialAccess); + } + + public RoleModel getRole(String name) { + return delegate.getRole(name); + } + + public RoleModel addRole(String name) { + return delegate.addRole(name); + } + + public RoleModel addRole(String id, String name) { + return delegate.addRole(id, name); + } + + public boolean removeRole(RoleModel role) { + return delegate.removeRole(role); + } + + public Stream getRolesStream() { + return delegate.getRolesStream(); + } + + public Stream getRolesStream(Integer firstResult, Integer maxResults) { + return delegate.getRolesStream(firstResult, maxResults); + } + + public Stream searchForRolesStream(String search, Integer first, Integer max) { + return delegate.searchForRolesStream(search, first, max); + } + +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/UserSessionModelDelegate.java b/server-spi-private/src/main/java/org/keycloak/models/utils/UserSessionModelDelegate.java new file mode 100644 index 0000000000..fe6d8c751d --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/UserSessionModelDelegate.java @@ -0,0 +1,134 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.utils; + +import org.keycloak.models.AuthenticatedClientSessionModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; + +import java.util.Collection; +import java.util.Map; + +/** + * @author Alexander Schwartz + */ +public class UserSessionModelDelegate implements UserSessionModel { + private UserSessionModel delegate; + + public UserSessionModelDelegate(UserSessionModel delegate) { + this.delegate = delegate; + } + + public String getId() { + return delegate.getId(); + } + + public RealmModel getRealm() { + return delegate.getRealm(); + } + + public String getBrokerSessionId() { + return delegate.getBrokerSessionId(); + } + + public String getBrokerUserId() { + return delegate.getBrokerUserId(); + } + + public UserModel getUser() { + return delegate.getUser(); + } + + public String getLoginUsername() { + return delegate.getLoginUsername(); + } + + public String getIpAddress() { + return delegate.getIpAddress(); + } + + public String getAuthMethod() { + return delegate.getAuthMethod(); + } + + public boolean isRememberMe() { + return delegate.isRememberMe(); + } + + public int getStarted() { + return delegate.getStarted(); + } + + public int getLastSessionRefresh() { + return delegate.getLastSessionRefresh(); + } + + public void setLastSessionRefresh(int seconds) { + delegate.setLastSessionRefresh(seconds); + } + + public boolean isOffline() { + return delegate.isOffline(); + } + + public Map getAuthenticatedClientSessions() { + return delegate.getAuthenticatedClientSessions(); + } + + public AuthenticatedClientSessionModel getAuthenticatedClientSessionByClient(String clientUUID) { + return delegate.getAuthenticatedClientSessionByClient(clientUUID); + } + + public void removeAuthenticatedClientSessions(Collection removedClientUUIDS) { + delegate.removeAuthenticatedClientSessions(removedClientUUIDS); + } + + public String getNote(String name) { + return delegate.getNote(name); + } + + public void setNote(String name, String value) { + delegate.setNote(name, value); + } + + public void removeNote(String name) { + delegate.removeNote(name); + } + + public Map getNotes() { + return delegate.getNotes(); + } + + public UserSessionModel.State getState() { + return delegate.getState(); + } + + public void setState(UserSessionModel.State state) { + delegate.setState(state); + } + + public void restartSession(RealmModel realm, UserModel user, String loginUsername, String ipAddress, String authMethod, boolean rememberMe, String brokerSessionId, String brokerUserId) { + delegate.restartSession(realm, user, loginUsername, ipAddress, authMethod, rememberMe, brokerSessionId, brokerUserId); + } + + public UserSessionModel.SessionPersistenceState getPersistenceState() { + return delegate.getPersistenceState(); + } + +} diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/containers/AbstractQuarkusDeployableContainer.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/containers/AbstractQuarkusDeployableContainer.java index 67ef4456e3..deb57ee126 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/containers/AbstractQuarkusDeployableContainer.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/containers/AbstractQuarkusDeployableContainer.java @@ -193,8 +193,7 @@ public abstract class AbstractQuarkusDeployableContainer implements DeployableCo // only run build during first execution of the server (if the DB is specified), restarts or when running cluster tests if (restart.get() || "ha".equals(cacheMode) || shouldSetUpDb.get() || configuration.getFipsMode() != FipsMode.DISABLED) { - commands.removeIf("--optimized"::equals); - commands.add("--http-relative-path=/auth"); + prepareCommandsForRebuilding(commands); if ("local".equals(cacheMode)) { commands.add("--cache=local"); @@ -213,6 +212,15 @@ public abstract class AbstractQuarkusDeployableContainer implements DeployableCo return commands; } + /** + * When enabling automatic rebuilding of the image, the `--optimized` argument must be removed, + * and all original build time parameters must be added. + */ + private static void prepareCommandsForRebuilding(List commands) { + commands.removeIf("--optimized"::equals); + commands.add("--http-relative-path=/auth"); + } + protected void addFeaturesOption(List commands) { String defaultFeatures = configuration.getDefaultFeatures(); @@ -238,6 +246,9 @@ public abstract class AbstractQuarkusDeployableContainer implements DeployableCo } } + // enabling or disabling features requires rebuilding the image + prepareCommandsForRebuilding(commands); + commands.add(featuresOption.toString()); } 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 c956c856df..ec7b23ac0d 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 @@ -304,6 +304,7 @@ public class UserSessionProviderTest extends AbstractTestRealmKeycloakTest { String userSessionId = KeycloakModelUtils.runJobInTransactionWithResult(session.getKeycloakSessionFactory(), kcSession -> { RealmModel realm = kcSession.realms().getRealmByName("test"); UserSessionModel userSession = createSessions(kcSession)[0]; + userSession = kcSession.sessions().getUserSession(realm, userSession.getId()); kcSession.sessions().removeUserSession(realm, userSession); return userSession.getId(); @@ -559,9 +560,11 @@ public class UserSessionProviderTest extends AbstractTestRealmKeycloakTest { public void testRemovingExpiredSession(KeycloakSession session) { UserSessionModel[] sessions = createSessions(session); try { - Time.setOffset(3600000); UserSessionModel userSession = sessions[0]; RealmModel realm = userSession.getRealm(); + // reload userSession in current session + userSession = session.sessions().getUserSession(realm, userSession.getId()); + Time.setOffset(3600000); session.sessions().removeExpired(realm); // Assert no exception is thrown here diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java index b50603c274..0a94ed0a32 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java @@ -522,6 +522,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { // Refresh with the offline token tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken(), "secret1"); + Assert.assertNull("received error " + tokenResponse.getError() + ", " + tokenResponse.getErrorDescription(), tokenResponse.getError()); // Use accessToken to admin REST request try (Keycloak offlineTokenAdmin = Keycloak.getInstance(getAuthServerContextRoot() + "/auth", diff --git a/testsuite/integration-arquillian/tests/base/testsuites/persistent-sessions-suite b/testsuite/integration-arquillian/tests/base/testsuites/persistent-sessions-suite new file mode 100644 index 0000000000..bd134a8056 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/testsuites/persistent-sessions-suite @@ -0,0 +1,22 @@ +SessionTest +KcSamlBrokerSessionNotOnOrAfterTest +OidcClaimToUserSessionNoteMapperTest +KcOidcBrokerTransientSessionsTest +KcAdmSessionTest +TransientSessionTest +UserSessionProviderOfflineTest +AuthenticationSessionProviderTest +UserSessionProviderTest +OAuthDanceClientSessionExtensionTest +SessionNotOnOrAfterTest +SessionTimeoutValidationTest +LastSessionRefreshUnitTest +KcOidcUserSessionLimitsBrokerTest +KcSamlUserSessionLimitsBrokerTest +AbstractUserSessionLimitsBrokerTest +UserSessionLimitsTest +ConcurrentLoginTest +RefreshTokenTest +OfflineTokenTest +AccessTokenTest +LogoutTest \ No newline at end of file diff --git a/testsuite/model/pom.xml b/testsuite/model/pom.xml index 80c3265b2c..4dc42c2e60 100644 --- a/testsuite/model/pom.xml +++ b/testsuite/model/pom.xml @@ -212,6 +212,13 @@ + + jpa+infinispan+persistentsessions + + Infinispan,Jpa,PersistentUserSessions + + + jpa+infinispan+client-storage diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/PersistentUserSessions.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/PersistentUserSessions.java new file mode 100644 index 0000000000..a1ae1187e5 --- /dev/null +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/PersistentUserSessions.java @@ -0,0 +1,41 @@ +/* + * 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.parameters; + +import org.keycloak.common.Profile; +import org.keycloak.common.profile.PropertiesProfileConfigResolver; +import org.keycloak.testsuite.model.Config; +import org.keycloak.testsuite.model.KeycloakModelParameters; + +import java.util.Collections; + +public class PersistentUserSessions extends KeycloakModelParameters { + + public PersistentUserSessions() { + super(Collections.emptySet(), Collections.emptySet()); + } + + @Override + public void updateConfig(Config cf) { + updateConfigForJpa(cf); + } + + public static void updateConfigForJpa(Config cf) { + System.getProperties().put(PropertiesProfileConfigResolver.getPropertyKey(Profile.Feature.PERSISTENT_USER_SESSIONS), "enabled"); + System.getProperties().put(PropertiesProfileConfigResolver.getPropertyKey(Profile.Feature.PERSISTENT_USER_SESSIONS_NO_CACHE), "enabled"); + } +} diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/OfflineSessionPersistenceTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/OfflineSessionPersistenceTest.java index 11e0228d7c..5ded5fbef3 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/OfflineSessionPersistenceTest.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/OfflineSessionPersistenceTest.java @@ -29,6 +29,7 @@ import org.keycloak.models.UserSessionModel; import org.keycloak.models.UserSessionProvider; import org.keycloak.models.session.UserSessionPersisterProvider; import org.keycloak.models.sessions.infinispan.InfinispanUserSessionProvider; +import org.keycloak.models.sessions.infinispan.PersistentUserSessionProvider; import org.keycloak.services.managers.RealmManager; import org.keycloak.testsuite.model.KeycloakModelTest; import org.keycloak.testsuite.model.RequireProvider; @@ -258,7 +259,13 @@ public class OfflineSessionPersistenceTest extends KeycloakModelTest { // Delete local user cache (persisted sessions are still kept) UserSessionProvider provider = session.getProvider(UserSessionProvider.class); // Remove in-memory representation of the offline sessions - ((InfinispanUserSessionProvider) provider).removeLocalUserSessions(realm.getId(), true); + if (provider instanceof InfinispanUserSessionProvider) { + ((InfinispanUserSessionProvider) provider).removeLocalUserSessions(realm.getId(), true); + } else if (provider instanceof PersistentUserSessionProvider) { + ((PersistentUserSessionProvider) provider).removeLocalUserSessions(realm.getId(), true); + } else { + throw new IllegalStateException("Unknown UserSessionProvider: " + provider); + } return null; }); diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionInitializerTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionInitializerTest.java index 2a9292c628..6fbf46901b 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionInitializerTest.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionInitializerTest.java @@ -17,10 +17,13 @@ package org.keycloak.testsuite.model.session; +import org.hamcrest.Matchers; import org.infinispan.Cache; import org.infinispan.client.hotrod.RemoteCache; import org.junit.Assert; +import org.junit.Assume; import org.junit.Test; +import org.keycloak.common.Profile; import org.keycloak.connections.infinispan.InfinispanConnectionProvider; import org.keycloak.models.ClientModel; import org.keycloak.models.Constants; @@ -155,6 +158,9 @@ public class UserSessionInitializerTest extends KeycloakModelTest { @Test public void testUserSessionPropagationBetweenSites() throws InterruptedException { + // When user sessions are not stored in the cache, this test doesn't make sense + Assume.assumeThat(Profile.isFeatureEnabled(Profile.Feature.PERSISTENT_USER_SESSIONS_NO_CACHE), Matchers.not(true)); + AtomicInteger index = new AtomicInteger(); AtomicReference userSessionId = new AtomicReference<>(); AtomicReference> containsSession = new AtomicReference<>(new LinkedList<>()); diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionPersisterProviderTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionPersisterProviderTest.java index 2f51d3d62e..271f164548 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionPersisterProviderTest.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionPersisterProviderTest.java @@ -20,6 +20,7 @@ package org.keycloak.testsuite.model.session; import org.junit.Assert; import org.junit.Test; import org.keycloak.OAuth2Constants; +import org.keycloak.common.Profile; import org.keycloak.common.util.Time; import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; @@ -144,18 +145,20 @@ public class UserSessionPersisterProviderTest extends KeycloakModelTest { .forEach(userSessionLooper -> persistUserSession(session, userSessionLooper, true)); }); - inComittedTransaction(session -> { - // Persist 1 online session - RealmModel realm = session.realms().getRealm(realmId); - userSession[0] = session.sessions().getUserSession(realm, origSessions[0].getId()); - persistUserSession(session, userSession[0], false); - }); + if (!Profile.isFeatureEnabled(Profile.Feature.PERSISTENT_USER_SESSIONS)) { + inComittedTransaction(session -> { + // Persist 1 online session + RealmModel realm = session.realms().getRealm(realmId); + userSession[0] = session.sessions().getUserSession(realm, origSessions[0].getId()); + persistUserSession(session, userSession[0], false); + }); - inComittedTransaction(session -> { // Assert online session - RealmModel realm = session.realms().getRealm(realmId); - List loadedSessions = loadPersistedSessionsPaginated(session, false, 1, 1, 1); - assertSession(loadedSessions.get(0), session.users().getUserByUsername(realm, "user1"), "127.0.0.1", started, started, "test-app", "third-party"); - }); + inComittedTransaction(session -> { // Assert online session + RealmModel realm = session.realms().getRealm(realmId); + List loadedSessions = loadPersistedSessionsPaginated(session, false, 1, 1, 1); + assertSession(loadedSessions.get(0), session.users().getUserByUsername(realm, "user1"), "127.0.0.1", started, started, "test-app", "third-party"); + }); + } inComittedTransaction(session -> { // Assert offline sessions @@ -261,7 +264,7 @@ public class UserSessionPersisterProviderTest extends KeycloakModelTest { UserSessionModel userSession = session.sessions().createUserSession(null, fooRealm, session.users().getUserByUsername(fooRealm, "user3"), "user3", "127.0.0.1", "form", true, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); userSessionID.set(userSession.getId()); - createClientSession(session, realmId, fooRealm.getClientByClientId("foo-app"), userSession, "http://redirect", "state"); + createClientSession(session, fooRealm.getId(), fooRealm.getClientByClientId("foo-app"), userSession, "http://redirect", "state"); }); inComittedTransaction(session -> { @@ -302,8 +305,8 @@ public class UserSessionPersisterProviderTest extends KeycloakModelTest { UserSessionModel userSession = session.sessions().createUserSession(null, fooRealm, session.users().getUserByUsername(fooRealm, "user3"), "user3", "127.0.0.1", "form", true, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); userSessionID.set(userSession.getId()); - createClientSession(session, realmId, fooRealm.getClientByClientId("foo-app"), userSession, "http://redirect", "state"); - createClientSession(session, realmId, fooRealm.getClientByClientId("bar-app"), userSession, "http://redirect", "state"); + createClientSession(session, fooRealm.getId(), fooRealm.getClientByClientId("foo-app"), userSession, "http://redirect", "state"); + createClientSession(session, fooRealm.getId(), fooRealm.getClientByClientId("bar-app"), userSession, "http://redirect", "state"); }); inComittedTransaction(session -> { diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionProviderModelTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionProviderModelTest.java index e04d5cd3ad..516af4fd3f 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionProviderModelTest.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionProviderModelTest.java @@ -101,8 +101,8 @@ public class UserSessionProviderModelTest extends KeycloakModelTest { inComittedTransaction(session -> { RealmModel realm = session.realms().getRealm(realmId); - session.sessions().removeUserSession(realm, origSessions[0]); - session.sessions().removeUserSession(realm, origSessions[1]); + session.sessions().removeUserSession(realm, session.sessions().getUserSession(realm, origSessions[0].getId())); + session.sessions().removeUserSession(realm, session.sessions().getUserSession(realm, origSessions[1].getId())); }); inComittedTransaction(session -> { diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionProviderOfflineModelTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionProviderOfflineModelTest.java index b48dccdb2d..cd63439bec 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionProviderOfflineModelTest.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionProviderOfflineModelTest.java @@ -24,6 +24,7 @@ import org.infinispan.context.Flag; import org.junit.Assert; import org.junit.Assume; import org.junit.Test; +import org.keycloak.common.Profile; import org.keycloak.common.util.Time; import org.keycloak.connections.infinispan.InfinispanConnectionProvider; import org.keycloak.models.AuthenticatedClientSessionModel; @@ -300,10 +301,20 @@ public class UserSessionProviderOfflineModelTest extends KeycloakModelTest { session.sessions().createOfflineUserSession(userSession); session.sessions().createOfflineUserSession(origSessions[0]); - // try to load user session from persister - Assert.assertEquals(2, persister.loadUserSessionsStream(0, 10, true, "00000000-0000-0000-0000-000000000000").count()); + if (!Profile.isFeatureEnabled(Profile.Feature.PERSISTENT_USER_SESSIONS)) { + // This does not work with persistent user sessions because we currently have two transactions and the one that creates the offline user sessions is not committing the changes + // try to load user session from persister + Assert.assertEquals(2, persister.loadUserSessionsStream(0, 10, true, "00000000-0000-0000-0000-000000000000").count()); + } }); + if (Profile.isFeatureEnabled(Profile.Feature.PERSISTENT_USER_SESSIONS)) { + inComittedTransaction(session -> { + persister = session.getProvider(UserSessionPersisterProvider.class); + Assert.assertEquals(2, persister.loadUserSessionsStream(0, 10, true, "00000000-0000-0000-0000-000000000000").count()); + }); + } + } finally { setTimeOffset(0); kcSession.getKeycloakSessionFactory().publish(new ResetTimeOffsetEvent());