Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.sessions.AuthenticationSessionModel;

import static org.keycloak.authentication.authenticators.resetcred.ResetCredentialChooseUser.RESET_CREDENTIAL_USER_CHOSEN;

/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
Expand Down Expand Up @@ -87,6 +89,7 @@ public void authenticate(AuthenticationFlowContext context) {
MultivaluedMap<String, String> formData = new MultivaluedHashMap<>();
String loginHint = context.getAuthenticationSession().getClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM);

clearUserIfComingFromResetPassword(context);
String rememberMeUsername = AuthenticationManager.getRememberMeUsername(context.getSession());

if (context.getUser() != null) {
Expand Down Expand Up @@ -119,6 +122,13 @@ public void authenticate(AuthenticationFlowContext context) {
context.challenge(challengeResponse);
}

private void clearUserIfComingFromResetPassword(AuthenticationFlowContext context) {
if ("true".equals(context.getAuthenticationSession().getAuthNote(RESET_CREDENTIAL_USER_CHOSEN))) {
context.clearUser();
context.getAuthenticationSession().removeAuthNote(RESET_CREDENTIAL_USER_CHOSEN);
}
}

@Override
public boolean requiresUser() {
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ public class ResetCredentialChooseUser implements Authenticator, AuthenticatorFa
private static final Logger logger = Logger.getLogger(ResetCredentialChooseUser.class);

public static final String PROVIDER_ID = "reset-credentials-choose-user";
public static final String RESET_CREDENTIAL_USER_CHOSEN = "RESET_CREDENTIAL_USER_CHOSEN";

@Override
public void authenticate(AuthenticationFlowContext context) {
Expand Down Expand Up @@ -109,7 +110,7 @@ public void action(AuthenticationFlowContext context) {
}

username = username.trim();

RealmModel realm = context.getRealm();
UserModel user = context.getSession().users().getUserByUsername(realm, username);
if (user == null && realm.isLoginWithEmailAllowed() && username.contains("@")) {
Expand All @@ -131,6 +132,7 @@ public void action(AuthenticationFlowContext context) {
.user(user).error(Errors.USER_DISABLED);
context.clearUser();
} else {
context.getAuthenticationSession().setAuthNote(RESET_CREDENTIAL_USER_CHOSEN, "true");
context.setUser(user);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,10 +196,10 @@ protected Set<String> getLinkedBrokerAliases(KeycloakSession session, RealmModel
.map(FederatedIdentityModel::getIdentityProvider)
.collect(Collectors.toSet());

if (!federatedIdentities.isEmpty() || organizationsDisabled(realm))
if (!federatedIdentities.isEmpty() || organizationsDisabled(realm)) {
// if orgs are enabled, we don't want to return an empty set - we want the organization IDPs to be shown if those are available.
result = new HashSet<>(federatedIdentities);

}
}
}
return result;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ public class LoginPasswordResetPage extends AbstractLoginPage {
@FindBy(id = "kc-reset-password-form")
private WebElement formResetPassword;

@FindBy(partialLinkText = "Back to Login")
private WebElement backToLogin;

public LoginPasswordResetPage(ManagedWebDriver driver) {
super(driver);
}
Expand All @@ -27,6 +30,10 @@ public void changePassword(String username) {
submitButton.click();
}

public void backToLogin() {
backToLogin.click();
}

public String getFormUrl() {
return formResetPassword.getAttribute("action");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ public class RegisterPage extends AbstractLoginPage {
@FindBy(css = "input[type=\"submit\"]")
private WebElement submitButton;

@FindBy(linkText = "« Back to Login")
private WebElement backToLoginLink;

public RegisterPage(ManagedWebDriver driver) {
super(driver);
}
Expand Down Expand Up @@ -187,4 +190,8 @@ public boolean isPasswordPresent() {
public String getExpectedPageId() {
return "login-register";
}

public void clickBackToLogin() {
backToLoginLink.click();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
package org.keycloak.tests.forms;

import java.io.IOException;

import jakarta.mail.internet.MimeMessage;

import org.keycloak.broker.oidc.OIDCIdentityProviderFactory;
import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.testframework.annotations.InjectEvents;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.events.EventAssertion;
import org.keycloak.testframework.events.Events;
import org.keycloak.testframework.injection.LifeCycle;
import org.keycloak.testframework.mail.MailServer;
import org.keycloak.testframework.mail.annotations.InjectMailServer;
import org.keycloak.testframework.oauth.OAuthClient;
import org.keycloak.testframework.oauth.annotations.InjectOAuthClient;
import org.keycloak.testframework.realm.ClientBuilder;
import org.keycloak.testframework.realm.ClientConfig;
import org.keycloak.testframework.realm.IdentityProviderBuilder;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.RealmBuilder;
import org.keycloak.testframework.realm.RealmConfig;
import org.keycloak.testframework.realm.UserBuilder;
import org.keycloak.testframework.ui.annotations.InjectPage;
import org.keycloak.testframework.ui.annotations.InjectWebDriver;
import org.keycloak.testframework.ui.page.LoginPage;
import org.keycloak.testframework.ui.page.LoginPasswordResetPage;
import org.keycloak.testframework.ui.page.LoginPasswordUpdatePage;
import org.keycloak.testframework.ui.page.RegisterPage;
import org.keycloak.testframework.ui.webdriver.ManagedWebDriver;
import org.keycloak.tests.utils.MailUtils;
import org.keycloak.testsuite.util.MailServerConfiguration;

import org.junit.jupiter.api.Test;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;

@KeycloakIntegrationTest
public class ResetPasswordTest {


protected static final String CONSUMER_REALM_NAME = "consumer";
protected static final String PROVIDER_REALM_NAME = "provider";
protected static final String IDP_ALIAS = "test-identity-provider";
protected static final String IDP_CLIENT_ID = "test-idp-client";
protected static final String IDP_CLIENT_SECRET = "test-idp-secret";
protected static final String USER_LOGIN = "testuser";
protected static final String USER_EMAIL = "spam@vnagy.eu";
protected static final String USER_PASSWORD = "password";
protected static final String BROKER_APP_CLIENT_ID = "broker-app";
protected static final String BASE_URL = "http://localhost:8080";

@InjectRealm(ref = PROVIDER_REALM_NAME, config = ProviderRealmConfig.class, lifecycle = LifeCycle.METHOD)
protected ManagedRealm providerRealm;

@InjectRealm(ref = CONSUMER_REALM_NAME, config = ConsumerRealmConfig.class, lifecycle = LifeCycle.METHOD)
protected ManagedRealm consumerRealm;

@InjectOAuthClient(realmRef = CONSUMER_REALM_NAME, config = BrokerAppClientConfig.class, lifecycle = LifeCycle.METHOD)
protected OAuthClient oauth;

@InjectPage
protected LoginPage loginPage;

@InjectPage
protected LoginPasswordResetPage resetPasswordPage;

@InjectPage
protected LoginPasswordUpdatePage updatePasswordPage;

@InjectPage
protected RegisterPage registerPage;

@InjectWebDriver
protected ManagedWebDriver driver;

@InjectMailServer
MailServer mailServer;

@InjectEvents(realmRef = CONSUMER_REALM_NAME)
protected Events events;

@Test
public void shouldOfferAllOidcOptionOnLoginPageUserTriesToResetTheirPasswordAndGoesBack() {
IdentityProviderRepresentation idp = IdentityProviderBuilder.create()
.providerId(OIDCIdentityProviderFactory.PROVIDER_ID)
.alias(IDP_ALIAS)
.attribute("clientId", IDP_CLIENT_ID)
.attribute("clientSecret", IDP_CLIENT_SECRET)
.attribute(IdentityProviderModel.SYNC_MODE, "IMPORT")
.attribute("authorizationUrl", BASE_URL + "/realms/" + PROVIDER_REALM_NAME + "/protocol/openid-connect/auth")
.attribute("tokenUrl", BASE_URL + "/realms/" + PROVIDER_REALM_NAME + "/protocol/openid-connect/token")
.attribute("jwksUrl", BASE_URL + "/realms/" + PROVIDER_REALM_NAME + "/protocol/openid-connect/certs")
.attribute("logoutUrl", BASE_URL + "/realms/" + PROVIDER_REALM_NAME + "/protocol/openid-connect/logout")
.build();

consumerRealm.admin().identityProviders().create(idp).close();
consumerRealm.cleanup().add(r -> r.identityProviders().get(IDP_ALIAS).remove());

oauth.loginForm().open();
loginPage.assertCurrent();
loginPage.resetPassword();

resetPasswordPage.assertCurrent();
resetPasswordPage.backToLogin();
String urlWhenBackFromRegistrationPage = driver.getCurrentUrl();
loginPage.assertCurrent();
assertDoesNotThrow(() -> loginPage.findSocialButton(IDP_ALIAS));

loginPage.resetPassword();
resetPasswordPage.changePassword(USER_LOGIN);
driver.driver().navigate().back();
driver.driver().navigate().back();
String urlWhenWentBackFromResetPassword = driver.getCurrentUrl();
assertEquals(
"The user clicks the back button twice. Their browser sends them to the same URL where they were previously",
urlWhenBackFromRegistrationPage, urlWhenWentBackFromResetPassword
);
loginPage.assertCurrent();
assertDoesNotThrow(() -> loginPage.findSocialButton(IDP_ALIAS));
}

@Test
public void testLoginPageClearsUserFromContextIfUserNavigatesBackFromResetPassword() {
oauth.openLoginForm();
loginPage.clickRegister();
registerPage.clickBackToLogin();
loginPage.assertCurrent();

loginPage.resetPassword();
resetPasswordPage.assertCurrent();
resetPasswordPage.changePassword(USER_LOGIN);

driver.driver().navigate().back();
driver.driver().navigate().back();
// we're at the login page now, and if we go back, the register page opens correctly
driver.driver().navigate().back();

registerPage.assertCurrent();
}

@Test
public void resetPasswordEmailLinkWorksAfterNavigatingBackToLoginPage() throws IOException {
final var user = consumerRealm.admin().users().search(USER_LOGIN).get(0);
oauth.openLoginForm();
loginPage.resetPassword();
resetPasswordPage.assertCurrent();
resetPasswordPage.backToLogin();

String urlWhenBackFromRegistrationPage = driver.getCurrentUrl();

loginPage.assertCurrent();
loginPage.resetPassword();
resetPasswordPage.assertCurrent();

resetPasswordPage.changePassword(USER_LOGIN);

EventRepresentation sendResetPasswordEvent = events.poll();
EventAssertion.assertSuccess(sendResetPasswordEvent)
.type(EventType.SEND_RESET_PASSWORD)
.sessionId(sendResetPasswordEvent.getSessionId())
.userId(user.getId())
.details(Details.USERNAME, USER_LOGIN)
.details(Details.EMAIL, USER_EMAIL);


MimeMessage message = mailServer.getReceivedMessages()[0];
String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);

// Navigate back to the login page, which triggers UsernamePasswordForm to clear the user from the auth session
driver.driver().navigate().back();
driver.driver().navigate().back();
String urlWhenWentBackFromResetPassword = driver.getCurrentUrl();
assertEquals(urlWhenBackFromRegistrationPage, urlWhenWentBackFromResetPassword);
loginPage.assertCurrent();

events.clear();
driver.driver().navigate().to(changePasswordUrl.trim());

updatePasswordPage.assertCurrent();
assertEquals("You need to change your password.", updatePasswordPage.getFeedbackMessage());
updatePasswordPage.changePassword("resetPassword", "resetPassword");

EventRepresentation updatePasswordEvent = events.poll();
EventAssertion.assertSuccess(updatePasswordEvent)
.type(EventType.UPDATE_PASSWORD)
.details(Details.CREDENTIAL_TYPE, PasswordCredentialModel.TYPE)
.details(Details.USERNAME, USER_LOGIN)
.userId(user.getId());


EventRepresentation updateCredentialEvent = events.poll();
EventAssertion.assertSuccess(updateCredentialEvent)
.type(EventType.UPDATE_CREDENTIAL)
.details(Details.CREDENTIAL_TYPE, PasswordCredentialModel.TYPE)
.details(Details.USERNAME, USER_LOGIN)
.userId(user.getId());

assertTrue(driver.page().getPageSource().contains("Happy days"));
}

static class ConsumerRealmConfig implements RealmConfig {
@Override
public RealmBuilder configure(RealmBuilder realm) {
return realm
.users(UserBuilder.create(USER_LOGIN)
.name("Vilmos", "Szabó-Nagy")
.email(USER_EMAIL)
.emailVerified(true)
.password(USER_PASSWORD))
.resetPasswordAllowed(true)
.registrationAllowed(true)
.smtp(MailServerConfiguration.HOST, Integer.parseInt(MailServerConfiguration.PORT), MailServerConfiguration.FROM);
}
}


static class ProviderRealmConfig implements RealmConfig {
@Override
public RealmBuilder configure(RealmBuilder realm) {
return realm
.users(UserBuilder.create(USER_LOGIN)
.name("Vilmos", "Szabó-Nagy")
.email(USER_EMAIL)
.emailVerified(true)
.password(USER_PASSWORD))
.clients(ClientBuilder.create()
.clientId(IDP_CLIENT_ID)
.secret(IDP_CLIENT_SECRET)
.redirectUris(BASE_URL + "/realms/" + CONSUMER_REALM_NAME + "/broker/" + IDP_ALIAS + "/endpoint*")
.build());
}
}

static class BrokerAppClientConfig implements ClientConfig {
@Override
public ClientBuilder configure(ClientBuilder client) {
return client
.clientId(BROKER_APP_CLIENT_ID)
.publicClient()
.redirectUris(BASE_URL + "/*");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,6 @@ public void shouldOfferOidcOptionOnLoginPageAfterUserTriedToLogInButDecidedNotTo
final var socialButton = this.loginPage.findSocialButton(bc.getIDPAlias());
}


@Test
public void testDisplayName() {

Expand Down
Loading
Loading