Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.cache.infinispan.events.AuthenticationSessionAuthNoteUpdateEvent;
import org.keycloak.models.sessions.infinispan.changes.InfinispanChangelogBasedTransaction;
Expand Down Expand Up @@ -96,6 +97,17 @@ private RootAuthenticationSessionEntity getRootAuthenticationSessionEntity(Strin
return entityWrapper==null ? null : entityWrapper.getEntity();
}

@Override
public void removeRootAuthenticationSessionsByAuthenticatedUser(RealmModel realm, UserModel user, String rootAuthenticationSessionIdToKeep) {
sessionTx.getCache().entrySet().stream()
.filter(SessionWrapperPredicate.create(realm.getId()))
.filter(entry -> entry.getValue().getEntity().hasAuthenticationSessionForUser(user.getId()))
.map(entry -> entry.getKey())
.filter(rootSessionId -> !Objects.equals(rootSessionId, rootAuthenticationSessionIdToKeep))
.toList()
.forEach(rootSessionId -> sessionTx.addTask(rootSessionId, Tasks.removeSync()));
}

@Override
public void onRealmRemoved(RealmModel realm) {
// Send message to all DCs. The remoteCache will notify client listeners on all DCs for remove authentication sessions
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.keycloak.models.sessions.infinispan.changes.remote.remover.query;

import java.util.Map;
import java.util.Objects;

import org.keycloak.models.sessions.infinispan.changes.remote.remover.ConditionalRemover;
import org.keycloak.models.sessions.infinispan.entities.RootAuthenticationSessionEntity;

/**
* A {@link ConditionalRemover} implementation to delete {@link RootAuthenticationSessionEntity} based on either the
* realm or the authenticated user.
*
* The removal is performed server-side with a single Infinispan Ickle query. Removing by user relies on the indexed
* {@code authenticatedUserIds} field of {@link RootAuthenticationSessionEntity}.
*/
public class AuthenticationSessionQueryConditionalRemover extends MultipleConditionQueryRemover<String, RootAuthenticationSessionEntity> {

private final String entity;

public AuthenticationSessionQueryConditionalRemover(String entity) {
this.entity = entity;
}

@Override
String getEntity() {
return entity;
}

public void removeByRealmId(String realmId) {
add(new RemoveByRealm(nextParameter(), realmId));
}

public void removeByUser(String realmId, String userId, String rootAuthenticationSessionIdToKeep) {
var keepParameter = rootAuthenticationSessionIdToKeep == null ? null : nextParameter();
add(new RemoveByUser(nextParameter(), realmId, nextParameter(), userId, keepParameter, rootAuthenticationSessionIdToKeep));
}

private record RemoveByRealm(String realmParameter,
String realmId) implements RemoveCondition<String, RootAuthenticationSessionEntity> {

@Override
public String getConditionalClause() {
return "(realmId = :%s)".formatted(realmParameter);
}

@Override
public void addParameters(Map<String, Object> parameters) {
parameters.put(realmParameter, realmId);
}

@Override
public boolean willRemove(String key, RootAuthenticationSessionEntity value) {
return Objects.equals(realmId, value.getRealmId());
}
}

private record RemoveByUser(String realmParameter, String realmId, String userParameter, String userId,
String keepParameter, String rootAuthenticationSessionIdToKeep)
implements RemoveCondition<String, RootAuthenticationSessionEntity> {

@Override
public String getConditionalClause() {
if (keepParameter == null) {
return "(realmId = :%s && authenticatedUserIds = :%s)".formatted(realmParameter, userParameter);
}
return "(realmId = :%s && authenticatedUserIds = :%s && id != :%s)".formatted(realmParameter, userParameter, keepParameter);
}

@Override
public void addParameters(Map<String, Object> parameters) {
parameters.put(realmParameter, realmId);
parameters.put(userParameter, userId);
if (keepParameter != null) {
parameters.put(keepParameter, rootAuthenticationSessionIdToKeep);
}
}

@Override
public boolean willRemove(String key, RootAuthenticationSessionEntity value) {
return Objects.equals(realmId, value.getRealmId())
&& value.hasAuthenticationSessionForUser(userId)
&& !Objects.equals(key, rootAuthenticationSessionIdToKeep);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,16 @@

package org.keycloak.models.sessions.infinispan.entities;

import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;

import org.keycloak.marshalling.Marshalling;

import org.infinispan.api.annotations.indexing.Basic;
import org.infinispan.api.annotations.indexing.Indexed;
import org.infinispan.protostream.annotations.ProtoFactory;
import org.infinispan.protostream.annotations.ProtoField;
Expand Down Expand Up @@ -51,7 +55,7 @@ protected RootAuthenticationSessionEntity(String realmId, String id, int timesta
}

@ProtoFactory
static RootAuthenticationSessionEntity protoFactory(String realmId, String id, int timestamp, Map<String, AuthenticationSessionEntity> authenticationSessions) {
static RootAuthenticationSessionEntity protoFactory(String realmId, String id, int timestamp, Map<String, AuthenticationSessionEntity> authenticationSessions, Set<String> authenticatedUserIds) {
return new RootAuthenticationSessionEntity(realmId, id, timestamp, authenticationSessions);
}

Expand All @@ -78,6 +82,20 @@ public void setAuthenticationSessions(Map<String, AuthenticationSessionEntity> a
this.authenticationSessions = authenticationSessions;
}

@ProtoField(value = 5, collectionImplementation = HashSet.class)
@Basic
public Set<String> getAuthenticatedUserIds() {
return authenticationSessions.values().stream()
.map(AuthenticationSessionEntity::getAuthUserId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
}

public boolean hasAuthenticationSessionForUser(String userId) {
return authenticationSessions.values().stream()
.anyMatch(authSession -> Objects.equals(authSession.getAuthUserId(), userId));
}
Comment thread
gaoyikeshuer marked this conversation as resolved.

@Override
public boolean shouldEvaluateRemoval() {
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.cache.infinispan.events.AuthenticationSessionAuthNoteUpdateEvent;
import org.keycloak.models.sessions.infinispan.InfinispanAuthenticationSessionProviderFactory;
import org.keycloak.models.sessions.infinispan.entities.RootAuthenticationSessionEntity;
Expand Down Expand Up @@ -84,6 +85,11 @@ public void removeRootAuthenticationSession(RealmModel realm, RootAuthentication
transaction.remove(authenticationSession.getId());
}

@Override
public void removeRootAuthenticationSessionsByAuthenticatedUser(RealmModel realm, UserModel user, String rootAuthenticationSessionIdToKeep) {
transaction.removeByUser(realm.getId(), user.getId(), rootAuthenticationSessionIdToKeep);
}

@Override
public void onRealmRemoved(RealmModel realm) {
transaction.removeByRealmId(realm.getId());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.sessions.infinispan.InfinispanAuthenticationSessionProviderFactory;
import org.keycloak.models.sessions.infinispan.changes.remote.remover.query.ByRealmIdQueryConditionalRemover;
import org.keycloak.models.sessions.infinispan.changes.remote.remover.query.AuthenticationSessionQueryConditionalRemover;
import org.keycloak.models.sessions.infinispan.changes.remote.updater.UpdaterFactory;
import org.keycloak.models.sessions.infinispan.changes.remote.updater.authsession.RootAuthenticationSessionUpdater;
import org.keycloak.models.sessions.infinispan.entities.RootAuthenticationSessionEntity;
Expand Down Expand Up @@ -155,7 +155,7 @@ public Set<Class<? extends Provider>> dependsOn() {

private AuthenticationSessionChangeLogTransaction createAndEnlistTransaction(KeycloakSession session) {
var provider = session.getProvider(InfinispanTransactionProvider.class);
var tx = new AuthenticationSessionChangeLogTransaction(this, this, new ByRealmIdQueryConditionalRemover<>(PROTO_ENTITY));
var tx = new AuthenticationSessionChangeLogTransaction(this, this, new AuthenticationSessionQueryConditionalRemover(PROTO_ENTITY));
provider.registerTransaction(tx);
return tx;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,27 @@

package org.keycloak.models.sessions.infinispan.remote.transaction;

import org.keycloak.models.sessions.infinispan.changes.remote.remover.query.ByRealmIdQueryConditionalRemover;
import org.keycloak.models.sessions.infinispan.changes.remote.remover.query.AuthenticationSessionQueryConditionalRemover;
import org.keycloak.models.sessions.infinispan.changes.remote.updater.UpdaterFactory;
import org.keycloak.models.sessions.infinispan.changes.remote.updater.authsession.RootAuthenticationSessionUpdater;
import org.keycloak.models.sessions.infinispan.entities.RootAuthenticationSessionEntity;

/**
* Syntactic sugar for
* {@code RemoteInfinispanKeycloakTransaction<String, RootAuthenticationSessionEntity,
* ByRealmIdQueryConditionalRemover<String, RootAuthenticationSessionEntity>>
* AuthenticationSessionQueryConditionalRemover>
*/
public class AuthenticationSessionChangeLogTransaction extends RemoteChangeLogTransaction<String, RootAuthenticationSessionEntity, RootAuthenticationSessionUpdater, ByRealmIdQueryConditionalRemover<String, RootAuthenticationSessionEntity>> {
public class AuthenticationSessionChangeLogTransaction extends RemoteChangeLogTransaction<String, RootAuthenticationSessionEntity, RootAuthenticationSessionUpdater, AuthenticationSessionQueryConditionalRemover> {

public AuthenticationSessionChangeLogTransaction(UpdaterFactory<String, RootAuthenticationSessionEntity, RootAuthenticationSessionUpdater> factory, SharedState<String, RootAuthenticationSessionEntity> sharedState, ByRealmIdQueryConditionalRemover<String, RootAuthenticationSessionEntity> conditionalRemover) {
public AuthenticationSessionChangeLogTransaction(UpdaterFactory<String, RootAuthenticationSessionEntity, RootAuthenticationSessionUpdater> factory, SharedState<String, RootAuthenticationSessionEntity> sharedState, AuthenticationSessionQueryConditionalRemover conditionalRemover) {
super(factory, sharedState, conditionalRemover);
}

public void removeByRealmId(String realmId) {
getConditionalRemover().removeByRealmId(realmId);
}

public void removeByUser(String realmId, String userId, String rootAuthenticationSessionIdToKeep) {
getConditionalRemover().removeByUser(realmId, userId, rootAuthenticationSessionIdToKeep);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.SessionExpiration;
import org.keycloak.sessions.AuthenticationSessionProvider;
import org.keycloak.sessions.RootAuthenticationSessionModel;
Expand Down Expand Up @@ -142,6 +143,16 @@ public void removeRootAuthenticationSession(RealmModel realm, RootAuthentication
}
}

@Override
public void removeRootAuthenticationSessionsByAuthenticatedUser(RealmModel realm, UserModel user, String rootAuthenticationSessionIdToKeep) {
getEntityManager()
.createNamedQuery("deleteRootAuthSessionsByUser")
.setParameter("realmId", realm.getId())
.setParameter("userId", user.getId())
.setParameter("rootSessionIdToKeep", rootAuthenticationSessionIdToKeep)
.executeUpdate();
}

@Override
public void onRealmRemoved(RealmModel realm) {
getEntityManager()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@
query = "SELECT sess.id FROM RootAuthenticationSessionEntity sess" +
" WHERE sess.realmId = :realmId AND sess.timestamp < :timestamp"
),
@NamedQuery(
name = "deleteRootAuthSessionsByUser",
query = "DELETE FROM RootAuthenticationSessionEntity sess" +
" WHERE sess.realmId = :realmId" +
" AND (:rootSessionIdToKeep IS NULL OR sess.id <> :rootSessionIdToKeep)" +
" AND EXISTS (" +
" SELECT auth.tabId FROM AuthenticationSessionEntity auth" +
" WHERE auth.rootAuthenticationSession = sess AND auth.authUserId = :userId" +
" )"
),
Comment on lines +50 to +59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gaoyikeshuer I think this type of queries are very slow in MySQL/MariaDB. I remember we added some specific queries per db type. Just check if you need something special here for the delete. See queries-mariadb.properties and the other file for mysql. Maybe you need to change this query for a delete with join. Not sure, but please recheck it.

Please ensure at least one test for this is executed in the DB jobs for every database we support.

@NamedQuery(
name = "deleteExpiredRootAuthSessionByIds",
query = "DELETE FROM RootAuthenticationSessionEntity e WHERE e.id IN :ids AND e.timestamp < :timestamp"
Expand Down
28 changes: 28 additions & 0 deletions model/jpa/src/main/resources/META-INF/jpa-changelog-26.8.0.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!--
~ * Copyright 2025 Red Hat, Inc. and/or its affiliates
~ * and other contributors as indicated by the @author tags.
~ *
~ * Licensed under the Apache License, Version 2.0 (the "License");
~ * you may not use this file except in compliance with the License.
~ * You may obtain a copy of the License at
~ *
~ * http://www.apache.org/licenses/LICENSE-2.0
~ *
~ * Unless required by applicable law or agreed to in writing, software
~ * distributed under the License is distributed on an "AS IS" BASIS,
~ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ * See the License for the specific language governing permissions and
~ * limitations under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">

<changeSet author="keycloak" id="26.8.0-auth-session-auth-user-id-index">
<!-- Index used when invalidating a user's in-progress authentication sessions on credential reset
with "sign out of other devices" (findRootAuthSessionIdsByUser filters on AUTH_USER_ID). -->
<createIndex tableName="AUTH_SESSION" indexName="IDX_AUTH_SESSION_AUTH_USER_ID">
<column name="AUTH_USER_ID"/>
</createIndex>
</changeSet>

</databaseChangeLog>
Original file line number Diff line number Diff line change
Expand Up @@ -92,5 +92,6 @@
<include file="META-INF/jpa-changelog-26.5.0.xml"/>
<include file="META-INF/jpa-changelog-26.6.0.xml"/>
<include file="META-INF/jpa-changelog-26.7.0.xml"/>
<include file="META-INF/jpa-changelog-26.8.0.xml"/>

</databaseChangeLog>
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import org.keycloak.models.ClientModel;
import org.keycloak.models.ModelException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.provider.Provider;

/**
Expand Down Expand Up @@ -82,6 +83,17 @@ default void removeAllExpired() {}
@Deprecated(since = "19.0", forRemoval = true)
default void removeExpired(RealmModel realm) {}

/**
* Removes all root authentication sessions of the given realm that hold an in-progress authentication session
* for the given authenticated user, except for the provided root authentication session id.
Comment on lines +87 to +88
*
* @param realm {@code RealmModel} Can't be {@code null}.
* @param user {@code UserModel} Can't be {@code null}.
* @param rootAuthenticationSessionIdToKeep optional id of a root authentication session to keep.
*/
default void removeRootAuthenticationSessionsByAuthenticatedUser(RealmModel realm, UserModel user, String rootAuthenticationSessionIdToKeep) {
}
Comment on lines +94 to +95

/**
* Removes all associated root authentication sessions to the given realm which was removed.
* @param realm {@code RealmModel} Can't be {@code null}.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ private static void logoutOtherSessions(KeycloakSession session, RealmModel real
});
}

session.authenticationSessions().removeRootAuthenticationSessionsByAuthenticatedUser(realm, user,
authSession.getParentSession().getId());
Comment thread
gaoyikeshuer marked this conversation as resolved.
}

private static void backchannelLogout(KeycloakSession session, RealmModel realm, ClientConnection conn, HttpRequest req, EventBuilder event, UserSessionModel s) {
Expand Down
Loading
Loading