From ee35cfe47862c016495f724b80a35c81f552a468 Mon Sep 17 00:00:00 2001 From: Ricardo Martin Date: Wed, 26 Jul 2023 11:34:19 +0200 Subject: [PATCH] Add logout other sessions checkbox to TOTP, webauthn and recovery authn codes setup pages (#21897) * Add logout other sessions checkbox to TOTP, webauthn, recovery authn codes setup pages and to update-email page Closes #10232 --- .../authentication/AuthenticatorUtil.java | 40 ++++++++ .../updateemail/UpdateEmailActionToken.java | 17 +++- .../UpdateEmailActionTokenHandler.java | 5 + .../RecoveryAuthnCodesAction.java | 5 + .../requiredactions/UpdateEmail.java | 11 ++- .../requiredactions/UpdatePassword.java | 16 +--- .../requiredactions/UpdateTotp.java | 5 + .../requiredactions/WebAuthnRegister.java | 9 +- .../auth/page/login/UpdateEmailPage.java | 17 ++-- .../testsuite/pages/LoginConfigTotpPage.java | 2 +- .../pages/LoginPasswordUpdatePage.java | 25 +---- .../testsuite/pages/LogoutSessionsPage.java | 57 ++++++++++++ .../pages/SetupRecoveryAuthnCodesPage.java | 2 +- ...AbstractRequiredActionUpdateEmailTest.java | 20 +++- .../actions/RequiredActionTotpSetupTest.java | 86 +++++++++++++++-- .../RequiredActionUpdateEmailTest.java | 53 +++++++++-- ...onUpdateEmailTestWithVerificationTest.java | 59 ++++++++++-- ...BackwardsCompatibilityUserStorageTest.java | 13 ++- .../RecoveryAuthnCodesAuthenticatorTest.java | 93 +++++++++++++++++-- .../webauthn/pages/WebAuthnRegisterPage.java | 4 +- .../AppInitiatedActionWebAuthnTest.java | 61 +++++++++++- .../theme/base/login/login-config-totp.ftl | 7 +- .../login-recovery-authn-code-config.ftl | 8 +- .../base/login/login-update-password.ftl | 11 +-- .../theme/base/login/password-commons.ftl | 12 +++ .../theme/base/login/update-email.ftl | 3 + .../theme/base/login/webauthn-register.ftl | 5 +- 27 files changed, 536 insertions(+), 110 deletions(-) create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LogoutSessionsPage.java create mode 100644 themes/src/main/resources/theme/base/login/password-commons.ftl diff --git a/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java b/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java index bba9982529..f655d520b9 100755 --- a/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java +++ b/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java @@ -19,16 +19,25 @@ package org.keycloak.authentication; import com.google.common.collect.Sets; 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.http.HttpRequest; import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.utils.StringUtil; import java.util.Collections; import java.util.LinkedList; import java.util.List; +import java.util.Objects; import java.util.Set; +import java.util.stream.Collectors; import static org.keycloak.services.managers.AuthenticationManager.FORCED_REAUTHENTICATION; import static org.keycloak.services.managers.AuthenticationManager.SSO_AUTH; @@ -110,4 +119,35 @@ public class AuthenticatorUtil { return executions; } + /** + * Logouts all sessions that are different to the current authentication session + * managed in the action context. + * + * @param context The required action context + */ + public static void logoutOtherSessions(RequiredActionContext context) { + logoutOtherSessions(context.getSession(), context.getRealm(), context.getUser(), + context.getAuthenticationSession(), context.getConnection(), context.getHttpRequest()); + } + + /** + * Logouts all sessions that are different to the current authentication session + * managed in the action token context. + * + * @param context The required action token context + */ + public static void logoutOtherSessions(ActionTokenContext context) { + logoutOtherSessions(context.getSession(), context.getRealm(), context.getAuthenticationSession().getAuthenticatedUser(), + context.getAuthenticationSession(), context.getClientConnection(), context.getRequest()); + } + + private static void logoutOtherSessions(KeycloakSession session, RealmModel realm, UserModel user, + AuthenticationSessionModel authSession, ClientConnection conn, HttpRequest req) { + 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) + ); + } } diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/updateemail/UpdateEmailActionToken.java b/services/src/main/java/org/keycloak/authentication/actiontoken/updateemail/UpdateEmailActionToken.java index ac77f2a2b8..04b22589b7 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/updateemail/UpdateEmailActionToken.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/updateemail/UpdateEmailActionToken.java @@ -28,12 +28,19 @@ public class UpdateEmailActionToken extends DefaultActionToken { private String oldEmail; @JsonProperty("newEmail") private String newEmail; + @JsonProperty("logoutSessions") + private Boolean logoutSessions; - public UpdateEmailActionToken(String userId, int absoluteExpirationInSecs, String oldEmail, String newEmail, String clientId){ + public UpdateEmailActionToken(String userId, int absoluteExpirationInSecs, String oldEmail, String newEmail, String clientId) { + this(userId, absoluteExpirationInSecs, oldEmail, newEmail, clientId, null); + } + + public UpdateEmailActionToken(String userId, int absoluteExpirationInSecs, String oldEmail, String newEmail, String clientId, Boolean logoutSessions){ super(userId, TOKEN_TYPE, absoluteExpirationInSecs, null); this.oldEmail = oldEmail; this.newEmail = newEmail; this.issuedFor = clientId; + this.logoutSessions = Boolean.TRUE.equals(logoutSessions)? true : null; } private UpdateEmailActionToken(){ @@ -55,4 +62,12 @@ public class UpdateEmailActionToken extends DefaultActionToken { public void setNewEmail(String newEmail) { this.newEmail = newEmail; } + + public Boolean getLogoutSessions() { + return this.logoutSessions; + } + + public void setLogoutSessions(Boolean logoutSessions) { + this.logoutSessions = Boolean.TRUE.equals(logoutSessions)? true : null; + } } 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 7e9357a445..82436ad849 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 @@ -21,6 +21,7 @@ import java.util.List; import java.util.Objects; import jakarta.ws.rs.core.Response; import org.keycloak.TokenVerifier; +import org.keycloak.authentication.AuthenticatorUtil; import org.keycloak.authentication.actiontoken.AbstractActionTokenHandler; import org.keycloak.authentication.actiontoken.ActionTokenContext; import org.keycloak.authentication.actiontoken.TokenUtils; @@ -74,6 +75,10 @@ public class UpdateEmailActionTokenHandler extends AbstractActionTokenHandlerBill Burke @@ -95,9 +90,7 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac public void processAction(RequiredActionContext context) { EventBuilder event = context.getEvent(); AuthenticationSessionModel authSession = context.getAuthenticationSession(); - RealmModel realm = context.getRealm(); UserModel user = context.getUser(); - KeycloakSession session = context.getSession(); MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); event.event(EventType.UPDATE_PASSWORD); String passwordNew = formData.getFirst("password-new"); @@ -125,13 +118,8 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac return; } - if ("on".equals(formData.getFirst("logout-sessions"))) - { - 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(), - context.getConnection(), context.getHttpRequest().getHttpHeaders(), true)); + if ("on".equals(formData.getFirst("logout-sessions"))) { + AuthenticatorUtil.logoutOtherSessions(context); } try { diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java index c35f2a21b1..17e93ca86f 100644 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java @@ -18,6 +18,7 @@ package org.keycloak.authentication.requiredactions; import org.keycloak.Config; +import org.keycloak.authentication.AuthenticatorUtil; import org.keycloak.authentication.CredentialRegistrator; import org.keycloak.authentication.InitiatedActionSupport; import org.keycloak.authentication.RequiredActionContext; @@ -105,6 +106,10 @@ public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory return; } + if ("on".equals(formData.getFirst("logout-sessions"))) { + AuthenticatorUtil.logoutOtherSessions(context); + } + if (!CredentialHelper.createOTPCredential(context.getSession(), context.getRealm(), context.getUser(), challengeResponse, credentialModel)) { Response challenge = context.form() .setAttribute("mode", mode) diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java b/services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java index 208e9370c2..f12abb75ee 100644 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java @@ -35,6 +35,7 @@ import com.webauthn4j.data.AuthenticatorTransport; import org.jboss.logging.Logger; import org.keycloak.http.HttpRequest; import org.keycloak.WebAuthnConstants; +import org.keycloak.authentication.AuthenticatorUtil; import org.keycloak.authentication.CredentialRegistrator; import org.keycloak.authentication.InitiatedActionSupport; import org.keycloak.authentication.RequiredActionContext; @@ -52,6 +53,8 @@ import org.keycloak.events.Errors; import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserModel; import org.keycloak.models.WebAuthnPolicy; +import org.keycloak.models.credential.WebAuthnCredentialModel; +import org.keycloak.utils.StringUtil; import com.webauthn4j.converter.util.ObjectConverter; import com.webauthn4j.data.attestation.authenticator.AttestedCredentialData; @@ -73,8 +76,6 @@ import com.webauthn4j.validator.attestation.statement.tpm.TPMAttestationStatemen import com.webauthn4j.validator.attestation.statement.u2f.FIDOU2FAttestationStatementValidator; import com.webauthn4j.validator.attestation.trustworthiness.certpath.CertPathTrustworthinessValidator; import com.webauthn4j.validator.attestation.trustworthiness.self.DefaultSelfAttestationTrustworthinessValidator; -import org.keycloak.models.credential.WebAuthnCredentialModel; -import org.keycloak.utils.StringUtil; import static org.keycloak.WebAuthnConstants.REG_ERR_DETAIL_LABEL; import static org.keycloak.WebAuthnConstants.REG_ERR_LABEL; @@ -225,6 +226,10 @@ public class WebAuthnRegister implements RequiredActionProvider, CredentialRegis RegistrationParameters registrationParameters = new RegistrationParameters(serverProperty, isUserVerificationRequired); + if ("on".equals(params.getFirst("logout-sessions"))) { + AuthenticatorUtil.logoutOtherSessions(context); + } + WebAuthnRegistrationManager webAuthnRegistrationManager = createWebAuthnRegistrationManager(); try { // parse diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/UpdateEmailPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/UpdateEmailPage.java index 96392e3457..f0d5dbb0f2 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/UpdateEmailPage.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/UpdateEmailPage.java @@ -20,11 +20,12 @@ import static org.keycloak.testsuite.util.UIUtils.clickLink; import static org.keycloak.testsuite.util.UIUtils.getTextFromElement; import org.keycloak.models.UserModel; +import org.keycloak.testsuite.pages.LogoutSessionsPage; import org.openqa.selenium.NoSuchElementException; import org.openqa.selenium.WebElement; import org.openqa.selenium.support.FindBy; -public class UpdateEmailPage extends RequiredActions { +public class UpdateEmailPage extends LogoutSessionsPage { @FindBy(id = "email") private WebElement emailInput; @@ -38,22 +39,20 @@ public class UpdateEmailPage extends RequiredActions { @FindBy(css = "input[type='submit']") private WebElement submitActionButton; - @Override - public String getActionId() { - return UserModel.RequiredAction.UPDATE_EMAIL.name(); - } + @FindBy(css = "input[type='submit']") + private WebElement submitButton; @Override public boolean isCurrent() { return driver.getCurrentUrl().contains("login-actions/required-action") - && driver.getCurrentUrl().contains("execution=" + getActionId()); + && driver.getCurrentUrl().contains("execution=" + UserModel.RequiredAction.UPDATE_EMAIL.name()); } public void changeEmail(String email){ emailInput.clear(); emailInput.sendKeys(email); - submit(); + clickLink(submitButton); } public String getEmail() { @@ -84,4 +83,8 @@ public class UpdateEmailPage extends RequiredActions { clickLink(submitActionButton); } + @Override + public void open() throws Exception { + throw new UnsupportedOperationException(); + } } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginConfigTotpPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginConfigTotpPage.java index 28d74b823b..220c606ce6 100755 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginConfigTotpPage.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginConfigTotpPage.java @@ -25,7 +25,7 @@ import org.openqa.selenium.support.FindBy; /** * @author Stian Thorgersen */ -public class LoginConfigTotpPage extends AbstractPage { +public class LoginConfigTotpPage extends LogoutSessionsPage { @FindBy(id = "totpSecret") private WebElement totpSecret; diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPasswordUpdatePage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPasswordUpdatePage.java index 37ca7f9249..ce068e943a 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPasswordUpdatePage.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPasswordUpdatePage.java @@ -19,14 +19,12 @@ package org.keycloak.testsuite.pages; import org.openqa.selenium.WebElement; import org.openqa.selenium.support.FindBy; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; import static org.keycloak.testsuite.util.UIUtils.isElementVisible; /** * @author Stian Thorgersen */ -public class LoginPasswordUpdatePage extends LanguageComboboxAwarePage { +public class LoginPasswordUpdatePage extends LogoutSessionsPage { @FindBy(id = "password-new") private WebElement newPasswordInput; @@ -42,9 +40,6 @@ public class LoginPasswordUpdatePage extends LanguageComboboxAwarePage { @FindBy(className = "kc-feedback-text") private WebElement feedbackMessage; - - @FindBy(id = "logout-sessions") - private WebElement logoutSessionsCheckbox; @FindBy(name = "cancel-aia") private WebElement cancelAIAButton; @@ -76,24 +71,6 @@ public class LoginPasswordUpdatePage extends LanguageComboboxAwarePage { return feedbackMessage.getText(); } - public boolean isLogoutSessionDisplayed() { - return isElementVisible(logoutSessionsCheckbox); - } - - public boolean isLogoutSessionsChecked() { - return logoutSessionsCheckbox.isSelected(); - } - - public void checkLogoutSessions() { - assertFalse("Logout sessions is checked", isLogoutSessionsChecked()); - logoutSessionsCheckbox.click(); - } - - public void uncheckLogoutSessions() { - assertTrue("Logout sessions is not checked", isLogoutSessionsChecked()); - logoutSessionsCheckbox.click(); - } - public boolean isCancelDisplayed() { return isElementVisible(cancelAIAButton); } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LogoutSessionsPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LogoutSessionsPage.java new file mode 100644 index 0000000000..48b527c409 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LogoutSessionsPage.java @@ -0,0 +1,57 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.testsuite.pages; + +import org.junit.Assert; +import org.keycloak.testsuite.util.UIUtils; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; + +/** + *

A page that contains the logout other sessions checkbox.

+ * + * @author rmartinc + */ +public abstract class LogoutSessionsPage extends LanguageComboboxAwarePage { + + @FindBy(id = "logout-sessions") + private WebElement logoutSessionsCheckbox; + + @Override + public void assertCurrent() { + super.assertCurrent(); + Assert.assertTrue("The page doesn't display the logout other sessions checkbox", this.isLogoutSessionDisplayed()); + } + + public boolean isLogoutSessionDisplayed() { + return UIUtils.isElementVisible(logoutSessionsCheckbox); + } + + public boolean isLogoutSessionsChecked() { + return logoutSessionsCheckbox.isSelected(); + } + + public void checkLogoutSessions() { + Assert.assertFalse("Logout sessions is checked", isLogoutSessionsChecked()); + logoutSessionsCheckbox.click(); + } + + public void uncheckLogoutSessions() { + Assert.assertTrue("Logout sessions is not checked", isLogoutSessionsChecked()); + logoutSessionsCheckbox.click(); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/SetupRecoveryAuthnCodesPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/SetupRecoveryAuthnCodesPage.java index 76cc797ca8..7b0b6d21cb 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/SetupRecoveryAuthnCodesPage.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/SetupRecoveryAuthnCodesPage.java @@ -9,7 +9,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Scanner; -public class SetupRecoveryAuthnCodesPage extends LanguageComboboxAwarePage { +public class SetupRecoveryAuthnCodesPage extends LogoutSessionsPage { @FindBy(id = "kc-recovery-codes-list") private WebElement recoveryAuthnCodesList; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AbstractRequiredActionUpdateEmailTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AbstractRequiredActionUpdateEmailTest.java index efb773520d..91246a24ff 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AbstractRequiredActionUpdateEmailTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AbstractRequiredActionUpdateEmailTest.java @@ -18,12 +18,15 @@ package org.keycloak.testsuite.actions; import static org.junit.Assert.assertFalse; +import java.util.Arrays; +import org.jboss.arquillian.drone.api.annotation.Drone; import org.jboss.arquillian.graphene.page.Page; import org.junit.Assert; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.UserResource; import org.keycloak.common.Profile; import org.keycloak.models.UserModel; import org.keycloak.representations.idm.RealmRepresentation; @@ -35,7 +38,9 @@ import org.keycloak.testsuite.arquillian.annotation.EnableFeature; import org.keycloak.testsuite.auth.page.login.UpdateEmailPage; import org.keycloak.testsuite.pages.AppPage; import org.keycloak.testsuite.pages.LoginPage; +import org.keycloak.testsuite.util.SecondBrowser; import org.keycloak.testsuite.util.UserBuilder; +import org.openqa.selenium.WebDriver; @EnableFeature(Profile.Feature.UPDATE_EMAIL) public abstract class AbstractRequiredActionUpdateEmailTest extends AbstractTestRealmKeycloakTest { @@ -52,6 +57,10 @@ public abstract class AbstractRequiredActionUpdateEmailTest extends AbstractTest @Page protected AppPage appPage; + @Drone + @SecondBrowser + protected WebDriver driver2; + @Before public void beforeTest() { ApiUtil.removeUserByUsername(testRealm(), "test-user@localhost"); @@ -81,6 +90,13 @@ public abstract class AbstractRequiredActionUpdateEmailTest extends AbstractTest realmResource.update(realmRepresentation); } + protected void configureRequiredActionsToUser(String username, String... actions) { + UserResource userResource = ApiUtil.findUserByUsernameId(testRealm(), username); + UserRepresentation userRepresentation = userResource.toRepresentation(); + userRepresentation.setRequiredActions(Arrays.asList(actions)); + userResource.update(userRepresentation); + } + protected void prepareUser(UserRepresentation user) { } @@ -168,7 +184,7 @@ public abstract class AbstractRequiredActionUpdateEmailTest extends AbstractTest setRegistrationEmailAsUsername(testRealm(), true); try { - changeEmailUsingRequiredAction("new@localhost"); + changeEmailUsingRequiredAction("new@localhost", true); UserRepresentation user = ActionUtil.findUserWithAdminClient(adminClient, "new@localhost"); Assert.assertNotNull(user); @@ -177,5 +193,5 @@ public abstract class AbstractRequiredActionUpdateEmailTest extends AbstractTest } } - protected abstract void changeEmailUsingRequiredAction(String newEmail) throws Exception; + protected abstract void changeEmailUsingRequiredAction(String newEmail, boolean logoutOtherSessions) throws Exception; } 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 5cedb3c811..5e1e429783 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 @@ -16,6 +16,9 @@ */ 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.Assert; import org.junit.Before; @@ -34,6 +37,7 @@ import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RequiredActionProviderRepresentation; import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.idm.UserSessionRepresentation; import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; import org.keycloak.testsuite.admin.ApiUtil; @@ -48,12 +52,16 @@ import org.keycloak.testsuite.updaters.RealmAttributeUpdater; import org.keycloak.testsuite.util.AccountHelper; import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.RealmBuilder; +import org.keycloak.testsuite.util.SecondBrowser; import org.keycloak.testsuite.util.UserBuilder; import org.openqa.selenium.By; +import org.openqa.selenium.WebDriver; import java.io.IOException; +import java.util.Arrays; 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; @@ -79,14 +87,26 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest { testRealm.setResetPasswordAllowed(Boolean.TRUE); } + private void setOTPAuthRequirement(AuthenticationExecutionModel.Requirement requirement) { + adminClient.realm(TEST_REALM_NAME).flows().getExecutions("browser"). + stream().filter(execution -> execution.getDisplayName().equals("Browser - Conditional OTP")) + .forEach(execution -> { + execution.setRequirement(requirement.name()); + adminClient.realm("test").flows().updateExecutions("browser", execution); + }); + } + + private void configureRequiredActionsToUser(String username, String... actions) { + UserResource userResource = ApiUtil.findUserByUsernameId(testRealm(), username); + UserRepresentation userRepresentation = userResource.toRepresentation(); + userRepresentation.setRequiredActions(Arrays.asList(actions)); + userResource.update(userRepresentation); + } + @Before public void setOTPAuthRequired() { - adminClient.realm("test").flows().getExecutions("browser"). - stream().filter(execution -> execution.getDisplayName().equals("Browser - Conditional OTP")) - .forEach(execution -> - {execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED.name()); - adminClient.realm("test").flows().updateExecutions("browser", execution);}); + setOTPAuthRequirement(AuthenticationExecutionModel.Requirement.REQUIRED); ApiUtil.removeUserByUsername(testRealm(), "test-user@localhost"); UserRepresentation user = UserBuilder.create().enabled(true) @@ -94,7 +114,7 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest { .email("test-user@localhost") .firstName("Tom") .lastName("Brady") - .requiredAction(UserModel.RequiredAction.UPDATE_PROFILE.name()).build(); + .build(); ApiUtil.createUserAndResetPasswordWithAdminClient(testRealm(), user, "password"); } @@ -117,6 +137,10 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest { @Page protected RegisterPage registerPage; + @Drone + @SecondBrowser + private WebDriver driver2; + protected TimeBasedOTP totp = new TimeBasedOTP(); @Test @@ -602,4 +626,54 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest { } + @Test + public void testTotpLogoutOtherSessionsChecked() { + testTotpLogoutOtherSessions(true); + } + + @Test + public void testTotpLogoutOtherSessionsNotChecked() { + testTotpLogoutOtherSessions(false); + } + + private void testTotpLogoutOtherSessions(boolean logoutOtherSessions) { + // allow login via password without OTP forced + setOTPAuthRequirement(AuthenticationExecutionModel.Requirement.CONDITIONAL); + configureRequiredActionsToUser("test-user@localhost"); + + // login with the user using the second driver + 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 to configure totp + configureRequiredActionsToUser("test-user@localhost", UserModel.RequiredAction.CONFIGURE_TOTP.name()); + + // login and configure totp checking/unchecking the logout checkbox + loginPage.open(); + loginPage.login("test-user@localhost", "password"); + totpPage.assertCurrent(); + if (!logoutOtherSessions) { + totpPage.uncheckLogoutSessions(); + } + Assert.assertEquals(logoutOtherSessions, totpPage.isLogoutSessionsChecked()); + totpPage.configure(totp.generateTOTP(totpPage.getTotpSecret())); + assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + 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(); + + // assert old session is gone or is maintained + List 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())); + } + } } 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 85ecb69786..35e2466995 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 @@ -19,35 +19,66 @@ package org.keycloak.testsuite.actions; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import java.util.List; +import java.util.stream.Collectors; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.Assert; 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; import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.idm.UserSessionRepresentation; import org.keycloak.testsuite.pages.AppPage; +import org.keycloak.testsuite.util.OAuthClient; public class RequiredActionUpdateEmailTest extends AbstractRequiredActionUpdateEmailTest { @Override - protected void changeEmailUsingRequiredAction(String newEmail) { + protected void changeEmailUsingRequiredAction(String newEmail, boolean logoutOtherSessions) { loginPage.open(); loginPage.login("test-user@localhost", "password"); - updateEmailPage.assertCurrent(); + if (!logoutOtherSessions) { + updateEmailPage.uncheckLogoutSessions(); + } + Assert.assertEquals(logoutOtherSessions, updateEmailPage.isLogoutSessionsChecked()); updateEmailPage.changeEmail(newEmail); } - @Test - public void updateEmail() { - changeEmailUsingRequiredAction("new@localhost"); + private void updateEmail(boolean logoutOtherSessions) { + // 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 the action and change it + configureRequiredActionsToUser("test-user@localhost", UserModel.RequiredAction.UPDATE_EMAIL.name()); + changeEmailUsingRequiredAction("new@localhost", logoutOtherSessions); 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()); - events.expectLogin().assertEvent(); + EventRepresentation event2 = events.expectLogin().assertEvent(); + List 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())); + } // assert user is really updated in persistent store UserRepresentation user = ActionUtil.findUserWithAdminClient(adminClient, "test-user@localhost"); @@ -56,4 +87,14 @@ public class RequiredActionUpdateEmailTest extends AbstractRequiredActionUpdateE assertEquals("Brady", user.getLastName()); assertFalse(user.getRequiredActions().contains(UserModel.RequiredAction.UPDATE_EMAIL.name())); } + + @Test + public void updateEmailLogoutSessionsChecked() { + updateEmail(true); + } + + @Test + public void updateEmailLogoutSessionsNotChecked() { + updateEmail(false); + } } 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 a4dd213539..b784d2b4fc 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 @@ -20,23 +20,29 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; -import java.io.IOException; import jakarta.mail.Address; import jakarta.mail.Message; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; +import java.io.IOException; +import java.util.List; 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.events.Details; import org.keycloak.events.EventType; import org.keycloak.models.UserModel; +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.pages.ErrorPage; import org.keycloak.testsuite.pages.InfoPage; import org.keycloak.testsuite.util.GreenMailRule; import org.keycloak.testsuite.util.MailUtils; +import org.keycloak.testsuite.util.OAuthClient; public class RequiredActionUpdateEmailTestWithVerificationTest extends AbstractRequiredActionUpdateEmailTest { @@ -59,13 +65,17 @@ public class RequiredActionUpdateEmailTestWithVerificationTest extends AbstractR } @Override - protected void changeEmailUsingRequiredAction(String newEmail) throws Exception { + protected void changeEmailUsingRequiredAction(String newEmail, boolean logoutOtherSessions) throws Exception { loginPage.open(); loginPage.login("test-user@localhost", "password"); - updateEmailPage.assertCurrent(); - updateEmailPage.changeEmail(newEmail); + updateEmailPage.assertCurrent(); + if (!logoutOtherSessions) { + updateEmailPage.uncheckLogoutSessions(); + } + Assert.assertEquals(logoutOtherSessions, updateEmailPage.isLogoutSessionsChecked()); + updateEmailPage.changeEmail(newEmail); events.expect(EventType.SEND_VERIFY_EMAIL).detail(Details.EMAIL, newEmail).assertEvent(); UserRepresentation user = ActionUtil.findUserWithAdminClient(adminClient, "test-user@localhost"); @@ -78,19 +88,48 @@ public class RequiredActionUpdateEmailTestWithVerificationTest extends AbstractR assertEquals("The account email has been successfully updated to new@localhost.", infoPage.getInfo()); } - @Test - public void updateEmail() throws Exception { - changeEmailUsingRequiredAction("new@localhost"); + 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()); - events.expect(EventType.UPDATE_EMAIL) - .detail(Details.PREVIOUS_EMAIL, "test-user@localhost") - .detail(Details.UPDATED_EMAIL, "new@localhost"); + // 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(); + + 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()); assertFalse(user.getRequiredActions().contains(UserModel.RequiredAction.UPDATE_EMAIL.name())); } + @Test + public void updateEmailLogoutSessionsChecked() throws Exception { + updateEmail(true); + } + + @Test + public void updateEmailLogoutSessionsNotChecked() throws Exception { + updateEmail(false); + } + @Test public void confirmEmailUpdateAfterThirdPartyEmailUpdate() throws MessagingException, IOException { loginPage.open(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/BackwardsCompatibilityUserStorageTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/BackwardsCompatibilityUserStorageTest.java index 9c81ebb93c..7b69678b2d 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/BackwardsCompatibilityUserStorageTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/BackwardsCompatibilityUserStorageTest.java @@ -168,7 +168,7 @@ public class BackwardsCompatibilityUserStorageTest extends AbstractTestRealmKeyc getCleanup().addUserId(userId); // Setup OTP for the user - String totpSecret = setupOTPForUserWithRequiredAction(userId); + String totpSecret = setupOTPForUserWithRequiredAction(userId, true); // Assert user has OTP in the userStorage assertUserDontHaveDBCredentials(); @@ -209,7 +209,7 @@ public class BackwardsCompatibilityUserStorageTest extends AbstractTestRealmKeyc getCleanup().addUserId(userId); // Setup OTP - String totpSecret = setupOTPForUserWithRequiredAction(userId); + String totpSecret = setupOTPForUserWithRequiredAction(userId, true); assertUserDontHaveDBCredentials(); assertUserHasOTPCredentialInUserStorage(true); @@ -245,7 +245,7 @@ public class BackwardsCompatibilityUserStorageTest extends AbstractTestRealmKeyc String accountToken = tokenUtil.getToken(); // Setup OTP - String totpSecret = setupOTPForUserWithRequiredAction(userId); + String totpSecret = setupOTPForUserWithRequiredAction(userId, false); assertUserDontHaveDBCredentials(); assertUserHasOTPCredentialInUserStorage(true); @@ -281,7 +281,7 @@ public class BackwardsCompatibilityUserStorageTest extends AbstractTestRealmKeyc getCleanup().addUserId(userId); // Setup OTP for the user - setupOTPForUserWithRequiredAction(userId); + setupOTPForUserWithRequiredAction(userId, true); // Assert user has OTP in the userStorage assertUserDontHaveDBCredentials(); @@ -315,7 +315,7 @@ public class BackwardsCompatibilityUserStorageTest extends AbstractTestRealmKeyc } // return created totpSecret - private String setupOTPForUserWithRequiredAction(String userId) throws URISyntaxException, IOException { + private String setupOTPForUserWithRequiredAction(String userId, boolean logoutOtherSessions) throws URISyntaxException, IOException { // Add required action to the user to reset OTP UserResource user = testRealm().users().get(userId); UserRepresentation userRep = user.toRepresentation(); @@ -328,6 +328,9 @@ public class BackwardsCompatibilityUserStorageTest extends AbstractTestRealmKeyc testAppHelper.startLogin("otp1", "pass"); configureTotpRequiredActionPage.assertCurrent(); + if (!logoutOtherSessions) { + configureTotpRequiredActionPage.uncheckLogoutSessions(); + } String totpSecret = configureTotpRequiredActionPage.getTotpSecret(); configureTotpRequiredActionPage.configure(totp.generateTOTP(totpSecret)); appPage.assertCurrent(); 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 06b211ac3e..d16d13af82 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 @@ -1,25 +1,39 @@ package org.keycloak.testsuite.forms; +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.Assert; +import org.junit.FixMethodOrder; +import org.junit.Rule; import org.junit.Test; +import org.junit.runners.MethodSorters; +import org.keycloak.admin.client.resource.UserResource; import org.keycloak.authentication.AuthenticationFlow; import org.keycloak.authentication.authenticators.browser.RecoveryAuthnCodesFormAuthenticatorFactory; import org.keycloak.authentication.authenticators.browser.PasswordFormFactory; import org.keycloak.authentication.authenticators.browser.UsernameFormFactory; import org.keycloak.credential.CredentialModel; +import org.keycloak.events.Details; +import org.keycloak.events.EventType; import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.credential.RecoveryAuthnCodesCredentialModel; import org.keycloak.models.utils.RecoveryAuthnCodesUtils; +import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RequiredActionProviderSimpleRepresentation; +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; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver; import org.keycloak.testsuite.client.KeycloakTestingClient; +import org.keycloak.testsuite.pages.AppPage; import org.keycloak.testsuite.pages.EnterRecoveryAuthnCodePage; import org.keycloak.testsuite.pages.LoginPage; import org.keycloak.testsuite.pages.LoginUsernameOnlyPage; @@ -27,16 +41,19 @@ import org.keycloak.testsuite.pages.PasswordPage; import org.keycloak.testsuite.pages.SelectAuthenticatorPage; import org.keycloak.testsuite.pages.SetupRecoveryAuthnCodesPage; import org.keycloak.testsuite.util.FlowUtil; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.testsuite.util.SecondBrowser; import org.openqa.selenium.WebDriver; -import org.junit.Assert; import org.openqa.selenium.chrome.ChromeDriver; import org.openqa.selenium.firefox.FirefoxDriver; import java.util.Arrays; import java.util.List; +import java.util.stream.Collectors; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; +import static org.junit.Assert.assertEquals; import static org.keycloak.common.Profile.Feature.RECOVERY_CODES; /** @@ -44,6 +61,7 @@ import static org.keycloak.common.Profile.Feature.RECOVERY_CODES; * * @author Venkata Nukala */ +@FixMethodOrder(MethodSorters.NAME_ASCENDING) @EnableFeature(value = RECOVERY_CODES, skipRestart = true) public class RecoveryAuthnCodesAuthenticatorTest extends AbstractTestRealmKeycloakTest { @@ -51,9 +69,6 @@ public class RecoveryAuthnCodesAuthenticatorTest extends AbstractTestRealmKeyclo private static final int BRUTE_FORCE_FAIL_ATTEMPTS = 3; - @Drone - protected WebDriver driver; - @Page protected LoginPage loginPage; @@ -72,6 +87,16 @@ public class RecoveryAuthnCodesAuthenticatorTest extends AbstractTestRealmKeyclo @Page protected PasswordPage passwordPage; + @Page + protected AppPage appPage; + + @Drone + @SecondBrowser + private WebDriver driver2; + + @Rule + public AssertEvents events = new AssertEvents(this); + @Override public void configureTestRealm(RealmRepresentation testRealm) { @@ -97,12 +122,63 @@ public class RecoveryAuthnCodesAuthenticatorTest extends AbstractTestRealmKeyclo ); ApiUtil.removeUserByUsername(testRealm(), "test-user@localhost"); - String userId = createUser("test", "test-user@localhost", "password", UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name()); + createUser("test", "test-user@localhost", "password", UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name()); + } + + private void testSetupRecoveryAuthnCodesLogoutOtherSessions(boolean logoutOtherSessions) { + // login with the user using the second driver + 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 to recovery codes for the test user + UserRepresentation userRepresentation = testUser.toRepresentation(); + userRepresentation.setRequiredActions(Arrays.asList(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name())); + testUser.update(userRepresentation); + + // login and configure codes + loginPage.open(); + loginPage.login("test-user@localhost", "password"); + setupRecoveryAuthnCodesPage.assertCurrent(); + if (!logoutOtherSessions) { + setupRecoveryAuthnCodesPage.uncheckLogoutSessions(); + } + Assert.assertEquals(logoutOtherSessions, setupRecoveryAuthnCodesPage.isLogoutSessionsChecked()); + setupRecoveryAuthnCodesPage.clickSaveRecoveryAuthnCodesButton(); + assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType()); + 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)) + .detail(Details.USERNAME, "test-user@localhost").assertEvent(); + + // assert old session is gone or is maintained + List 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 + public void test01SetupRecoveryAuthnCodesLogoutOtherSessionsChecked() throws Exception { + testSetupRecoveryAuthnCodesLogoutOtherSessions(true); + } + + @Test + public void test02SetupRecoveryAuthnCodesLogoutOtherSessionsNotChecked() { + testSetupRecoveryAuthnCodesLogoutOtherSessions(false); } // In a sub-flow with alternative credential executors, test whether Recovery Authentication Codes are working @Test - public void testAuthenticateRecoveryAuthnCodes() { + public void test03AuthenticateRecoveryAuthnCodes() { try { configureBrowserFlowWithRecoveryAuthnCodes(testingClient); testRealm().flows().removeRequiredAction(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name()); @@ -146,7 +222,7 @@ public class RecoveryAuthnCodesAuthenticatorTest extends AbstractTestRealmKeyclo @Test @IgnoreBrowserDriver(FirefoxDriver.class) @IgnoreBrowserDriver(ChromeDriver.class) - public void testSetupRecoveryAuthnCodes() { + public void test04SetupRecoveryAuthnCodes() { try { configureBrowserFlowWithRecoveryAuthnCodes(testingClient); RequiredActionProviderSimpleRepresentation simpleRepresentation = new RequiredActionProviderSimpleRepresentation(); @@ -174,11 +250,10 @@ public class RecoveryAuthnCodesAuthenticatorTest extends AbstractTestRealmKeyclo } } - @Test @IgnoreBrowserDriver(FirefoxDriver.class) // TODO: https://github.com/keycloak/keycloak/issues/13543 @IgnoreBrowserDriver(ChromeDriver.class) - public void testBruteforceProtectionRecoveryAuthnCodes() { + public void test05BruteforceProtectionRecoveryAuthnCodes() { try { configureBrowserFlowWithRecoveryAuthnCodes(testingClient); RealmRepresentation rep = testRealm().toRepresentation(); diff --git a/testsuite/integration-arquillian/tests/other/webauthn/src/main/java/org/keycloak/testsuite/webauthn/pages/WebAuthnRegisterPage.java b/testsuite/integration-arquillian/tests/other/webauthn/src/main/java/org/keycloak/testsuite/webauthn/pages/WebAuthnRegisterPage.java index 8f1723f58a..425ab0e35c 100644 --- a/testsuite/integration-arquillian/tests/other/webauthn/src/main/java/org/keycloak/testsuite/webauthn/pages/WebAuthnRegisterPage.java +++ b/testsuite/integration-arquillian/tests/other/webauthn/src/main/java/org/keycloak/testsuite/webauthn/pages/WebAuthnRegisterPage.java @@ -18,7 +18,7 @@ package org.keycloak.testsuite.webauthn.pages; import org.hamcrest.CoreMatchers; -import org.keycloak.testsuite.pages.AbstractPage; +import org.keycloak.testsuite.pages.LogoutSessionsPage; import org.keycloak.testsuite.util.WaitUtils; import org.openqa.selenium.Alert; import org.openqa.selenium.NoSuchElementException; @@ -39,7 +39,7 @@ import static org.keycloak.testsuite.util.WaitUtils.waitForPageToLoad; * Page will be displayed after successful JS call of "navigator.credentials.create", which will register WebAuthn credential * with the browser */ -public class WebAuthnRegisterPage extends AbstractPage { +public class WebAuthnRegisterPage extends LogoutSessionsPage { public static final long ALERT_CHECK_TIMEOUT = 3; //seconds public static final long ALERT_DEFAULT_TIMEOUT = 60; //seconds diff --git a/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/AppInitiatedActionWebAuthnTest.java b/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/AppInitiatedActionWebAuthnTest.java index e6a5857ad8..44a8ec1136 100644 --- a/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/AppInitiatedActionWebAuthnTest.java +++ b/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/AppInitiatedActionWebAuthnTest.java @@ -16,6 +16,7 @@ */ package org.keycloak.testsuite.webauthn; +import org.jboss.arquillian.drone.api.annotation.Drone; import org.jboss.arquillian.graphene.page.Page; import org.junit.After; import org.junit.Before; @@ -28,25 +29,35 @@ import org.keycloak.authentication.authenticators.browser.WebAuthnPasswordlessAu import org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory; import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory; import org.keycloak.events.Details; +import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RequiredActionProviderRepresentation; +import org.keycloak.representations.idm.UserSessionRepresentation; import org.keycloak.testsuite.actions.AbstractAppInitiatedActionTest; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.pages.LoginUsernameOnlyPage; import org.keycloak.testsuite.pages.PasswordPage; +import org.keycloak.testsuite.updaters.RealmAttributeUpdater; import org.keycloak.testsuite.util.FlowUtil; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.testsuite.util.SecondBrowser; import org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions; import org.keycloak.testsuite.webauthn.authenticators.UseVirtualAuthenticators; import org.keycloak.testsuite.webauthn.authenticators.VirtualAuthenticatorManager; import org.keycloak.testsuite.webauthn.pages.WebAuthnRegisterPage; +import org.openqa.selenium.WebDriver; +import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.function.Supplier; +import java.util.stream.Collectors; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.junit.Assert.assertEquals; import static org.keycloak.models.AuthenticationExecutionModel.Requirement.ALTERNATIVE; import static org.keycloak.models.AuthenticationExecutionModel.Requirement.REQUIRED; import static org.keycloak.testsuite.util.BrowserDriverUtil.isDriverFirefox; @@ -72,6 +83,10 @@ public class AppInitiatedActionWebAuthnTest extends AbstractAppInitiatedActionTe @Page WebAuthnRegisterPage webAuthnRegisterPage; + @Drone + @SecondBrowser + private WebDriver driver2; + @Before @Override public void setUpVirtualAuthenticator() { @@ -150,8 +165,32 @@ public class AppInitiatedActionWebAuthnTest extends AbstractAppInitiatedActionTe } @Test - public void proceedSetupWebAuthn() { - loginUser(); + public void proceedSetupWebAuthnLogoutOtherSessionsChecked() throws IOException { + testWebAuthnLogoutOtherSessions(true); + } + + @Test + public void proceedSetupWebAuthnLogoutOtherSessionsNotChecked() throws IOException { + testWebAuthnLogoutOtherSessions(false); + } + + private void testWebAuthnLogoutOtherSessions(boolean logoutOtherSessions) throws IOException { + UserResource testUser = testRealm().users().get(findUser(DEFAULT_USERNAME).getId()); + + // perform a login using normal user/password form to have an old session + EventRepresentation event1; + try (RealmAttributeUpdater rau = new RealmAttributeUpdater(testRealm()) + .setBrowserFlow("browser") + .update()) { + OAuthClient oauth2 = new OAuthClient(); + oauth2.init(driver2); + oauth2.doLogin(DEFAULT_USERNAME, DEFAULT_PASSWORD); + event1 = events.expectLogin().assertEvent(); + assertEquals(1, testUser.getUserSessions().size()); + } + + EventRepresentation event2 = loginUser(); + assertEquals(2, testUser.getUserSessions().size()); doAIA(); @@ -163,15 +202,29 @@ public class AppInitiatedActionWebAuthnTest extends AbstractAppInitiatedActionTe final int credentialsCount = getCredentialCount.get(); webAuthnRegisterPage.assertCurrent(); + if (!logoutOtherSessions) { + webAuthnRegisterPage.uncheckLogoutSessions(); + } + assertThat(webAuthnRegisterPage.isLogoutSessionsChecked(), is(logoutOtherSessions)); webAuthnRegisterPage.clickRegister(); webAuthnRegisterPage.registerWebAuthnCredential("authenticator1"); assertKcActionStatus(SUCCESS); assertThat(getCredentialCount.get(), is(credentialsCount + 1)); + + List sessions = testUser.getUserSessions(); + if (logoutOtherSessions) { + assertThat(sessions.size(), is(1)); + assertThat(sessions.iterator().next().getId(), is(event2.getSessionId())); + } else { + assertThat(sessions.size(), is(2)); + assertThat(sessions.stream().map(UserSessionRepresentation::getId).collect(Collectors.toList()), + containsInAnyOrder(event1.getSessionId(), event2.getSessionId())); + } } - private void loginUser() { + private EventRepresentation loginUser() { usernamePage.open(); usernamePage.assertCurrent(); usernamePage.login(DEFAULT_USERNAME); @@ -179,7 +232,7 @@ public class AppInitiatedActionWebAuthnTest extends AbstractAppInitiatedActionTe passwordPage.assertCurrent(); passwordPage.login(DEFAULT_PASSWORD); - events.expectLogin() + return events.expectLogin() .detail(Details.USERNAME, DEFAULT_USERNAME) .assertEvent(); } diff --git a/themes/src/main/resources/theme/base/login/login-config-totp.ftl b/themes/src/main/resources/theme/base/login/login-config-totp.ftl index 80145856d8..5b21da49cf 100755 --- a/themes/src/main/resources/theme/base/login/login-config-totp.ftl +++ b/themes/src/main/resources/theme/base/login/login-config-totp.ftl @@ -1,4 +1,5 @@ <#import "template.ftl" as layout> +<#import "password-commons.ftl" as passwordCommons> <@layout.registrationLayout displayRequiredFields=false displayMessage=!messagesPerField.existsError('totp','userLabel'); section> <#if section = "header"> @@ -88,6 +89,10 @@ +
+ <@passwordCommons.logoutOtherSessions/> +
+ <#if isAppInitiatedAction??> - \ No newline at end of file + diff --git a/themes/src/main/resources/theme/base/login/login-recovery-authn-code-config.ftl b/themes/src/main/resources/theme/base/login/login-recovery-authn-code-config.ftl index 5bd3559d73..ef81710be4 100644 --- a/themes/src/main/resources/theme/base/login/login-recovery-authn-code-config.ftl +++ b/themes/src/main/resources/theme/base/login/login-recovery-authn-code-config.ftl @@ -1,4 +1,5 @@ <#import "template.ftl" as layout> +<#import "password-commons.ftl" as passwordCommons> <@layout.registrationLayout; section> <#if section = "header"> @@ -38,17 +39,18 @@ -
+
- +
-
+ + <@passwordCommons.logoutOtherSessions/> <#if isAppInitiatedAction??> +<#import "password-commons.ftl" as passwordCommons> <@layout.registrationLayout displayMessage=!messagesPerField.existsError('password','password-confirm'); section> <#if section = "header"> ${msg("updatePasswordTitle")} @@ -47,13 +48,7 @@
-
-
-
- -
-
-
+ <@passwordCommons.logoutOtherSessions/>
<#if isAppInitiatedAction??> @@ -66,4 +61,4 @@
- \ No newline at end of file + diff --git a/themes/src/main/resources/theme/base/login/password-commons.ftl b/themes/src/main/resources/theme/base/login/password-commons.ftl new file mode 100644 index 0000000000..233c781d75 --- /dev/null +++ b/themes/src/main/resources/theme/base/login/password-commons.ftl @@ -0,0 +1,12 @@ +<#macro logoutOtherSessions> +
+
+
+ +
+
+
+ diff --git a/themes/src/main/resources/theme/base/login/update-email.ftl b/themes/src/main/resources/theme/base/login/update-email.ftl index 4c85e5b5da..e63b012de5 100644 --- a/themes/src/main/resources/theme/base/login/update-email.ftl +++ b/themes/src/main/resources/theme/base/login/update-email.ftl @@ -1,4 +1,5 @@ <#import "template.ftl" as layout> +<#import "password-commons.ftl" as passwordCommons> <@layout.registrationLayout displayMessage=!messagesPerField.existsError('email'); section> <#if section = "header"> ${msg("updateEmailTitle")} @@ -28,6 +29,8 @@
+ <@passwordCommons.logoutOtherSessions/> +
<#if isAppInitiatedAction??> diff --git a/themes/src/main/resources/theme/base/login/webauthn-register.ftl b/themes/src/main/resources/theme/base/login/webauthn-register.ftl index 90461641fe..e0eff2256c 100644 --- a/themes/src/main/resources/theme/base/login/webauthn-register.ftl +++ b/themes/src/main/resources/theme/base/login/webauthn-register.ftl @@ -1,4 +1,6 @@ <#import "template.ftl" as layout> + <#import "password-commons.ftl" as passwordCommons> + <@layout.registrationLayout; section> <#if section = "title"> title @@ -15,6 +17,7 @@ + <@passwordCommons.logoutOtherSessions/>
@@ -188,4 +191,4 @@ - \ No newline at end of file +