Add logout event to SessionResource

Closes #44842

Signed-off-by: Robin Meese <39960884+robson90@users.noreply.github.com>
Signed-off-by: Alexander Schwartz <alexander.schwartz@gmx.net>
Co-authored-by: Alexander Schwartz <alexander.schwartz@gmx.net>
This commit is contained in:
Robin Meese 2025-12-29 13:25:45 +01:00 committed by GitHub
parent 6519142f43
commit 0957572751
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 127 additions and 12 deletions

View File

@ -41,11 +41,10 @@ https://www.npmjs.com/package/*
https://docs.kantarainitiative.org*
https://saml.xml.org*
# To be removed once KC 26.4.2 is released
# To be removed once KC 26.5.0 is released
https://www.keycloak.org/observability/telemetry
https://www.keycloak.org/securing-apps/jwt-authorization-grant
https://www.keycloak.org/server/windows-service
https://www.keycloak.org/server/db#multiple-datasources
https://www.keycloak.org/server/logging#http-access-logging
https://www.keycloak.org/server/logging#mdc
# To be removed once KC 26.5.0 is release
https://www.keycloak.org/observability/telemetry
https://www.keycloak.org/securing-apps/jwt-authorization-grant
https://www.keycloak.org/server/logging#mdc

View File

@ -39,8 +39,10 @@ only the basic attributes in representations or all of them.
The `UserProfile` interface is a private API and should not be implemented by custom code. However, if you have extensions that
implement this interface, you will need to update your code to accommodate this new method.
Breaking changes are identified as those that might require changes for existing users to their configurations or applications.
In minor or patch releases, {project_name} will only introduce breaking changes to fix bugs.
=== LOGOUT events when logging out sessions via the Account Console
When logging out sessions via the Account Console {project_name} now creates LOGOUT user events to track this activity.
The events are connected to the `account` client.
=== Identity Provider refactoring

View File

@ -210,7 +210,7 @@ public class AccountRestService {
public SessionResource sessions() {
checkAccountApiEnabled();
auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE);
return new SessionResource(session, auth);
return new SessionResource(session, auth, event);
}
@Path("/credentials")

View File

