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
This commit is contained in:
Ricardo Martin 2023-07-26 11:34:19 +02:00 committed by GitHub
parent c803d8fe26
commit ee35cfe478
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 536 additions and 110 deletions

View file

@ -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<? extends DefaultActionToken> 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)
);
}
}

View file

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

View file

@ -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 AbstractActionTokenHandler<Up
UpdateEmail.updateEmailNow(tokenContext.getEvent(), user, emailUpdateValidationResult);
if (Boolean.TRUE.equals(token.getLogoutSessions())) {
AuthenticatorUtil.logoutOtherSessions(tokenContext);
}
tokenContext.getEvent().success();
// verify user email as we know it is valid as this entry point would never have gotten here.

View file

@ -4,6 +4,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.keycloak.Config;
import org.keycloak.authentication.AuthenticatorUtil;
import org.keycloak.authentication.InitiatedActionSupport;
import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.authentication.RequiredActionFactory;
@ -92,6 +93,10 @@ public class RecoveryAuthnCodesAction implements RequiredActionProvider, Require
RecoveryAuthnCodesCredentialModel credentialModel = createFromValues(generatedCodes, generatedAtTime, generatedUserLabel);
if ("on".equals(httpReqParamsMap.getFirst("logout-sessions"))) {
AuthenticatorUtil.logoutOtherSessions(reqActionContext);
}
recoveryCodeCredentialProvider.createCredential(reqActionContext.getRealm(), reqActionContext.getUser(),
credentialModel);

View file

