diff --git a/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionContext.java b/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionContext.java index 17f292681f..483846534e 100755 --- a/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionContext.java +++ b/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionContext.java @@ -50,6 +50,8 @@ public interface RequiredActionContext { ERROR } + String getAction(); + /** * Get the action URL for the required action. * diff --git a/server-spi-private/src/main/java/org/keycloak/events/Details.java b/server-spi-private/src/main/java/org/keycloak/events/Details.java index 857487b80f..728f2dbd50 100755 --- a/server-spi-private/src/main/java/org/keycloak/events/Details.java +++ b/server-spi-private/src/main/java/org/keycloak/events/Details.java @@ -91,4 +91,7 @@ public interface Details { String NOT_BEFORE = "not_before"; String NUM_FAILURES = "num_failures"; + + String LOGOUT_TRIGGERED_BY_ACTION_TOKEN = "logout_triggered_by_action_token"; + String LOGOUT_TRIGGERED_BY_REQUIRED_ACTION = "logout_triggered_by_required_action"; } diff --git a/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java b/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java index e7387dac28..34789d1c57 100755 --- a/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java +++ b/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java @@ -21,6 +21,9 @@ import org.jboss.logging.Logger; import org.keycloak.authentication.actiontoken.ActionTokenContext; import org.keycloak.authentication.actiontoken.DefaultActionToken; import org.keycloak.common.ClientConnection; +import org.keycloak.events.Details; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; import org.keycloak.http.HttpRequest; import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.Constants; @@ -136,28 +139,39 @@ public class AuthenticatorUtil { * @param context The required action context */ public static void logoutOtherSessions(RequiredActionContext context) { + EventBuilder event = context.getEvent().clone() + .detail(Details.LOGOUT_TRIGGERED_BY_REQUIRED_ACTION, context.getAction()); logoutOtherSessions(context.getSession(), context.getRealm(), context.getUser(), - context.getAuthenticationSession(), context.getConnection(), context.getHttpRequest()); + context.getAuthenticationSession(), context.getConnection(), context.getHttpRequest(), event); } /** * Logouts all sessions that are different to the current authentication session * managed in the action token context. * + * @param token The action token * @param context The required action token context */ - public static void logoutOtherSessions(ActionTokenContext context) { + public static void logoutOtherSessions(DefaultActionToken token, ActionTokenContext context) { + EventBuilder event = context.getEvent().clone() + .detail(Details.LOGOUT_TRIGGERED_BY_ACTION_TOKEN, token.getActionId()); logoutOtherSessions(context.getSession(), context.getRealm(), context.getAuthenticationSession().getAuthenticatedUser(), - context.getAuthenticationSession(), context.getClientConnection(), context.getRequest()); + context.getAuthenticationSession(), context.getClientConnection(), context.getRequest(), event); } private static void logoutOtherSessions(KeycloakSession session, RealmModel realm, UserModel user, - AuthenticationSessionModel authSession, ClientConnection conn, HttpRequest req) { + AuthenticationSessionModel authSession, ClientConnection conn, HttpRequest req, EventBuilder event) { session.sessions().getUserSessionsStream(realm, user) .filter(s -> !Objects.equals(s.getId(), authSession.getParentSession().getId())) .collect(Collectors.toList()) // collect to avoid concurrent modification as backchannelLogout removes the user sessions. - .forEach(s -> AuthenticationManager.backchannelLogout(session, realm, s, session.getContext().getUri(), - conn, req.getHttpHeaders(), true) - ); + .forEach(s -> { + AuthenticationManager.backchannelLogout(session, realm, s, session.getContext().getUri(), + conn, req.getHttpHeaders(), true); + + event.event(EventType.LOGOUT) + .session(s) + .user(s.getUser()) + .success(); + }); } } diff --git a/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java b/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java index 768741d173..25d8ec0af1 100755 --- a/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java +++ b/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java @@ -136,6 +136,11 @@ public class RequiredActionContextResult implements RequiredActionContext { status = Status.IGNORE; } + @Override + public String getAction() { + return getFactory().getId(); + } + @Override public URI getActionUrl(String code) { ClientModel client = authenticationSession.getClient(); diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/updateemail/UpdateEmailActionTokenHandler.java b/services/src/main/java/org/keycloak/authentication/actiontoken/updateemail/UpdateEmailActionTokenHandler.java index f14a51c2b5..dc1147aa35 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/updateemail/UpdateEmailActionTokenHandler.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/updateemail/UpdateEmailActionTokenHandler.java @@ -79,7 +79,7 @@ public class UpdateEmailActionTokenHandler extends AbstractActionTokenHandler sessions = testUser.getUserSessions(); + if (logoutOtherSessions) { + assertEquals(1, sessions.size()); + assertEquals(event2.getSessionId(), sessions.iterator().next().getId()); + } else { + assertEquals(2, sessions.size()); + MatcherAssert.assertThat(sessions.stream().map(UserSessionRepresentation::getId).collect(Collectors.toList()), + Matchers.containsInAnyOrder(event1.getSessionId(), event2.getSessionId())); + } } @Test @@ -162,8 +192,7 @@ public class RequiredActionResetPasswordTest extends AbstractTestRealmKeycloakTe loginUsernameOnlyPage.open(); loginUsernameOnlyPage.login("test-user@localhost"); events.expectLogin().assertEvent(); - } - finally { + } finally { //reset browser flow and delete username only flow RealmRepresentation realm = testRealm().toRepresentation(); realm.setBrowserFlow(DefaultAuthenticationFlows.BROWSER_FLOW); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionTotpSetupTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionTotpSetupTest.java index c555c552aa..87ec420695 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionTotpSetupTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionTotpSetupTest.java @@ -671,6 +671,13 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest { Assert.assertEquals(logoutOtherSessions, totpPage.isLogoutSessionsChecked()); totpPage.configure(totp.generateTOTP(totpPage.getTotpSecret())); assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + + if (logoutOtherSessions) { + events.expectLogout(event1.getSessionId()) + .detail(Details.LOGOUT_TRIGGERED_BY_REQUIRED_ACTION, UserModel.RequiredAction.CONFIGURE_TOTP.name()) + .assertEvent(); + } + EventRepresentation event2 = events.expectRequiredAction(EventType.UPDATE_TOTP).user(event1.getUserId()).detail(Details.USERNAME, "test-user@localhost").assertEvent(); event2 = events.expectLogin().user(event2.getUserId()).session(event2.getDetails().get(Details.CODE_ID)).detail(Details.USERNAME, "test-user@localhost").assertEvent(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionUpdateEmailTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionUpdateEmailTest.java index 35e2466995..d3def4d0ad 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionUpdateEmailTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionUpdateEmailTest.java @@ -65,6 +65,12 @@ public class RequiredActionUpdateEmailTest extends AbstractRequiredActionUpdateE configureRequiredActionsToUser("test-user@localhost", UserModel.RequiredAction.UPDATE_EMAIL.name()); changeEmailUsingRequiredAction("new@localhost", logoutOtherSessions); + if (logoutOtherSessions) { + events.expectLogout(event1.getSessionId()) + .detail(Details.LOGOUT_TRIGGERED_BY_REQUIRED_ACTION, UserModel.RequiredAction.UPDATE_EMAIL.name()) + .assertEvent(); + } + events.expectRequiredAction(EventType.UPDATE_EMAIL).detail(Details.PREVIOUS_EMAIL, "test-user@localhost") .detail(Details.UPDATED_EMAIL, "new@localhost").assertEvent(); assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionUpdateEmailTestWithVerificationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionUpdateEmailTestWithVerificationTest.java index 406bf3575e..0033e04e07 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionUpdateEmailTestWithVerificationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionUpdateEmailTestWithVerificationTest.java @@ -19,6 +19,7 @@ package org.keycloak.testsuite.actions; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot; import jakarta.mail.Address; import jakarta.mail.Message; @@ -27,11 +28,14 @@ import jakarta.mail.internet.MimeMessage; import java.io.IOException; import java.util.List; import java.util.UUID; + +import org.hamcrest.Matchers; import org.jboss.arquillian.graphene.page.Page; import org.junit.Assert; import org.junit.Rule; import org.junit.Test; import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.authentication.actiontoken.updateemail.UpdateEmailActionToken; import org.keycloak.events.Details; import org.keycloak.events.EventType; import org.keycloak.models.UserModel; @@ -96,31 +100,38 @@ public class RequiredActionUpdateEmailTestWithVerificationTest extends AbstractR } private void updateEmail(boolean logoutOtherSessions) throws Exception { - // login using another session - configureRequiredActionsToUser("test-user@localhost"); - UserResource testUser = testRealm().users().get(findUser("test-user@localhost").getId()); - OAuthClient oauth2 = new OAuthClient(); - oauth2.init(driver2); - oauth2.doLogin("test-user@localhost", "password"); - EventRepresentation event1 = events.expectLogin().assertEvent(); - assertEquals(1, testUser.getUserSessions().size()); + // login using another session + configureRequiredActionsToUser("test-user@localhost"); + UserResource testUser = testRealm().users().get(findUser("test-user@localhost").getId()); + OAuthClient oauth2 = new OAuthClient(); + oauth2.init(driver2); + oauth2.doLogin("test-user@localhost", "password"); + EventRepresentation event1 = events.expectLogin().assertEvent(); + assertEquals(1, testUser.getUserSessions().size()); - // add action and change email - configureRequiredActionsToUser("test-user@localhost", UserModel.RequiredAction.UPDATE_EMAIL.name()); + // add action and change email + configureRequiredActionsToUser("test-user@localhost", UserModel.RequiredAction.UPDATE_EMAIL.name()); changeEmailUsingRequiredAction("new@localhost", logoutOtherSessions); - events.expect(EventType.UPDATE_EMAIL) - .detail(Details.PREVIOUS_EMAIL, "test-user@localhost") - .detail(Details.UPDATED_EMAIL, "new@localhost") - .assertEvent(); + if (logoutOtherSessions) { + events.expectLogout(event1.getSessionId()) + .detail(Details.REDIRECT_URI, getAuthServerContextRoot() + "/auth/realms/test/account/") + .detail(Details.LOGOUT_TRIGGERED_BY_ACTION_TOKEN, UpdateEmailActionToken.TOKEN_TYPE) + .assertEvent(); + } - List sessions = testUser.getUserSessions(); - if (logoutOtherSessions) { - assertEquals(0, sessions.size()); - } else { - assertEquals(1, sessions.size()); - assertEquals(event1.getSessionId(), sessions.iterator().next().getId()); - } + events.expect(EventType.UPDATE_EMAIL) + .detail(Details.PREVIOUS_EMAIL, "test-user@localhost") + .detail(Details.UPDATED_EMAIL, "new@localhost") + .assertEvent(); + + List sessions = testUser.getUserSessions(); + if (logoutOtherSessions) { + assertEquals(0, sessions.size()); + } else { + assertEquals(1, sessions.size()); + assertEquals(event1.getSessionId(), sessions.iterator().next().getId()); + } UserRepresentation user = ActionUtil.findUserWithAdminClient(adminClient, "test-user@localhost"); assertEquals("new@localhost", user.getEmail()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/RecoveryAuthnCodesAuthenticatorTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/RecoveryAuthnCodesAuthenticatorTest.java index d16d13af82..3ffad3c163 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/RecoveryAuthnCodesAuthenticatorTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/RecoveryAuthnCodesAuthenticatorTest.java @@ -149,6 +149,13 @@ public class RecoveryAuthnCodesAuthenticatorTest extends AbstractTestRealmKeyclo Assert.assertEquals(logoutOtherSessions, setupRecoveryAuthnCodesPage.isLogoutSessionsChecked()); setupRecoveryAuthnCodesPage.clickSaveRecoveryAuthnCodesButton(); assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType()); + + if (logoutOtherSessions) { + events.expectLogout(event1.getSessionId()) + .detail(Details.LOGOUT_TRIGGERED_BY_REQUIRED_ACTION, UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name()) + .assertEvent(); + } + EventRepresentation event2 = events.expectRequiredAction(EventType.CUSTOM_REQUIRED_ACTION) .user(event1.getUserId()).detail(Details.USERNAME, "test-user@localhost").assertEvent(); event2 = events.expectLogin().user(event2.getUserId()).session(event2.getDetails().get(Details.CODE_ID))