diff --git a/docs/documentation/release_notes/topics/24_0_0.adoc b/docs/documentation/release_notes/topics/24_0_0.adoc index c213f3f983..6d692c3db0 100644 --- a/docs/documentation/release_notes/topics/24_0_0.adoc +++ b/docs/documentation/release_notes/topics/24_0_0.adoc @@ -79,6 +79,7 @@ on the user profile configuration set to a realm: * `login-update-profile.ftl` * `register.ftl` +* `update-email.ftl` For more details, see link:{upgradingguide_link}[{upgradingguide_name}]. diff --git a/docs/documentation/upgrading/topics/keycloak/changes-24_0_0.adoc b/docs/documentation/upgrading/topics/keycloak/changes-24_0_0.adoc index 0320bd0219..94b2ea0907 100644 --- a/docs/documentation/upgrading/topics/keycloak/changes-24_0_0.adoc +++ b/docs/documentation/upgrading/topics/keycloak/changes-24_0_0.adoc @@ -78,9 +78,10 @@ on the user profile configuration set to a realm: * `login-update-profile.ftl` * `register.ftl` +* `update-email.ftl` -These templates are responsible for rendering both update profile (when the `Update Profile` required action is enabled to a user) -and registration pages, respectively. +These templates are responsible for rendering the update profile (when the `Update Profile` required action is enabled to a user), +the registration, and the update email (when the `UPDATE_EMAIL` feature is enabled) pages, respectively. If you use a custom theme to change these templates, they will function as expect because only the content is updated. However, we recommend you to take a look at how to configure a link:{adminguide_link}#user-profile[{declarative user profile}] and possibly avoid diff --git a/server-spi/src/main/java/org/keycloak/userprofile/UserProfileContext.java b/server-spi/src/main/java/org/keycloak/userprofile/UserProfileContext.java index 4f2d46686b..c64f35ad4a 100644 --- a/server-spi/src/main/java/org/keycloak/userprofile/UserProfileContext.java +++ b/server-spi/src/main/java/org/keycloak/userprofile/UserProfileContext.java @@ -20,10 +20,14 @@ package org.keycloak.userprofile; import java.util.Set; +import java.util.function.Predicate; import static org.keycloak.userprofile.UserProfileConstants.ROLE_ADMIN; import static org.keycloak.userprofile.UserProfileConstants.ROLE_USER; +import org.keycloak.models.UserModel; +import org.keycloak.utils.StringUtil; + /** *

