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:
parent
c803d8fe26
commit
ee35cfe478
27 changed files with 536 additions and 110 deletions
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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")}" />
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue