KEYCLOAK-15262 Logout all sessions after password change

This commit is contained in:
vmuzikar 2020-09-14 16:06:33 +02:00 committed by Bruno Oliveira da Silva
parent 1bcb397a2f
commit 790b549cf9
6 changed files with 183 additions and 19 deletions

View file

@ -30,16 +30,22 @@ import org.keycloak.events.Details;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder; import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.ModelException; import org.keycloak.models.ModelException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserCredentialModel; import org.keycloak.models.UserCredentialModel;
import org.keycloak.models.UserModel; 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.messages.Messages;
import org.keycloak.services.validation.Validation; import org.keycloak.services.validation.Validation;
import org.keycloak.sessions.AuthenticationSessionModel;
import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import java.util.List;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/** /**
@ -88,18 +94,22 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac
@Override @Override
public void processAction(RequiredActionContext context) { public void processAction(RequiredActionContext context) {
EventBuilder event = context.getEvent(); 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(); MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
event.event(EventType.UPDATE_PASSWORD); event.event(EventType.UPDATE_PASSWORD);
String passwordNew = formData.getFirst("password-new"); String passwordNew = formData.getFirst("password-new");
String passwordConfirm = formData.getFirst("password-confirm"); String passwordConfirm = formData.getFirst("password-confirm");
EventBuilder errorEvent = event.clone().event(EventType.UPDATE_PASSWORD_ERROR) EventBuilder errorEvent = event.clone().event(EventType.UPDATE_PASSWORD_ERROR)
.client(context.getAuthenticationSession().getClient()) .client(authSession.getClient())
.user(context.getAuthenticationSession().getAuthenticatedUser()); .user(authSession.getAuthenticatedUser());
if (Validation.isBlank(passwordNew)) { if (Validation.isBlank(passwordNew)) {
Response challenge = context.form() Response challenge = context.form()
.setAttribute("username", context.getAuthenticationSession().getAuthenticatedUser().getUsername()) .setAttribute("username", authSession.getAuthenticatedUser().getUsername())
.setError(Messages.MISSING_PASSWORD) .setError(Messages.MISSING_PASSWORD)
.createResponse(UserModel.RequiredAction.UPDATE_PASSWORD); .createResponse(UserModel.RequiredAction.UPDATE_PASSWORD);
context.challenge(challenge); context.challenge(challenge);
@ -107,7 +117,7 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac
return; return;
} else if (!passwordNew.equals(passwordConfirm)) { } else if (!passwordNew.equals(passwordConfirm)) {
Response challenge = context.form() Response challenge = context.form()
.setAttribute("username", context.getAuthenticationSession().getAuthenticatedUser().getUsername()) .setAttribute("username", authSession.getAuthenticatedUser().getUsername())
.setError(Messages.NOTMATCH_PASSWORD) .setError(Messages.NOTMATCH_PASSWORD)
.createResponse(UserModel.RequiredAction.UPDATE_PASSWORD); .createResponse(UserModel.RequiredAction.UPDATE_PASSWORD);
context.challenge(challenge); context.challenge(challenge);
@ -115,13 +125,24 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac
return; 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 { try {
context.getSession().userCredentialManager().updateCredential(context.getRealm(), context.getUser(), UserCredentialModel.password(passwordNew, false)); session.userCredentialManager().updateCredential(realm, user, UserCredentialModel.password(passwordNew, false));
context.success(); context.success();
} catch (ModelException me) { } catch (ModelException me) {
errorEvent.detail(Details.REASON, me.getMessage()).error(Errors.PASSWORD_REJECTED); errorEvent.detail(Details.REASON, me.getMessage()).error(Errors.PASSWORD_REJECTED);
Response challenge = context.form() Response challenge = context.form()
.setAttribute("username", context.getAuthenticationSession().getAuthenticatedUser().getUsername()) .setAttribute("username", authSession.getAuthenticatedUser().getUsername())
.setError(me.getMessage(), me.getParameters()) .setError(me.getMessage(), me.getParameters())
.createResponse(UserModel.RequiredAction.UPDATE_PASSWORD); .createResponse(UserModel.RequiredAction.UPDATE_PASSWORD);
context.challenge(challenge); context.challenge(challenge);
@ -129,7 +150,7 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac
} catch (Exception ape) { } catch (Exception ape) {
errorEvent.detail(Details.REASON, ape.getMessage()).error(Errors.PASSWORD_REJECTED); errorEvent.detail(Details.REASON, ape.getMessage()).error(Errors.PASSWORD_REJECTED);
Response challenge = context.form() Response challenge = context.form()
.setAttribute("username", context.getAuthenticationSession().getAuthenticatedUser().getUsername()) .setAttribute("username", authSession.getAuthenticatedUser().getUsername())
.setError(ape.getMessage()) .setError(ape.getMessage())
.createResponse(UserModel.RequiredAction.UPDATE_PASSWORD); .createResponse(UserModel.RequiredAction.UPDATE_PASSWORD);
context.challenge(challenge); context.challenge(challenge);

View file

@ -16,10 +16,13 @@
*/ */
package org.keycloak.testsuite.pages; package org.keycloak.testsuite.pages;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebElement; import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy; 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> * @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']") @FindBy(xpath = "//span[@class='kc-feedback-text']")
private WebElement feedbackMessage; private WebElement feedbackMessage;
@FindBy(id = "logout-sessions")
private WebElement logoutSessionsCheckbox;
@FindBy(name = "cancel-aia") @FindBy(name = "cancel-aia")
private WebElement cancelAIAButton; private WebElement cancelAIAButton;
@ -70,12 +76,26 @@ public class LoginPasswordUpdatePage extends LanguageComboboxAwarePage {
return feedbackMessage.getText(); 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() { public boolean isCancelDisplayed() {
try { return isElementVisible(cancelAIAButton);
return cancelAIAButton.isDisplayed();
} catch (NoSuchElementException e) {
return false;
}
} }
} }

