Provide an option to force login after reset credentials (#36856)

Closes #36844

Signed-off-by: rmartinc <rmartinc@redhat.com>


Co-authored-by: Ricardo Martin <rmartinc@redhat.com>
Co-authored-by: andymunro <48995441+andymunro@users.noreply.github.com>
Co-authored-by: Marek Posolda <mposolda@gmail.com>
Signed-off-by: Marek Posolda <mposolda@gmail.com>
This commit is contained in:
Ricardo Martin 2025-01-28 18:35:02 +01:00 committed by Marek Posolda
parent 4d54071551
commit 66a6248d51
7 changed files with 100 additions and 20 deletions

View File

@ -3,3 +3,9 @@
The X.509 authenticator has a new option `x509-cert-auth-crl-abort-if-non-updated` (*CRL abort if non updated* in the Admin Console) to abort the login if a CRL is configured to validate the certificate and the CRL is not updated in the time specified in the next update field. The new option defaults to `true` in the Admin Console. For more details about the CRL next update field, see link:https://datatracker.ietf.org/doc/html/rfc5280#section-5.1.2.5[RFC5280, Section-5.1.2.5].
The value `false` is maintained for compatibility with the previous behavior. Note that existing configurations will not have the new option and will act as if this option was set to `false`, but the Admin Console will add the default value `true` on edit.
= New option in Send Reset Email to force a login after reset credentials
The `reset-credential-email` (*Send Reset Email*) is the authenticator used in the *reset credentials* flow (*forgot password* feature) for sending the email to the user with the reset credentials token link. This authenticator now has a new option `force-login` (*Force login after reset*). When this option is set to `true`, the authenticator terminates the session and forces a new login.
For more details about this new option, see link:{adminguide_link}#enabling-forgot-password[Enable forgot password].

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -27,7 +27,7 @@ image:images/forgot-password-page.png[Forgot Password Page]
The text sent in the email is configurable. See link:{developerguide_link}[{developerguide_name}] for more information.
When users click the email link, {project_name} asks them to update their password, and if they have set up an OTP generator, {project_name} asks them to reconfigure the OTP generator. Depending on security requirements of your organization, you may not want users to reset their OTP generator through email.
When users click the email link, {project_name} asks them to update their password, and if they have set up an OTP generator, {project_name} asks them to reconfigure the OTP generator. By default, the flow maintains the user who is logged in if the same authentication session (same browser) was used. Depending on the security requirements of your organization, you may not want users to reset their OTP generator through email or you may want to force them to login again after the process.
To change this behavior, perform these steps:
@ -40,6 +40,11 @@ To change this behavior, perform these steps:
image:images/reset-credentials-flow.png[Reset Credentials Flow]
+
If you do not want to reset the OTP, set the `Reset - Conditional OTP` sub-flow requirement to *Disabled*.
+
.Send Reset Email Configuration
image:images/reset-credential-email-config.png[Send Reset Email Configuration]
+
If the user needs to login again after the password change, click the *Send Reset Email* settings icon in the flow, define an *Alias*, and enable *Force login after reset*.
. Click *Authentication* in the menu.
. Click the *Required actions* tab.
. Ensure *Update Password* is enabled.

View File

@ -30,10 +30,16 @@ import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.*;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.FormMessage;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.messages.Messages;
import org.keycloak.sessions.AuthenticationSessionCompoundId;
import org.keycloak.sessions.AuthenticationSessionModel;
@ -53,6 +59,7 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory
private static final Logger logger = Logger.getLogger(ResetCredentialEmail.class);
public static final String PROVIDER_ID = "reset-credential-email";
public static final String FORCE_LOGIN = "force-login";
@Override
public void authenticate(AuthenticationFlowContext context) {
@ -70,6 +77,11 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory
String actionTokenUserId = authenticationSession.getAuthNote(DefaultActionTokenKey.ACTION_TOKEN_USER_ID);
if (actionTokenUserId != null && Objects.equals(user.getId(), actionTokenUserId)) {
logger.debugf("Forget-password triggered when reauthenticating user after authentication via action token. Skipping " + PROVIDER_ID + " screen and using user '%s' ", user.getUsername());
if (context.getAuthenticatorConfig() != null
&& Boolean.parseBoolean(context.getAuthenticatorConfig().getConfig().get(FORCE_LOGIN))) {
// force end of auth session after the required actions
context.getAuthenticationSession().setAuthNote(AuthenticationManager.END_AFTER_REQUIRED_ACTIONS, "true");
}
context.success();
return;
}
@ -159,7 +171,7 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory
@Override
public boolean isConfigurable() {
return false;
return true;
}
public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
@ -183,7 +195,21 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return null;
return ProviderConfigurationBuilder.create()
.property()
.name(FORCE_LOGIN)
.label("Force login after reset")
.helpText(
"""
If this property is true, the user needs to login again after the reset credentials.
If this property is false, the user will be automatically logged in after the succesful
reset credentials when the same authentication session is used.
"""
)
.type(ProviderConfigProperty.BOOLEAN_TYPE)
.defaultValue(Boolean.FALSE.toString())
.add()
.build();
}
@Override

View File

@ -64,6 +64,12 @@ public class InfoPage extends LanguageComboboxAwarePage {
backToApplicationLink.click();
}
public String getBackToApplicationLink() {
return backToApplicationLink != null
? backToApplicationLink.getAttribute("href")
: null;
}
public void clickToContinueDe() {
clickToContinueDe.click();
}

View File

@ -220,7 +220,7 @@ public class InitialFlowsTest extends AbstractAuthenticationTest {
execs = new LinkedList<>();
addExecInfo(execs, "Choose User", "reset-credentials-choose-user", false, 0, 0, REQUIRED, null, new String[]{REQUIRED}, 10);
addExecInfo(execs, "Send Reset Email", "reset-credential-email", false, 0, 1, REQUIRED, null, new String[]{REQUIRED}, 20);
addExecInfo(execs, "Send Reset Email", "reset-credential-email", true, 0, 1, REQUIRED, null, new String[]{REQUIRED}, 20);
addExecInfo(execs, "Reset Password", "reset-password", false, 0, 2, REQUIRED, null, new String[]{REQUIRED, ALTERNATIVE, DISABLED}, 30);
addExecInfo(execs, "Reset - Conditional OTP", null, false, 0, 3, CONDITIONAL, true, new String[]{REQUIRED, ALTERNATIVE, DISABLED, CONDITIONAL}, 40);
addExecInfo(execs, "Condition - user configured", "conditional-user-configured", false, 1, 0, REQUIRED, null, new String[]{REQUIRED, DISABLED}, 10);

View File

@ -22,6 +22,7 @@ import org.jboss.arquillian.drone.api.annotation.Drone;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.authentication.actiontoken.resetcred.ResetCredentialsActionToken;
import org.keycloak.authentication.authenticators.resetcred.ResetCredentialEmail;
import org.jboss.arquillian.graphene.page.Page;
import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.common.util.KeycloakUriBuilder;
@ -31,7 +32,10 @@ import org.keycloak.events.EventType;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.Constants;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.models.utils.DefaultAuthenticationFlows;
import org.keycloak.models.utils.SystemClientUtil;
import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation;
import org.keycloak.representations.idm.AuthenticatorConfigRepresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
@ -332,6 +336,24 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
assertSecondPasswordResetFails(changePasswordUrl, oauth.getClientId()); // KC_RESTART doesn't exist, it was deleted after first successful reset-password flow was finished
}
@Test
public void resetPasswordForceLogin() throws IOException, MessagingException {
// add the force login option in the reset-credential-email authenticator
AuthenticationExecutionInfoRepresentation sendEmailExec = testRealm()
.flows()
.getExecutions(DefaultAuthenticationFlows.RESET_CREDENTIALS_FLOW)
.stream()
.filter(e -> ResetCredentialEmail.PROVIDER_ID.equals(e.getProviderId()))
.findAny().orElseThrow();
AuthenticatorConfigRepresentation config = new AuthenticatorConfigRepresentation();
config.setAlias("reset-password-config");
config.getConfig().put(ResetCredentialEmail.FORCE_LOGIN, Boolean.TRUE.toString());
String configId = ApiUtil.getCreatedId(testRealm().flows().newExecutionConfig(sendEmailExec.getId(), config));
getCleanup().addAuthenticationConfigId(configId);
resetPassword("login-test", "resetPassword", true);
}
@Test
public void resetPasswordTwiceInNewBrowser() throws IOException, MessagingException {
String changePasswordUrl = resetPassword("login-test");
@ -400,10 +422,14 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
}
private String resetPassword(String username) throws IOException, MessagingException {
return resetPassword(username, "resetPassword");
return resetPassword(username, "resetPassword", false);
}
private String resetPassword(String username, String password) throws IOException, MessagingException {
return resetPassword(username, password, false);
}
private String resetPassword(String username, String password, boolean relogin) throws IOException, MessagingException {
initiateResetPasswordFromResetPasswordPage(username);
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
@ -430,29 +456,40 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
events.expectRequiredAction(EventType.UPDATE_PASSWORD).detail(Details.CREDENTIAL_TYPE, PasswordCredentialModel.TYPE).user(userId).detail(Details.USERNAME, username.trim()).assertEvent();
events.expectRequiredAction(EventType.UPDATE_CREDENTIAL).detail(Details.CREDENTIAL_TYPE, PasswordCredentialModel.TYPE).user(userId).detail(Details.USERNAME, username.trim()).assertEvent();
assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
if (relogin) {
// relogin is forced therefore the info page should be displayed
Assert.assertEquals("Your account has been updated.", infoPage.getInfo());
String backToAppLink = infoPage.getBackToApplicationLink();
ClientRepresentation client = ApiUtil.findClientByClientId(adminClient.realm("test"), "test-app").toRepresentation();
Assert.assertEquals(backToAppLink, client.getBaseUrl());
loginPage.open();
loginPage.assertCurrent();
} else {
// continue to app because it is the same browser and auth session exists
assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
EventRepresentation loginEvent = events.expectLogin().user(userId).detail(Details.USERNAME, username.trim()).assertEvent();
String sessionId = loginEvent.getSessionId();
EventRepresentation loginEvent = events.expectLogin().user(userId).detail(Details.USERNAME, username.trim()).assertEvent();
String sessionId = loginEvent.getSessionId();
OAuthClient.AccessTokenResponse tokenResponse = sendTokenRequestAndGetResponse(loginEvent);
oauth.idTokenHint(tokenResponse.getIdToken()).openLogout();
OAuthClient.AccessTokenResponse tokenResponse = sendTokenRequestAndGetResponse(loginEvent);
oauth.idTokenHint(tokenResponse.getIdToken()).openLogout();
events.expectLogout(sessionId).user(userId).session(sessionId).assertEvent();
events.expectLogout(sessionId).user(userId).session(sessionId).assertEvent();
loginPage.open();
loginPage.open();
loginPage.login("login-test", password);
loginPage.login("login-test", password);
loginEvent = events.expectLogin().user(userId).detail(Details.USERNAME, "login-test").assertEvent();
sessionId = loginEvent.getSessionId();
loginEvent = events.expectLogin().user(userId).detail(Details.USERNAME, "login-test").assertEvent();
sessionId = loginEvent.getSessionId();
assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
tokenResponse = sendTokenRequestAndGetResponse(loginEvent);
oauth.idTokenHint(tokenResponse.getIdToken()).openLogout();
tokenResponse = sendTokenRequestAndGetResponse(loginEvent);
oauth.idTokenHint(tokenResponse.getIdToken()).openLogout();
events.expectLogout(sessionId).user(userId).session(sessionId).assertEvent();
events.expectLogout(sessionId).user(userId).session(sessionId).assertEvent();
}
return changePasswordUrl;
}