This interface represents the different contexts from where user profiles are managed. The core contexts are already * available here representing the different areas in Keycloak where user profiles are managed. @@ -62,17 +66,24 @@ public enum UserProfileContext { /** * In this context, a user profile is managed by themselves when updating their email through an application initiated action. + * In this context, only the {@link UserModel#EMAIL} attribute is supported. */ - UPDATE_EMAIL(false, true, false); - + UPDATE_EMAIL(false, true, false, Set.of(UserModel.EMAIL)::contains); + private final boolean resetEmailVerified; + private final Predicate attributeSelector; private final boolean adminContext; private final boolean authFlowContext; - UserProfileContext(boolean adminContext, boolean authFlowContext, boolean resetEmailVerified){ + UserProfileContext(boolean adminContext, boolean authFlowContext, boolean resetEmailVerified, Predicate attributeSelector){ this.adminContext = adminContext; this.authFlowContext = authFlowContext; this.resetEmailVerified = resetEmailVerified; + this.attributeSelector = attributeSelector; + } + + UserProfileContext(boolean adminContext, boolean authFlowContext, boolean resetEmailVerified){ + this(adminContext, authFlowContext, resetEmailVerified, StringUtil::isNotBlank); } /** @@ -111,5 +122,8 @@ public enum UserProfileContext { private String getContextRole() { return isAdminContext() ? ROLE_ADMIN : ROLE_USER; } - + + public boolean isAttributeSupported(String name) { + return attributeSelector.test(name); + } } diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java index 990c8639db..6e763bc609 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java @@ -164,6 +164,8 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { page = LoginFormsPages.LOGIN_UPDATE_PROFILE; break; case UPDATE_EMAIL: + UpdateProfileContext updateEmailContext = new UserUpdateProfileContext(realm,user); + attributes.put("user",new ProfileBean(updateEmailContext,formData)); actionMessage = Messages.UPDATE_EMAIL; page = LoginFormsPages.UPDATE_EMAIL; break; @@ -245,7 +247,10 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { attributes.put("user", new ProfileBean(userCtx, formData)); break; case UPDATE_EMAIL: - attributes.put("email", new EmailBean(user, formData)); + EmailBean emailBean = new EmailBean(user, formData, session); + attributes.put("profile", emailBean); + // only for backward compatibility but should be removed once declarative user profile is supported + attributes.put("email", emailBean); break; case LOGIN_IDP_LINK_CONFIRM: case LOGIN_IDP_LINK_EMAIL: diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/model/EmailBean.java b/services/src/main/java/org/keycloak/forms/login/freemarker/model/EmailBean.java index e5156b0157..08b249fef3 100644 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/model/EmailBean.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/model/EmailBean.java @@ -16,20 +16,40 @@ */ package org.keycloak.forms.login.freemarker.model; -import jakarta.ws.rs.core.MultivaluedMap; -import org.keycloak.models.UserModel; +import java.util.stream.Stream; -public class EmailBean { +import jakarta.ws.rs.core.MultivaluedMap; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; +import org.keycloak.userprofile.UserProfile; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.UserProfileProvider; + +public class EmailBean extends AbstractUserProfileBean { private final UserModel user; - private final MultivaluedMap formData; - - public EmailBean(UserModel user, MultivaluedMap formData) { + public EmailBean(UserModel user, MultivaluedMap formData, KeycloakSession session) { + super(formData); this.user = user; - this.formData = formData; + init(session, false); } public String getValue() { return formData != null ? formData.getFirst("email") : user.getEmail(); } + + @Override + protected UserProfile createUserProfile(UserProfileProvider provider) { + return provider.create(UserProfileContext.UPDATE_EMAIL, user); + } + + @Override + protected Stream getAttributeDefaultValues(String name) { + return user.getAttributeStream(name); + } + + @Override + public String getContext() { + return UserProfileContext.UPDATE_PROFILE.name(); + } } diff --git a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java index 31822f8105..e22e5ea5f7 100644 --- a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java +++ b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java @@ -137,7 +137,14 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider { } private UserProfile createUserProfile(UserProfileContext context, Map attributes, UserModel user) { - UserProfileMetadata metadata = configureUserProfile(contextualMetadataRegistry.get(context), session); + UserProfileMetadata defaultMetadata = contextualMetadataRegistry.get(context); + + if (defaultMetadata == null) { + // some contexts (and their metadata) are available enabled when the corresponding feature is enabled + throw new RuntimeException("No metadata is bound to the " + context + " context"); + } + + UserProfileMetadata metadata = configureUserProfile(defaultMetadata, session); Attributes profileAttributes = createAttributes(context, attributes, user, metadata); return new DefaultUserProfile(metadata, profileAttributes, createUserFactory(), user, session); } @@ -266,10 +273,7 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider { protected UserProfileMetadata decorateUserProfileForCache(UserProfileMetadata decoratedMetadata, UPConfig parsedConfig) { UserProfileContext context = decoratedMetadata.getContext(); - // do not change config for UPDATE_EMAIL context, validations are already set and do not need including anything else from the configuration - if (parsedConfig == null - || context == UserProfileContext.UPDATE_EMAIL - ) { + if (parsedConfig == null) { return decoratedMetadata; } @@ -278,6 +282,13 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider { for (UPAttribute attrConfig : parsedConfig.getAttributes()) { String attributeName = attrConfig.getName(); + + if (!context.isAttributeSupported(attributeName)) { + // attributes not supported by the context are ignored + // for instance, only support email attribute when at the UPDATE_EMAIL context + continue; + } + List validators = new ArrayList<>(); Map> validationsConfig = attrConfig.getValidations(); diff --git a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java index 0444bb594c..57efb8116c 100644 --- a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java +++ b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java @@ -214,7 +214,7 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide addContextualProfileMetadata(configureUserProfile(createAccountProfile(ACCOUNT, readOnlyValidator))); addContextualProfileMetadata(configureUserProfile(createDefaultProfile(UPDATE_PROFILE, readOnlyValidator))); if (Profile.isFeatureEnabled(Profile.Feature.UPDATE_EMAIL)) { - addContextualProfileMetadata(configureUserProfile(createDefaultProfile(UPDATE_EMAIL, readOnlyValidator))); + addContextualProfileMetadata(configureUserProfile(createUpdateEmailProfile(UPDATE_EMAIL, readOnlyValidator))); } addContextualProfileMetadata(configureUserProfile(createRegistrationUserCreationProfile(readOnlyValidator))); addContextualProfileMetadata(configureUserProfile(createUserResourceValidation(config))); @@ -400,6 +400,31 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide return metadata; } + private UserProfileMetadata createUpdateEmailProfile(UserProfileContext context, AttributeValidatorMetadata readOnlyValidator) { + UserProfileMetadata metadata = new UserProfileMetadata(context); + + metadata.addAttribute(UserModel.EMAIL, -1, + DeclarativeUserProfileProviderFactory::editEmailCondition, + DeclarativeUserProfileProviderFactory::readEmailCondition, + new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_EMAIL, false)), + new AttributeValidatorMetadata(DuplicateEmailValidator.ID), + new AttributeValidatorMetadata(EmailExistsAsUsernameValidator.ID), + new AttributeValidatorMetadata(EmailValidator.ID, ValidatorConfig.builder().config(EmailValidator.IGNORE_EMPTY_VALUE, true).build())) + .setAttributeDisplayName("${email}"); + + List readonlyValidators = new ArrayList<>(); + + readonlyValidators.add(createReadOnlyAttributeUnchangedValidator(readOnlyAttributesPattern)); + + if (readOnlyValidator != null) { + readonlyValidators.add(readOnlyValidator); + } + + metadata.addAttribute(READ_ONLY_ATTRIBUTE_KEY, 1000, readonlyValidators); + + return metadata; + } + private UserProfileMetadata createUserResourceValidation(Config.Scope config) { Pattern p = getRegexPatternString(config.getArray(CONFIG_ADMIN_READ_ONLY_ATTRIBUTES)); UserProfileMetadata metadata = new UserProfileMetadata(USER_API); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AbstractAppInitiatedActionUpdateEmailTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AbstractAppInitiatedActionUpdateEmailTest.java index ca2fcc3f9d..4dd3e6149f 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AbstractAppInitiatedActionUpdateEmailTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AbstractAppInitiatedActionUpdateEmailTest.java @@ -128,7 +128,7 @@ public abstract class AbstractAppInitiatedActionUpdateEmailTest extends Abstract emailUpdatePage.changeEmail(""); emailUpdatePage.assertCurrent(); - Assert.assertEquals("Please specify email.", emailUpdatePage.getEmailError()); + Assert.assertTrue(emailUpdatePage.getEmailError().contains("Please specify email.")); UserRepresentation user = ActionUtil.findUserWithAdminClient(adminClient, "test-user@localhost"); Assert.assertEquals("test-user@localhost", user.getEmail()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AbstractRequiredActionUpdateEmailTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AbstractRequiredActionUpdateEmailTest.java index 917b042c60..96e751524a 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AbstractRequiredActionUpdateEmailTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AbstractRequiredActionUpdateEmailTest.java @@ -132,7 +132,7 @@ public abstract class AbstractRequiredActionUpdateEmailTest extends AbstractTest // assert that form holds submitted values during validation error Assert.assertEquals("", updateEmailPage.getEmail()); - Assert.assertEquals("Please specify email.", updateEmailPage.getEmailInputError()); + Assert.assertTrue(updateEmailPage.getEmailInputError().contains("Please specify email.")); events.assertEmpty(); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AppInitiatedActionUpdateEmailUserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AppInitiatedActionUpdateEmailUserProfileTest.java index bbcde13154..e22875272a 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AppInitiatedActionUpdateEmailUserProfileTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AppInitiatedActionUpdateEmailUserProfileTest.java @@ -16,10 +16,27 @@ */ package org.keycloak.testsuite.actions; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.keycloak.userprofile.UserProfileConstants.ROLE_USER; + +import java.util.Map; +import java.util.Set; + +import org.junit.Test; +import org.keycloak.admin.client.resource.UserProfileResource; import org.keycloak.common.Profile; +import org.keycloak.events.Details; +import org.keycloak.events.EventType; +import org.keycloak.models.UserModel; import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.userprofile.config.UPAttribute; +import org.keycloak.representations.userprofile.config.UPAttributePermissions; +import org.keycloak.representations.userprofile.config.UPAttributeRequired; +import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; import org.keycloak.testsuite.forms.VerifyProfileTest; +import org.keycloak.validate.validators.LengthValidator; @EnableFeature(Profile.Feature.DECLARATIVE_USER_PROFILE) public class AppInitiatedActionUpdateEmailUserProfileTest extends AppInitiatedActionUpdateEmailTest { @@ -29,4 +46,44 @@ public class AppInitiatedActionUpdateEmailUserProfileTest extends AppInitiatedAc super.configureTestRealm(testRealm); VerifyProfileTest.enableDynamicUserProfile(testRealm); } + + @Test + public void testCustomEmailValidator() throws Exception { + UserProfileResource userProfile = testRealm().users().userProfile(); + UPConfig upConfig = userProfile.getConfiguration(); + UPAttribute emailConfig = upConfig.getAttribute(UserModel.EMAIL); + emailConfig.addValidation(LengthValidator.ID, Map.of("min", "1", "max", "1")); + getCleanup().addCleanup(() -> { + emailConfig.getValidations().remove(LengthValidator.ID); + userProfile.update(upConfig); + }); + userProfile.update(upConfig); + + changeEmailUsingAIA("new@email.com"); + assertTrue(emailUpdatePage.getEmailError().contains("Length must be between 1 and 1.")); + + emailConfig.getValidations().remove(LengthValidator.ID); + userProfile.update(upConfig); + changeEmailUsingAIA("new@email.com"); + events.expect(EventType.UPDATE_EMAIL).detail(Details.PREVIOUS_EMAIL, "test-user@localhost") + .detail(Details.UPDATED_EMAIL, "new@email.com").assertEvent(); + } + + @Test + public void testOnlyEmailSupportedInContext() throws Exception { + UserProfileResource userProfile = testRealm().users().userProfile(); + UPConfig upConfig = userProfile.getConfiguration(); + String unexpectedAttributeName = "unexpectedAttribute"; + upConfig.addOrReplaceAttribute(new UPAttribute(unexpectedAttributeName, new UPAttributePermissions(Set.of(), Set.of(ROLE_USER)), new UPAttributeRequired(Set.of(ROLE_USER), Set.of()))); + getCleanup().addCleanup(() -> { + upConfig.removeAttribute(unexpectedAttributeName); + userProfile.update(upConfig); + }); + userProfile.update(upConfig); + + assertFalse(driver.getPageSource().contains(unexpectedAttributeName)); + changeEmailUsingAIA("new@email.com"); + events.expect(EventType.UPDATE_EMAIL).detail(Details.PREVIOUS_EMAIL, "test-user@localhost") + .detail(Details.UPDATED_EMAIL, "new@email.com").assertEvent(); + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java index 28c86d3757..d6f97bf808 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java @@ -47,6 +47,7 @@ import org.junit.Assert; import org.junit.ClassRule; import org.junit.Test; import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.common.Profile.Feature; import org.keycloak.component.ComponentModel; import org.keycloak.component.ComponentValidationException; import org.keycloak.models.Constants; @@ -61,6 +62,7 @@ import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.userprofile.config.UPConfig.UnmanagedAttributePolicy; import org.keycloak.representations.userprofile.config.UPGroup; import org.keycloak.services.messages.Messages; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; import org.keycloak.testsuite.arquillian.annotation.ModelTest; import org.keycloak.testsuite.runonserver.RunOnServer; import org.keycloak.testsuite.util.LDAPRule; @@ -75,6 +77,7 @@ import org.keycloak.testsuite.util.ClientScopeBuilder; import org.keycloak.testsuite.util.KeycloakModelUtils; import org.keycloak.userprofile.Attributes; import org.keycloak.userprofile.UserProfile; +import org.keycloak.userprofile.UserProfileConstants; import org.keycloak.userprofile.UserProfileContext; import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.userprofile.ValidationException; @@ -1860,4 +1863,44 @@ public class UserProfileTest extends AbstractUserProfileTest { assertEquals(attributes.get(UserModel.USERNAME).toLowerCase(), profileAttributes.getFirst(UserModel.USERNAME)); assertEquals(attributes.get(UserModel.EMAIL).toLowerCase(), profileAttributes.getFirst(UserModel.EMAIL)); } + + @EnableFeature(Feature.UPDATE_EMAIL) + @Test + public void testEmailAttributeInUpdateEmailContext() { + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testEmailAttributeInUpdateEmailContext); + } + + private static void testEmailAttributeInUpdateEmailContext(KeycloakSession session) { + UserProfileProvider provider = getUserProfileProvider(session); + String userName = org.keycloak.models.utils.KeycloakModelUtils.generateId(); + Map attributes = new HashMap<>(); + + attributes.put(UserModel.USERNAME, userName); + attributes.put(UserModel.EMAIL, userName + "@keycloak.org"); + attributes.put(UserModel.FIRST_NAME, "Joe"); + attributes.put(UserModel.LAST_NAME, "Doe"); + + UserProfile profile = provider.create(UserProfileContext.USER_API, attributes); + UserModel user = profile.create(); + + profile = provider.create(UserProfileContext.UPDATE_EMAIL, user); + containsInAnyOrder(profile.getAttributes().nameSet(), UserModel.EMAIL); + + UPConfig upConfig = provider.getConfiguration(); + upConfig.addOrReplaceAttribute(new UPAttribute("foo", new UPAttributePermissions(Set.of(), Set.of(UserProfileConstants.ROLE_USER)), new UPAttributeRequired(Set.of(UserProfileConstants.ROLE_USER), Set.of()))); + provider.setConfiguration(upConfig); + profile = provider.create(UserProfileContext.UPDATE_EMAIL, attributes, user); + profile.update(); + + upConfig = provider.getConfiguration(); + upConfig.getAttribute(UserModel.EMAIL).getValidations().put(LengthValidator.ID, Map.of("min", "1", "max", "2")); + provider.setConfiguration(upConfig); + profile = provider.create(UserProfileContext.UPDATE_EMAIL, attributes, user); + try { + profile.update(); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(UserModel.EMAIL)); + assertTrue(ve.hasError(LengthValidator.MESSAGE_INVALID_LENGTH)); + } + } } diff --git a/themes/src/main/resources/theme/base/login/update-email.ftl b/themes/src/main/resources/theme/base/login/update-email.ftl index e63b012de5..1650e25afa 100644 --- a/themes/src/main/resources/theme/base/login/update-email.ftl +++ b/themes/src/main/resources/theme/base/login/update-email.ftl @@ -1,27 +1,12 @@ <#import "template.ftl" as layout> <#import "password-commons.ftl" as passwordCommons> -<@layout.registrationLayout displayMessage=!messagesPerField.existsError('email'); section> +<#import "user-profile-commons.ftl" as userProfileCommons> +<@layout.registrationLayout displayMessage=messagesPerField.exists('global') displayRequiredFields=true; section> <#if section = "header"> ${msg("updateEmailTitle")} <#elseif section = "form">

-
-
- -
-
- - - <#if messagesPerField.existsError('email')> - - ${kcSanitize(messagesPerField.get('email'))?no_esc} - - -
-
+ <@userProfileCommons.userProfileFormFields/>