KEYCLOAK-15262 Logout all sessions after password change
This commit is contained in:
parent
1bcb397a2f
commit
790b549cf9
6 changed files with 183 additions and 19 deletions
|
@ -30,16 +30,22 @@ 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.UserSessionModel;
|
||||
import org.keycloak.services.managers.AuthenticationManager;
|
||||
import org.keycloak.services.messages.Messages;
|
||||
import org.keycloak.services.validation.Validation;
|
||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||
|
||||
import javax.ws.rs.core.MultivaluedMap;
|
||||
import javax.ws.rs.core.Response;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
|
@ -88,18 +94,22 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac
|
|||
@Override
|
||||
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");
|
||||
String passwordConfirm = formData.getFirst("password-confirm");
|
||||
|
||||
EventBuilder errorEvent = event.clone().event(EventType.UPDATE_PASSWORD_ERROR)
|
||||
.client(context.getAuthenticationSession().getClient())
|
||||
.user(context.getAuthenticationSession().getAuthenticatedUser());
|
||||
.client(authSession.getClient())
|
||||
.user(authSession.getAuthenticatedUser());
|
||||
|
||||
if (Validation.isBlank(passwordNew)) {
|
||||
Response challenge = context.form()
|
||||
.setAttribute("username", context.getAuthenticationSession().getAuthenticatedUser().getUsername())
|
||||
.setAttribute("username", authSession.getAuthenticatedUser().getUsername())
|
||||
.setError(Messages.MISSING_PASSWORD)
|
||||
.createResponse(UserModel.RequiredAction.UPDATE_PASSWORD);
|
||||
context.challenge(challenge);
|
||||
|
@ -107,7 +117,7 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac
|
|||
return;
|
||||
} else if (!passwordNew.equals(passwordConfirm)) {
|
||||
Response challenge = context.form()
|
||||
.setAttribute("username", context.getAuthenticationSession().getAuthenticatedUser().getUsername())
|
||||
.setAttribute("username", authSession.getAuthenticatedUser().getUsername())
|
||||
.setError(Messages.NOTMATCH_PASSWORD)
|
||||
.createResponse(UserModel.RequiredAction.UPDATE_PASSWORD);
|
||||
context.challenge(challenge);
|
||||
|
@ -115,13 +125,24 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac
|
|||
return;
|
||||
}
|
||||
|
||||
if (getId().equals(authSession.getClientNote(Constants.KC_ACTION_EXECUTING))
|
||||
&& "on".equals(formData.getFirst("logout-sessions")))
|
||||
{
|
||||
List<UserSessionModel> sessions = session.sessions().getUserSessions(realm, user);
|
||||
for (UserSessionModel s : sessions) {
|
||||
if (!s.getId().equals(authSession.getParentSession().getId())) {
|
||||
AuthenticationManager.backchannelLogout(session, realm, s, session.getContext().getUri(), context.getConnection(), context.getHttpRequest().getHttpHeaders(), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
context.getSession().userCredentialManager().updateCredential(context.getRealm(), context.getUser(), UserCredentialModel.password(passwordNew, false));
|
||||
session.userCredentialManager().updateCredential(realm, user, UserCredentialModel.password(passwordNew, false));
|
||||
context.success();
|
||||
} catch (ModelException me) {
|
||||
errorEvent.detail(Details.REASON, me.getMessage()).error(Errors.PASSWORD_REJECTED);
|
||||
Response challenge = context.form()
|
||||
.setAttribute("username", context.getAuthenticationSession().getAuthenticatedUser().getUsername())
|
||||
.setAttribute("username", authSession.getAuthenticatedUser().getUsername())
|
||||
.setError(me.getMessage(), me.getParameters())
|
||||
.createResponse(UserModel.RequiredAction.UPDATE_PASSWORD);
|
||||
context.challenge(challenge);
|
||||
|
@ -129,7 +150,7 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac
|
|||
} catch (Exception ape) {
|
||||
errorEvent.detail(Details.REASON, ape.getMessage()).error(Errors.PASSWORD_REJECTED);
|
||||
Response challenge = context.form()
|
||||
.setAttribute("username", context.getAuthenticationSession().getAuthenticatedUser().getUsername())
|
||||
.setAttribute("username", authSession.getAuthenticatedUser().getUsername())
|
||||
.setError(ape.getMessage())
|
||||
.createResponse(UserModel.RequiredAction.UPDATE_PASSWORD);
|
||||
context.challenge(challenge);
|
||||
|
|
|
@ -16,10 +16,13 @@
|
|||
*/
|
||||
package org.keycloak.testsuite.pages;
|
||||
|
||||
import org.openqa.selenium.NoSuchElementException;
|
||||
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>
|
||||
*/
|
||||
|
@ -39,6 +42,9 @@ public class LoginPasswordUpdatePage extends LanguageComboboxAwarePage {
|
|||
|
||||
@FindBy(xpath = "//span[@class='kc-feedback-text']")
|
||||
private WebElement feedbackMessage;
|
||||
|
||||
@FindBy(id = "logout-sessions")
|
||||
private WebElement logoutSessionsCheckbox;
|
||||
|
||||
@FindBy(name = "cancel-aia")
|
||||
private WebElement cancelAIAButton;
|
||||
|
@ -70,12 +76,26 @@ 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() {
|
||||
try {
|
||||
return cancelAIAButton.isDisplayed();
|
||||
} catch (NoSuchElementException e) {
|
||||
return false;
|
||||
}
|
||||
return isElementVisible(cancelAIAButton);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -16,9 +16,9 @@
|
|||
*/
|
||||
package org.keycloak.testsuite.actions;
|
||||
|
||||
import org.jboss.arquillian.drone.api.annotation.Drone;
|
||||
import org.jboss.arquillian.graphene.page.Page;
|
||||
import org.junit.After;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.admin.client.resource.UserResource;
|
||||
|
@ -27,11 +27,17 @@ 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.admin.ApiUtil;
|
||||
import org.keycloak.testsuite.pages.AppPage.RequestType;
|
||||
import org.keycloak.testsuite.pages.LoginPasswordUpdatePage;
|
||||
import org.keycloak.testsuite.util.GreenMailRule;
|
||||
import org.keycloak.testsuite.util.OAuthClient;
|
||||
import org.keycloak.testsuite.util.SecondBrowser;
|
||||
import org.openqa.selenium.WebDriver;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
|
@ -55,6 +61,10 @@ public class AppInitiatedActionResetPasswordTest extends AbstractAppInitiatedAct
|
|||
@Page
|
||||
protected LoginPasswordUpdatePage changePasswordPage;
|
||||
|
||||
@Drone
|
||||
@SecondBrowser
|
||||
private WebDriver driver2;
|
||||
|
||||
@After
|
||||
public void after() {
|
||||
ApiUtil.resetUserPassword(testRealm().users().get(findUser("test-user@localhost").getId()), "password", false);
|
||||
|
@ -150,4 +160,61 @@ public class AppInitiatedActionResetPasswordTest extends AbstractAppInitiatedAct
|
|||
assertKcActionStatus("success");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void checkLogoutSessions() {
|
||||
OAuthClient oauth2 = new OAuthClient();
|
||||
oauth2.init(driver2);
|
||||
|
||||
loginPage.open();
|
||||
loginPage.login("test-user@localhost", "password");
|
||||
events.expectLogin().assertEvent();
|
||||
|
||||
UserResource testUser = testRealm().users().get(findUser("test-user@localhost").getId());
|
||||
List<UserSessionRepresentation> sessions = testUser.getUserSessions();
|
||||
assertEquals(1, sessions.size());
|
||||
final String firstSessionId = sessions.get(0).getId();
|
||||
|
||||
oauth2.doLogin("test-user@localhost", "password");
|
||||
events.expectLogin().assertEvent();
|
||||
assertEquals(2, testUser.getUserSessions().size());
|
||||
|
||||
doAIA();
|
||||
|
||||
changePasswordPage.assertCurrent();
|
||||
assertTrue("Logout sessions is checked by default", changePasswordPage.isLogoutSessionsChecked());
|
||||
changePasswordPage.changePassword("All Right Then, Keep Your Secrets", "All Right Then, Keep Your Secrets");
|
||||
events.expectRequiredAction(EventType.UPDATE_PASSWORD).assertEvent();
|
||||
assertKcActionStatus("success");
|
||||
|
||||
sessions = testUser.getUserSessions();
|
||||
assertEquals(1, sessions.size());
|
||||
assertEquals("Old session is still valid", firstSessionId, sessions.get(0).getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void uncheckLogoutSessions() {
|
||||
OAuthClient oauth2 = new OAuthClient();
|
||||
oauth2.init(driver2);
|
||||
|
||||
UserResource testUser = testRealm().users().get(findUser("test-user@localhost").getId());
|
||||
|
||||
loginPage.open();
|
||||
loginPage.login("test-user@localhost", "password");
|
||||
events.expectLogin().assertEvent();
|
||||
|
||||
oauth2.doLogin("test-user@localhost", "password");
|
||||
events.expectLogin().assertEvent();
|
||||
assertEquals(2, testUser.getUserSessions().size());
|
||||
|
||||
doAIA();
|
||||
|
||||
changePasswordPage.assertCurrent();
|
||||
changePasswordPage.uncheckLogoutSessions();
|
||||
changePasswordPage.changePassword("All Right Then, Keep Your Secrets", "All Right Then, Keep Your Secrets");
|
||||
events.expectRequiredAction(EventType.UPDATE_PASSWORD).assertEvent();
|
||||
assertKcActionStatus("success");
|
||||
|
||||
assertEquals(2, testUser.getUserSessions().size());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -16,25 +16,34 @@
|
|||
*/
|
||||
package org.keycloak.testsuite.actions;
|
||||
|
||||
import org.jboss.arquillian.drone.api.annotation.Drone;
|
||||
import org.jboss.arquillian.graphene.page.Page;
|
||||
import org.junit.After;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.admin.client.resource.UserResource;
|
||||
import org.keycloak.events.EventType;
|
||||
import org.keycloak.models.UserModel.RequiredAction;
|
||||
import org.keycloak.representations.idm.EventRepresentation;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.testsuite.AssertEvents;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
|
||||
import org.keycloak.testsuite.AssertEvents;
|
||||
import org.keycloak.testsuite.admin.ApiUtil;
|
||||
import org.keycloak.testsuite.pages.AppPage;
|
||||
import org.keycloak.testsuite.pages.AppPage.RequestType;
|
||||
import org.keycloak.testsuite.pages.LoginPage;
|
||||
import org.keycloak.testsuite.pages.LoginPasswordUpdatePage;
|
||||
import org.keycloak.testsuite.util.GreenMailRule;
|
||||
import org.keycloak.testsuite.util.OAuthClient;
|
||||
import org.keycloak.testsuite.util.SecondBrowser;
|
||||
import org.openqa.selenium.WebDriver;
|
||||
|
||||
import java.util.LinkedList;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
|
@ -44,9 +53,12 @@ public class RequiredActionResetPasswordTest extends AbstractTestRealmKeycloakTe
|
|||
@Override
|
||||
public void configureTestRealm(RealmRepresentation testRealm) {
|
||||
testRealm.setResetPasswordAllowed(Boolean.TRUE);
|
||||
ActionUtil.addRequiredActionForUser(testRealm, "test-user@localhost", RequiredAction.UPDATE_PASSWORD.name());
|
||||
}
|
||||
|
||||
@Drone
|
||||
@SecondBrowser
|
||||
private WebDriver driver2;
|
||||
|
||||
@Rule
|
||||
public AssertEvents events = new AssertEvents(this);
|
||||
|
||||
|
@ -62,9 +74,14 @@ public class RequiredActionResetPasswordTest extends AbstractTestRealmKeycloakTe
|
|||
@Page
|
||||
protected LoginPasswordUpdatePage changePasswordPage;
|
||||
|
||||
@After
|
||||
public void after() {
|
||||
ApiUtil.resetUserPassword(testRealm().users().get(findUser("test-user@localhost").getId()), "password", false);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tempPassword() throws Exception {
|
||||
requireUpdatePassword();
|
||||
loginPage.open();
|
||||
loginPage.login("test-user@localhost", "password");
|
||||
|
||||
|
@ -89,4 +106,37 @@ public class RequiredActionResetPasswordTest extends AbstractTestRealmKeycloakTe
|
|||
events.expectLogin().assertEvent();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void logoutSessionsCheckboxNotPresent() {
|
||||
OAuthClient oauth2 = new OAuthClient();
|
||||
oauth2.init(driver2);
|
||||
|
||||
UserResource testUser = testRealm().users().get(findUser("test-user@localhost").getId());
|
||||
|
||||
oauth2.doLogin("test-user@localhost", "password");
|
||||
events.expectLogin().assertEvent();
|
||||
assertEquals(1, testUser.getUserSessions().size());
|
||||
|
||||
requireUpdatePassword();
|
||||
|
||||
loginPage.open();
|
||||
loginPage.login("test-user@localhost", "password");
|
||||
changePasswordPage.assertCurrent();
|
||||
assertFalse(changePasswordPage.isLogoutSessionDisplayed());
|
||||
changePasswordPage.changePassword("All Right Then, Keep Your Secrets", "All Right Then, Keep Your Secrets");
|
||||
events.expectRequiredAction(EventType.UPDATE_PASSWORD).assertEvent();
|
||||
events.expectLogin().assertEvent();
|
||||
|
||||
assertEquals("All sessions are still active", 2, testUser.getUserSessions().size());
|
||||
}
|
||||
|
||||
private void requireUpdatePassword() {
|
||||
UserRepresentation userRep = findUser("test-user@localhost");
|
||||
if (userRep.getRequiredActions() == null) {
|
||||
userRep.setRequiredActions(new LinkedList<>());
|
||||
}
|
||||
userRep.getRequiredActions().add(RequiredAction.UPDATE_PASSWORD.name());
|
||||
testRealm().users().get(userRep.getId()).update(userRep);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -28,6 +28,11 @@
|
|||
<div class="${properties.kcFormGroupClass!}">
|
||||
<div id="kc-form-options" class="${properties.kcFormOptionsClass!}">
|
||||
<div class="${properties.kcFormOptionsWrapperClass!}">
|
||||
<#if isAppInitiatedAction??>
|
||||
<div class="checkbox">
|
||||
<label><input type="checkbox" id="logout-sessions" name="logout-sessions" value="on" checked> ${msg("logoutOtherSessions")}</label>
|
||||
</div>
|
||||
</#if>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -75,6 +75,7 @@ postal_code=Zip or Postal code
|
|||
country=Country
|
||||
emailVerified=Email verified
|
||||
gssDelegationCredential=GSS Delegation Credential
|
||||
logoutOtherSessions=Sign out from other devices
|
||||
|
||||
profileScopeConsentText=User profile
|
||||
emailScopeConsentText=Email address
|
||||
|
|
Loading…
Reference in a new issue