Skip to content

Commit

Permalink
KEYCLOAK-15262 Logout all sessions after password change
Browse files Browse the repository at this point in the history
  • Loading branch information
vmuzikar authored and Bruno Oliveira da Silva committed Sep 18, 2020
1 parent 1bcb397 commit 790b549
Show file tree
Hide file tree
Showing 6 changed files with 183 additions and 19 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,22 @@
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;

/**
Expand Down Expand Up @@ -88,48 +94,63 @@ public void requiredActionChallenge(RequiredActionContext context) {
@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);
errorEvent.error(Errors.PASSWORD_MISSING);
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);
errorEvent.error(Errors.PASSWORD_CONFIRM_ERROR);
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);
return;
} 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
*/
Expand All @@ -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;
Expand Down Expand Up @@ -70,12 +76,26 @@ public String getFeedbackMessage() {
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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,11 +27,17 @@
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;

Expand All @@ -55,6 +61,10 @@ public void configureTestRealm(RealmRepresentation testRealm) {
@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);
Expand Down Expand Up @@ -150,4 +160,61 @@ public void resetPasswordUserHasUpdatePasswordRequiredAction() throws Exception
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());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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);

Expand All @@ -62,9 +74,14 @@ public void configureTestRealm(RealmRepresentation testRealm) {
@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");

Expand All @@ -89,4 +106,37 @@ public void tempPassword() throws Exception {
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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 790b549

Please sign in to comment.