Fire logout event when logout other sessions (#26658)

Closes #26658

Signed-off-by: Lex Cao <lexcao@foxmail.com>
This commit is contained in:
Lex Cao 2024-03-24 22:59:12 +08:00 committed by Marek Posolda
parent 3382e16954
commit a53cacc0a7
11 changed files with 128 additions and 42 deletions

View file

@ -50,6 +50,8 @@ public interface RequiredActionContext {
ERROR
}
String getAction();
/**
* Get the action URL for the required action.
*

View file

@ -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";
}

View file

@ -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<? extends DefaultActionToken> context) {
public static void logoutOtherSessions(DefaultActionToken token, ActionTokenContext<? extends DefaultActionToken> 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();
});
}
}

View file

@ -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();

View file

@ -79,7 +79,7 @@ public class UpdateEmailActionTokenHandler extends AbstractActionTokenHandler<Up
UpdateEmail.updateEmailNow(tokenContext.getEvent(), user, emailUpdateValidationResult);
if (Boolean.TRUE.equals(token.getLogoutSessions())) {
AuthenticatorUtil.logoutOtherSessions(tokenContext);
AuthenticatorUtil.logoutOtherSessions(token, tokenContext);
}
tokenContext.getEvent().success();

View file

@ -23,6 +23,7 @@ import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.EventRepresentation;
@ -227,7 +228,7 @@ public class AppInitiatedActionResetPasswordTest extends AbstractAppInitiatedAct
final String firstSessionId = sessions.get(0).getId();
oauth2.doLogin("test-user@localhost", "password");
events.expectLogin().assertEvent();
EventRepresentation event2 = events.expectLogin().assertEvent();
assertEquals(2, testUser.getUserSessions().size());
doAIA();
@ -235,6 +236,7 @@ public class AppInitiatedActionResetPasswordTest extends AbstractAppInitiatedAct
changePasswordPage.assertCurrent();
assertTrue("Logout sessions is checked by default", changePasswordPage.isLogoutSessionsChecked());
changePasswordPage.changePassword("All Right Then, Keep Your Secrets", "All Right Then, Keep Your Secrets");
events.expectLogout(event2.getSessionId()).detail(Details.LOGOUT_TRIGGERED_BY_REQUIRED_ACTION, UserModel.RequiredAction.UPDATE_PASSWORD.name()).assertEvent();
events.expectRequiredAction(EventType.UPDATE_PASSWORD).assertEvent();
assertKcActionStatus(SUCCESS);

View file

@ -16,6 +16,8 @@
*/
package org.keycloak.testsuite.actions;
import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.jboss.arquillian.drone.api.annotation.Drone;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.After;
@ -24,6 +26,7 @@ import org.junit.Rule;
import org.junit.Test;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.authentication.authenticators.browser.UsernameFormFactory;
import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.UserModel.RequiredAction;
@ -31,6 +34,7 @@ import org.keycloak.models.utils.DefaultAuthenticationFlows;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.idm.UserSessionRepresentation;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
@ -39,14 +43,16 @@ import org.keycloak.testsuite.pages.AppPage.RequestType;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.LoginPasswordUpdatePage;
import org.keycloak.testsuite.pages.LoginUsernameOnlyPage;
import org.keycloak.testsuite.util.FlowUtil;
import org.keycloak.testsuite.util.GreenMailRule;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.SecondBrowser;
import org.keycloak.testsuite.util.FlowUtil;
import org.keycloak.testsuite.util.RealmManager;
import org.keycloak.testsuite.util.SecondBrowser;
import org.openqa.selenium.WebDriver;
import java.util.LinkedList;
import java.util.List;
import java.util.stream.Collectors;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@ -118,14 +124,21 @@ public class RequiredActionResetPasswordTest extends AbstractTestRealmKeycloakTe
}
@Test
public void logoutSessionsCheckboxNotPresent() {
public void resetPasswordLogoutSessionsChecked() {
resetPassword(true);
}
@Test
public void resetPasswordLogoutSessionsNotChecked() {
resetPassword(false);
}
private void resetPassword(boolean logoutOtherSessions) {
OAuthClient oauth2 = new OAuthClient();
oauth2.init(driver2);
UserResource testUser = testRealm().users().get(findUser("test-user@localhost").getId());
oauth2.doLogin("test-user@localhost", "password");
events.expectLogin().assertEvent();
EventRepresentation event1 = events.expectLogin().assertEvent();
assertEquals(1, testUser.getUserSessions().size());
requireUpdatePassword();
@ -135,12 +148,29 @@ public class RequiredActionResetPasswordTest extends AbstractTestRealmKeycloakTe
changePasswordPage.assertCurrent();
assertTrue(changePasswordPage.isLogoutSessionDisplayed());
assertTrue(changePasswordPage.isLogoutSessionsChecked());
if (!logoutOtherSessions) {
changePasswordPage.uncheckLogoutSessions();
}
changePasswordPage.changePassword("All Right Then, Keep Your Secrets", "All Right Then, Keep Your Secrets");
events.expectRequiredAction(EventType.UPDATE_PASSWORD).assertEvent();
events.expectLogin().assertEvent();
assertEquals("All sessions are still active", 2, testUser.getUserSessions().size());
if (logoutOtherSessions) {
events.expectLogout(event1.getSessionId())
.detail(Details.LOGOUT_TRIGGERED_BY_REQUIRED_ACTION, RequiredAction.UPDATE_PASSWORD.name())
.assertEvent();
}
events.expectRequiredAction(EventType.UPDATE_PASSWORD).assertEvent();
EventRepresentation event2 = events.expectLogin().assertEvent();
List<UserSessionRepresentation> 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);

View file

@ -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();

View file

@ -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());

View file

@ -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;
@ -109,6 +113,13 @@ public class RequiredActionUpdateEmailTestWithVerificationTest extends AbstractR
configureRequiredActionsToUser("test-user@localhost", UserModel.RequiredAction.UPDATE_EMAIL.name());
changeEmailUsingRequiredAction("new@localhost", logoutOtherSessions);
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();
}
events.expect(EventType.UPDATE_EMAIL)
.detail(Details.PREVIOUS_EMAIL, "test-user@localhost")
.detail(Details.UPDATED_EMAIL, "new@localhost")

View file

@ -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))