mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-08 14:32:05 -03:30
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:
parent
6519142f43
commit
0957572751
@ -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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user