View file

@ -16,9 +16,9 @@
*/ */
package org.keycloak.testsuite.actions; package org.keycloak.testsuite.actions;
import org.jboss.arquillian.drone.api.annotation.Drone;
import org.jboss.arquillian.graphene.page.Page; import org.jboss.arquillian.graphene.page.Page;
import org.junit.After; import org.junit.After;
import org.junit.Assert;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.keycloak.admin.client.resource.UserResource; 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.EventRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.idm.UserSessionRepresentation;
import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.pages.AppPage.RequestType;
import org.keycloak.testsuite.pages.LoginPasswordUpdatePage; import org.keycloak.testsuite.pages.LoginPasswordUpdatePage;
import org.keycloak.testsuite.util.GreenMailRule; 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.assertFalse;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
@ -55,6 +61,10 @@ public class AppInitiatedActionResetPasswordTest extends AbstractAppInitiatedAct
@Page @Page
protected LoginPasswordUpdatePage changePasswordPage; protected LoginPasswordUpdatePage changePasswordPage;
@Drone
@SecondBrowser
private WebDriver driver2;
@After @After
public void after() { public void after() {
ApiUtil.resetUserPassword(testRealm().users().get(findUser("test-user@localhost").getId()), "password", false); ApiUtil.resetUserPassword(testRealm().users().get(findUser("test-user@localhost").getId()), "password", false);
@ -150,4 +160,61 @@ public class AppInitiatedActionResetPasswordTest extends AbstractAppInitiatedAct
assertKcActionStatus("success"); 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());
}
} }

View file

@ -16,25 +16,34 @@
*/ */
package org.keycloak.testsuite.actions; package org.keycloak.testsuite.actions;
import org.jboss.arquillian.drone.api.annotation.Drone;
import org.jboss.arquillian.graphene.page.Page; import org.jboss.arquillian.graphene.page.Page;
import org.junit.After;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
import org.keycloak.models.UserModel.RequiredAction; import org.keycloak.models.UserModel.RequiredAction;
import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; 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.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.pages.AppPage; import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.AppPage.RequestType; import org.keycloak.testsuite.pages.AppPage.RequestType;
import org.keycloak.testsuite.pages.LoginPage; import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.LoginPasswordUpdatePage; import org.keycloak.testsuite.pages.LoginPasswordUpdatePage;
import org.keycloak.testsuite.util.GreenMailRule; 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.assertFalse;
import static org.junit.Assert.assertTrue;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -44,9 +53,12 @@ public class RequiredActionResetPasswordTest extends AbstractTestRealmKeycloakTe
@Override @Override
public void configureTestRealm(RealmRepresentation testRealm) { public void configureTestRealm(RealmRepresentation testRealm) {
testRealm.setResetPasswordAllowed(Boolean.TRUE); testRealm.setResetPasswordAllowed(Boolean.TRUE);
ActionUtil.addRequiredActionForUser(testRealm, "test-user@localhost", RequiredAction.UPDATE_PASSWORD.name());
} }
@Drone
@SecondBrowser
private WebDriver driver2;
@Rule @Rule
public AssertEvents events = new AssertEvents(this); public AssertEvents events = new AssertEvents(this);
@ -62,9 +74,14 @@ public class RequiredActionResetPasswordTest extends AbstractTestRealmKeycloakTe
@Page @Page
protected LoginPasswordUpdatePage changePasswordPage; protected LoginPasswordUpdatePage changePasswordPage;
@After
public void after() {
ApiUtil.resetUserPassword(testRealm().users().get(findUser("test-user@localhost").getId()), "password", false);
}
@Test @Test
public void tempPassword() throws Exception { public void tempPassword() throws Exception {
requireUpdatePassword();
loginPage.open(); loginPage.open();
loginPage.login("test-user@localhost", "password"); loginPage.login("test-user@localhost", "password");
@ -89,4 +106,37 @@ public class RequiredActionResetPasswordTest extends AbstractTestRealmKeycloakTe
events.expectLogin().assertEvent(); 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);
}
} }

View file

@ -28,6 +28,11 @@
<div class="${properties.kcFormGroupClass!}"> <div class="${properties.kcFormGroupClass!}">
<div id="kc-form-options" class="${properties.kcFormOptionsClass!}"> <div id="kc-form-options" class="${properties.kcFormOptionsClass!}">
<div class="${properties.kcFormOptionsWrapperClass!}"> <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>
</div> </div>

View file

@ -75,6 +75,7 @@ postal_code=Zip or Postal code
country=Country country=Country
emailVerified=Email verified emailVerified=Email verified
gssDelegationCredential=GSS Delegation Credential gssDelegationCredential=GSS Delegation Credential
logoutOtherSessions=Sign out from other devices
profileScopeConsentText=User profile profileScopeConsentText=User profile
emailScopeConsentText=Email address emailScopeConsentText=Email address