@ -33,6 +33,8 @@ import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.keycloak.device.DeviceActivityManager;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.AccountRoles;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
@ -56,12 +58,14 @@ public class SessionResource {
private final Auth auth;
private final RealmModel realm;
private final UserModel user;
private final EventBuilder event;
public SessionResource(KeycloakSession session, Auth auth) {
public SessionResource(KeycloakSession session, Auth auth, EventBuilder event) {
this.session = session;
this.auth = auth;
this.realm = auth.getRealm();
this.user = auth.getUser();
this.event = event;
}
/**
@ -129,8 +133,14 @@ public class SessionResource {
auth.require(AccountRoles.MANAGE_ACCOUNT);
session.sessions().getUserSessionsStream(realm, user).filter(s -> removeCurrent || !isCurrentSession(s))
.collect(Collectors.toList()) // collect to avoid concurrent modification as backchannelLogout removes the user sessions.
.forEach(s -> AuthenticationManager.backchannelLogout(session, s, true));
.forEach(s -> {
AuthenticationManager.backchannelLogout(session, s, true);
event.clone()
.event(EventType.LOGOUT)
.user(user)
.session(s)
.success();
});
return Response.noContent().build();
}
@ -149,6 +159,10 @@ public class SessionResource {
UserSessionModel userSession = session.sessions().getUserSession(realm, id);
if (userSession != null && userSession.getUser().equals(user)) {
AuthenticationManager.backchannelLogout(session, userSession, true);
event.event(EventType.LOGOUT)
.user(user)
.session(id)
.success();
}
return Response.noContent().build();
}

View File

@ -87,6 +87,7 @@ public abstract class AbstractRestServiceTest extends AbstractTestRealmKeycloakT
testRealm.getUsers().add(UserBuilder.create().username("view-applications-access").addRoles("user", "offline_access").role("account", "view-applications").role("account", "manage-consent").password("password").build());
testRealm.getUsers().add(UserBuilder.create().username("view-consent-access").role("account", "view-consent").password("password").build());
testRealm.getUsers().add(UserBuilder.create().username("manage-consent-access").role("account", "manage-consent").role("account", "view-profile").password("password").build());
testRealm.getUsers().add(UserBuilder.create().username("manage-account-access").role("account", "view-profile").role("account", "manage-account").password("password").build());
org.keycloak.representations.idm.ClientRepresentation inUseApp = ClientBuilder.create().clientId("in-use-client")
.id(KeycloakModelUtils.generateId())

View File

@ -46,6 +46,8 @@ import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.http.simple.SimpleHttpRequest;
import org.keycloak.http.simple.SimpleHttpResponse;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.UserModel;
@ -55,6 +57,7 @@ import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.models.credential.WebAuthnCredentialModel;
import org.keycloak.models.utils.DefaultAuthenticationFlows;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.account.ClientRepresentation;
import org.keycloak.representations.account.ConsentRepresentation;
import org.keycloak.representations.account.ConsentScopeRepresentation;
@ -72,6 +75,7 @@ import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
import org.keycloak.representations.idm.RequiredActionProviderSimpleRepresentation;
import org.keycloak.representations.idm.UserProfileAttributeMetadata;
import org.keycloak.representations.idm.UserSessionRepresentation;
import org.keycloak.services.cors.Cors;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.account.AccountCredentialResource;
@ -1144,6 +1148,101 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
assertEquals(1, sessions.size());
}
@Test
public void testDeletionOfAllUserSessionsWillFireLogoutEvents() throws IOException {
String username = "manage-account-access";
String password = "password";
String firstToken = new TokenUtil(username, password).getToken();
String secondToken = new TokenUtil(username, password).getToken();
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), username);
List<UserSessionRepresentation> userSessions = user.getUserSessions();
assertEquals(2, userSessions.size());
// skip the two direct access grant logins
events.poll();
events.poll();
int status = SimpleHttpDefault.doDelete(getAccountUrl("sessions?current=true"), httpClient).acceptJson().auth(firstToken).asStatus();
assertEquals(204, status);
assertEquals(0, user.getUserSessions().size());
userSessions.forEach(session -> {
events.expectAccount(EventType.LOGOUT)
.user(user.toRepresentation().getId())
.session(session.getId())
.assertEvent();
});
events.assertEmpty();
}
@Test
public void testDeletionOfAllUserSessionsExceptTheCurrentWillFireLogoutEvents() throws IOException, JWSInputException {
String username = "manage-account-access";
String password = "password";
String firstToken = new TokenUtil(username, password).getToken();
String secondToken = new TokenUtil(username, password).getToken();
String thirdToken = new TokenUtil(username, password).getToken();
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), username);
List<UserSessionRepresentation> userSessions = user.getUserSessions();
assertEquals(3, userSessions.size());
// skip the three direct access grant logins
events.poll();
events.poll();
events.poll();
int status = SimpleHttpDefault.doDelete(getAccountUrl("sessions?current=false"), httpClient).acceptJson().auth(firstToken).asStatus();
assertEquals(204, status);
assertEquals(1, user.getUserSessions().size());
JWSInput input = new JWSInput(firstToken);
AccessToken token = input.readJsonContent(AccessToken.class);
userSessions = userSessions.stream().filter(session -> !session.getId().equals(token.getSessionId())).toList();
userSessions.forEach(session -> {
events.expectAccount(EventType.LOGOUT)
.user(user.toRepresentation().getId())
.session(session.getId())
.assertEvent();
});
events.assertEmpty();
}
@Test
public void testDeletionOfSpecificSessionWillFireLogoutEvent() throws IOException, JWSInputException {
String username = "manage-account-access";
String password = "password";
String firstToken = new TokenUtil(username, password).getToken();
String secondToken = new TokenUtil(username, password).getToken();
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), username);
List<UserSessionRepresentation> userSessions = user.getUserSessions();
assertEquals(2, userSessions.size());
// skip the two direct access grant logins
events.poll();
events.poll();
JWSInput input = new JWSInput(firstToken);
AccessToken token = input.readJsonContent(AccessToken.class);
int status = SimpleHttpDefault.doDelete(getAccountUrl(String.format("sessions/%s", token.getSessionId())), httpClient)
.acceptJson().auth(firstToken).asStatus();
assertEquals(204, status);
assertEquals(1, user.getUserSessions().size());
events.expectAccount(EventType.LOGOUT)
.user(user.toRepresentation().getId())
.session(token.getSessionId())
.assertEvent();
events.assertEmpty();
}
@Test
public void listApplications() throws Exception {
oauth.client("in-use-client", "secret1");