@ -25,6 +25,7 @@ import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.UriInfo;
import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.authentication.AuthenticatorUtil;
import org.keycloak.authentication.InitiatedActionSupport;
import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.authentication.RequiredActionFactory;
@ -97,16 +98,20 @@ public class UpdateEmail implements RequiredActionProvider, RequiredActionFactor
return;
}
final boolean logoutSessions = "on".equals(formData.getFirst("logout-sessions"));
if (!realm.isVerifyEmail() || Validation.isBlank(newEmail)
|| Objects.equals(user.getEmail(), newEmail) && user.isEmailVerified()) {
if (logoutSessions) {
AuthenticatorUtil.logoutOtherSessions(context);
}
updateEmailWithoutConfirmation(context, emailUpdateValidationResult);
return;
}
sendEmailUpdateConfirmation(context);
sendEmailUpdateConfirmation(context, logoutSessions);
}
private void sendEmailUpdateConfirmation(RequiredActionContext context) {
private void sendEmailUpdateConfirmation(RequiredActionContext context, boolean logoutSessions) {
UserModel user = context.getUser();
String oldEmail = user.getEmail();
String newEmail = context.getHttpRequest().getDecodedFormParameters().getFirst(UserModel.EMAIL);
@ -119,7 +124,7 @@ public class UpdateEmail implements RequiredActionProvider, RequiredActionFactor
AuthenticationSessionModel authenticationSession = context.getAuthenticationSession();
UpdateEmailActionToken actionToken = new UpdateEmailActionToken(user.getId(), Time.currentTime() + validityInSecs,
oldEmail, newEmail, authenticationSession.getClient().getClientId());
oldEmail, newEmail, authenticationSession.getClient().getClientId(), logoutSessions);
String link = Urls
.actionTokenBuilder(uriInfo.getBaseUri(), actionToken.serialize(session, realm, uriInfo),

View file

@ -29,24 +29,19 @@ import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.ModelException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserCredentialModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.FormMessage;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation;
import org.keycloak.sessions.AuthenticationSessionModel;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
@ -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<String, String> 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 {

View file

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

View file

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

View file

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

View file

@ -25,7 +25,7 @@ import org.openqa.selenium.support.FindBy;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
public class LoginConfigTotpPage extends AbstractPage {
public class LoginConfigTotpPage extends LogoutSessionsPage {
@FindBy(id = "totpSecret")
private WebElement totpSecret;

View file

@ -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 <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
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);
}

View file

@ -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;
/**
* <p>A page that contains the logout other sessions checkbox.</p>
*
* @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();
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <a href="mailto:vnukala@redhat.com">Venkata Nukala</a>
*/
@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<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
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();

View file

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

View file

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

View file

@ -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 @@
</div>
</div>
<div class="${properties.kcFormGroupClass!}">
<@passwordCommons.logoutOtherSessions/>
</div>
<#if isAppInitiatedAction??>
<input type="submit"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}"
@ -105,4 +110,4 @@
</#if>
</form>
</#if>
</@layout.registrationLayout>
</@layout.registrationLayout>

View file

@ -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 @@
</div>
<!-- confirmation checkbox -->
<div class="${properties.kcCheckClass} ${properties.kcRecoveryCodesConfirmation}">
<div class="${properties.kcFormOptionsClass!}">
<input class="${properties.kcCheckInputClass}" type="checkbox" id="kcRecoveryCodesConfirmationCheck" name="kcRecoveryCodesConfirmationCheck"
onchange="document.getElementById('saveRecoveryAuthnCodesBtn').disabled = !this.checked;"
/>
<label class="${properties.kcCheckLabelClass}" for="kcRecoveryCodesConfirmationCheck">${msg("recovery-codes-confirmation-message")}</label>
<label for="kcRecoveryCodesConfirmationCheck">${msg("recovery-codes-confirmation-message")}</label>
</div>
<form action="${url.loginAction}" class="${properties.kcFormClass!}" id="kc-recovery-codes-settings-form" method="post">
<form action="${url.loginAction}" class="${properties.kcFormGroupClass!}" id="kc-recovery-codes-settings-form" method="post">
<input type="hidden" name="generatedRecoveryAuthnCodes" value="${recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesAsString}" />
<input type="hidden" name="generatedAt" value="${recoveryAuthnCodesConfigBean.generatedAt?c}" />
<input type="hidden" id="userLabel" name="userLabel" value="${msg("recovery-codes-label-default")}" />
<@passwordCommons.logoutOtherSessions/>
<#if isAppInitiatedAction??>
<input type="submit"

View file

@ -1,4 +1,5 @@
<#import "template.ftl" as layout>
<#import "password-commons.ftl" as passwordCommons>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('password','password-confirm'); section>
<#if section = "header">
${msg("updatePasswordTitle")}
@ -47,13 +48,7 @@
</div>
<div class="${properties.kcFormGroupClass!}">
<div id="kc-form-options" class="${properties.kcFormOptionsClass!}">
<div class="${properties.kcFormOptionsWrapperClass!}">
<div class="checkbox">
<label><input type="checkbox" id="logout-sessions" name="logout-sessions" value="on" checked> ${msg("logoutOtherSessions")}</label>
</div>
</div>
</div>
<@passwordCommons.logoutOtherSessions/>
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
<#if isAppInitiatedAction??>
@ -66,4 +61,4 @@
</div>
</form>
</#if>
</@layout.registrationLayout>
</@layout.registrationLayout>

View file

@ -0,0 +1,12 @@
<#macro logoutOtherSessions>
<div id="kc-form-options" class="${properties.kcFormOptionsClass!}">
<div class="${properties.kcFormOptionsWrapperClass!}">
<div class="checkbox">
<label>
<input type="checkbox" id="logout-sessions" name="logout-sessions" value="on" checked>
${msg("logoutOtherSessions")}
</label>
</div>
</div>
</div>
</#macro>

View file

@ -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 @@
</div>
</div>
<@passwordCommons.logoutOtherSessions/>
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
<#if isAppInitiatedAction??>
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}" type="submit" value="${msg("doSubmit")}" />

View file

@ -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 @@
<input type="hidden" id="authenticatorLabel" name="authenticatorLabel"/>
<input type="hidden" id="transports" name="transports"/>
<input type="hidden" id="error" name="error"/>
<@passwordCommons.logoutOtherSessions/>
</div>
</form>
@ -188,4 +191,4 @@
</#if>
</#if>
</@layout.registrationLayout>
</@layout.registrationLayout>