Fire logout event when logout other sessions (#26658)
Closes #26658 Signed-off-by: Lex Cao <lexcao@foxmail.com>
This commit is contained in:
parent
3382e16954
commit
a53cacc0a7
11 changed files with 128 additions and 42 deletions
|
@ -50,6 +50,8 @@ public interface RequiredActionContext {
|
|||
ERROR
|
||||
}
|
||||
|
||||
String getAction();
|
||||
|
||||
/**
|
||||
* Get the action URL for the required action.
|
||||
*
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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());
|
||||
changePasswordPage.uncheckLogoutSessions();
|
||||
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);
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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<UserSessionRepresentation> 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<UserSessionRepresentation> 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());
|
||||
|
|
|
@ -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))
|
||||
|
|
Loading…
Reference in a new issue