diff --git a/docs/documentation/release_notes/index.adoc b/docs/documentation/release_notes/index.adoc index aa7c436645..3dcaa31082 100644 --- a/docs/documentation/release_notes/index.adoc +++ b/docs/documentation/release_notes/index.adoc @@ -13,6 +13,9 @@ include::topics/templates/document-attributes.adoc[] :release_header_latest_link: {releasenotes_link_latest} include::topics/templates/release-header.adoc[] +== {project_name_full} 26.1.3 +include::topics/26_1_3.adoc[leveloffset=2] + == {project_name_full} 26.1.1 include::topics/26_1_1.adoc[leveloffset=2] diff --git a/docs/documentation/release_notes/topics/26_1_3.adoc b/docs/documentation/release_notes/topics/26_1_3.adoc new file mode 100644 index 0000000000..c428b5bb88 --- /dev/null +++ b/docs/documentation/release_notes/topics/26_1_3.adoc @@ -0,0 +1,5 @@ += Send Reset Email force login again for federated users after reset credentials + +In <> a new configuration option was added to the `reset-credential-email` (*Send Reset Email*) authenticator to allow changing the default behavior after the reset credentials flow. Now the option `force-login` (*Force login after reset*) is adding a third configuration value `only-federated`, which means that the force login is true for federated users and false for the internal database users. The new behavior is now the default. This way all users managed by user federation providers, whose implementation can be not so tightly integrated with {project_name}, are forced to login again after the reset credentials flow to avoid any issue. This change in behavior is due to the secure by default policy. + +For more information, see link:{adminguide_link}#enabling-forgot-password[Enable forgot password]. \ No newline at end of file diff --git a/docs/documentation/server_admin/images/reset-credential-email-config.png b/docs/documentation/server_admin/images/reset-credential-email-config.png index bac153ce74..be0e6e48a3 100644 Binary files a/docs/documentation/server_admin/images/reset-credential-email-config.png and b/docs/documentation/server_admin/images/reset-credential-email-config.png differ diff --git a/docs/documentation/server_admin/topics/login-settings/forgot-password.adoc b/docs/documentation/server_admin/topics/login-settings/forgot-password.adoc index c725921870..5b37e6e9fe 100644 --- a/docs/documentation/server_admin/topics/login-settings/forgot-password.adoc +++ b/docs/documentation/server_admin/topics/login-settings/forgot-password.adoc @@ -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. 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. +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. For security reasons, the flow forces federated users to login again after the reset credentials and keeps internal database users logged in if the same authentication session (same browser) is used. Depending on the security requirements of your organization, you can change the default behavior. To change this behavior, perform these steps: @@ -44,7 +44,7 @@ If you do not want to reset the OTP, set the `Reset - Conditional OTP` sub-flow .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*. +If you want to change default behavior for the force login option, click the *Send Reset Email* settings icon in the flow, define an *Alias*, and select the best *Force login after reset* option for you (`true`, always force re-authentication, `false`, keep the user logged in if the same browser was used, `only-federated`, default value that forces login again only for federated users). . Click *Authentication* in the menu. . Click the *Required actions* tab. . Ensure *Update Password* is enabled. diff --git a/docs/documentation/upgrading/topics/changes/changes-26_1_3.adoc b/docs/documentation/upgrading/topics/changes/changes-26_1_3.adoc new file mode 100644 index 0000000000..218186baa7 --- /dev/null +++ b/docs/documentation/upgrading/topics/changes/changes-26_1_3.adoc @@ -0,0 +1,11 @@ +== Notable changes + +Notable changes where an internal behavior changed to prevent common misconfigurations, fix bugs or simplify running {project_name}. + +=== Send Reset Email force login again for federated users after reset credentials + +Previously the reset credentials flow (*forgot password* feature) kept the user logged in after the reset credentials if the same authentication session (same browser) was used. For federated user providers this behavior can be a security issue. Imagine a provider implementation that detects the user as *enabled*, performs the password change successfully but the validation of the user password fails for some reason. In this scenario the reset credentials flow allowed a user to be logged in after the successful password change that would have not been allowed to login using the normal browser flow. This scenario is not a common case but should be avoided by default. + +For this reason now the authenticator `reset-credential-email` (*Send Reset Email*) has a new configuration option called `force-login` (*Force login after reset*) with values `true` (always force the login), `false` (previous behavior that keeps the user logged in if the same authentication session is used), and `only-federated` (default value that forces federated users to authenticate again and keeps previous behavior for users stored in {project_name}'s internal database). + +For more information about changing this option, see link:{adminguide_link}#enabling-forgot-password[Enable forgot password]. \ No newline at end of file diff --git a/docs/documentation/upgrading/topics/changes/changes.adoc b/docs/documentation/upgrading/topics/changes/changes.adoc index a64dc4ba60..ad9d6b9005 100644 --- a/docs/documentation/upgrading/topics/changes/changes.adoc +++ b/docs/documentation/upgrading/topics/changes/changes.adoc @@ -1,6 +1,10 @@ [[migration-changes]] == Migration Changes +=== Migrating to 26.1.3 + +include::changes-26_1_3.adoc[leveloffset=2] + === Migrating to 26.1.0 include::changes-26_1_0.adoc[leveloffset=2] diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java b/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java index 49bc688b52..bc1e4adec2 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java @@ -31,6 +31,7 @@ import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; import org.keycloak.events.EventType; import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.AuthenticatorConfigModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.RealmModel; @@ -43,6 +44,7 @@ import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.messages.Messages; import org.keycloak.sessions.AuthenticationSessionCompoundId; import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.storage.StorageId; import java.util.*; import jakarta.ws.rs.core.Response; @@ -60,6 +62,7 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory public static final String PROVIDER_ID = "reset-credential-email"; public static final String FORCE_LOGIN = "force-login"; + public static final String FEDERATED_OPTION = "only-federated"; @Override public void authenticate(AuthenticationFlowContext context) { @@ -77,8 +80,7 @@ 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))) { + if (forceLogin(context.getAuthenticatorConfig(), user)) { // force end of auth session after the required actions context.getAuthenticationSession().setAuthNote(AuthenticationManager.END_AFTER_REQUIRED_ACTIONS, "true"); } @@ -204,10 +206,13 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory 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. + If this property is only-federated (default), only federated users will be forced to login again, + users stored in the internal database will be logged in if using the same authentication session. """ ) - .type(ProviderConfigProperty.BOOLEAN_TYPE) - .defaultValue(Boolean.FALSE.toString()) + .type(ProviderConfigProperty.LIST_TYPE) + .options(Arrays.asList(Boolean.TRUE.toString(), Boolean.FALSE.toString(), FEDERATED_OPTION)) + .defaultValue(FEDERATED_OPTION) .add() .build(); } @@ -236,4 +241,19 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory public String getId() { return PROVIDER_ID; } + + private boolean forceLogin(AuthenticatorConfigModel config, UserModel user) { + final String forceLogin = config != null? config.getConfig().get(FORCE_LOGIN) : null; + if (forceLogin == null || FEDERATED_OPTION.equalsIgnoreCase(forceLogin)) { + // default is only-federated, return true only for federated users + return !StorageId.isLocalStorage(user.getId()) || user.getFederationLink() != null; + } else if (Boolean.TRUE.toString().equalsIgnoreCase(forceLogin)) { + return Boolean.TRUE; + } else if (Boolean.FALSE.toString().equalsIgnoreCase(forceLogin)) { + return Boolean.FALSE; + } else { + logger.warnf("Invalid value for force-login option: %s", forceLogin); + throw new AuthenticationFlowException("Invalid value for force-login option: " + forceLogin, AuthenticationFlowError.INTERNAL_ERROR); + } + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ResetPasswordTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ResetPasswordTest.java index d6bb5252b7..a66d9b68c7 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ResetPasswordTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ResetPasswordTest.java @@ -25,6 +25,7 @@ import org.keycloak.authentication.actiontoken.resetcred.ResetCredentialsActionT 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.MultivaluedHashMap; import org.keycloak.common.util.KeycloakUriBuilder; import org.keycloak.events.Details; import org.keycloak.events.Errors; @@ -37,14 +38,19 @@ 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.ComponentRepresentation; import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.services.resources.LoginActionsService; +import org.keycloak.storage.StorageId; +import org.keycloak.storage.UserStorageProvider; +import org.keycloak.storage.UserStorageProviderModel; import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver; +import org.keycloak.testsuite.federation.UserMapStorageFactory; import org.keycloak.testsuite.federation.kerberos.AbstractKerberosTest; import org.keycloak.testsuite.pages.AppPage; import org.keycloak.testsuite.pages.AppPage.RequestType; @@ -339,21 +345,41 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest { @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); + configureForceLogin(Boolean.TRUE.toString()); resetPassword("login-test", "resetPassword", true); } + @Test + public void resetPasswordForceLoginFederatedUser() throws IOException, MessagingException { + // create the example map storage user federation + ComponentRepresentation memProvider = new ComponentRepresentation(); + memProvider.setName(UserStorageProvider.class.getName()); + memProvider.setProviderId(UserMapStorageFactory.PROVIDER_ID); + memProvider.setProviderType(UserStorageProvider.class.getName()); + memProvider.setConfig(new MultivaluedHashMap<>()); + memProvider.getConfig().putSingle("priority", Integer.toString(0)); + memProvider.getConfig().putSingle(UserStorageProviderModel.IMPORT_ENABLED, Boolean.toString(false)); + String componentId = ApiUtil.getCreatedId(testRealm().components().add(memProvider)); + getCleanup().addComponentId(componentId); + + // remove the test user and create it but federated + testRealm().users().get(userId).remove(); + UserRepresentation user = new UserRepresentation(); + user.setUsername("login-test"); + user.setEmail("login@test.com"); + user.setFederationLink(componentId); + this.userId = ApiUtil.getCreatedId(testRealm().users().create(user)); + Assert.assertFalse(StorageId.isLocalStorage(userId)); + + // by default federated users are force to re-login + resetPassword("login-test", "resetPassword", true); + + // check with false the session is maintained + configureForceLogin(Boolean.FALSE.toString()); + resetPassword("login-test", "resetPassword", false); + } + @Test public void resetPasswordTwiceInNewBrowser() throws IOException, MessagingException { String changePasswordUrl = resetPassword("login-test"); @@ -421,6 +447,20 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest { loginPage.assertCurrent(); } + private void configureForceLogin(String value) { + 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, value); + String configId = ApiUtil.getCreatedId(testRealm().flows().newExecutionConfig(sendEmailExec.getId(), config)); + getCleanup().addAuthenticationConfigId(configId); + } + private String resetPassword(String username) throws IOException, MessagingException { return resetPassword(username, "resetPassword", false); }