Hide read-only email attribute in update profile context with update … …email enabled (#43024)

* Hide read-only email attribute in update profile context with update email enabled

Closes #42990

Signed-off-by: Martin Kanis <mkanis@redhat.com>

* Simplifying conditions when checking read/write on email attribute and more tests

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>

---------

Signed-off-by: Martin Kanis <mkanis@redhat.com>
Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
Co-authored-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Martin Kanis 2025-10-07 12:52:55 +02:00 committed by GitHub
parent 8dd7437e90
commit a493213ad4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 253 additions and 14 deletions

View File

@ -57,3 +57,9 @@ If the email attribute is set as required in the user profile configuration, the
meaning a user won't be able to clear his/her email in update email page. The opposite is true, if the email attribute is set as optional
in the user profile configuration.
If the email attribute is set as read-only in the user profile configuration, the following behavior applies:
* The `Update email` link will not be displayed in the account console
* The `UPDATE_EMAIL` required action will be automatically skipped and removed from the user account
* In the update profile page, if the user's email is initially empty, the email field will be hidden

View File

@ -27,6 +27,7 @@ import java.util.stream.Stream;
import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import org.jboss.logging.Logger;
import org.keycloak.Config;
@ -60,6 +61,7 @@ import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.services.Urls;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.userprofile.EventAuditingAttributeChangeListener;
@ -157,7 +159,6 @@ public class UpdateEmail implements RequiredActionProvider, RequiredActionFactor
if (newEmail != null) {
// Remove VERIFY_EMAIL to ensure UPDATE_EMAIL takes precedence when both realm verification and forced verification are enabled.
user.removeRequiredAction(UserModel.RequiredAction.VERIFY_EMAIL);
setPendingEmailVerification(context, newEmail);
sendEmailUpdateConfirmation(context, false);
} else {
// Check if email verification is pending and show message for subsequent visits
@ -238,6 +239,10 @@ public class UpdateEmail implements RequiredActionProvider, RequiredActionFactor
} catch (EmailException e) {
logger.error("Failed to send email for email update", e);
context.getEvent().error(Errors.EMAIL_SEND_FAILED);
context.failure(Messages.EMAIL_SENT_ERROR);
context.challenge(context.form()
.setError(Messages.EMAIL_SENT_ERROR)
.createErrorPage(Response.Status.INTERNAL_SERVER_ERROR));
return;
}
context.getEvent().success();

View File

@ -67,6 +67,7 @@ import org.keycloak.userprofile.validator.RegistrationEmailAsUsernameUsernameVal
import org.keycloak.userprofile.validator.RegistrationUsernameExistsValidator;
import org.keycloak.userprofile.validator.UsernameHasValueValidator;
import org.keycloak.userprofile.validator.UsernameMutationValidator;
import org.keycloak.utils.StringUtil;
import org.keycloak.validate.ValidatorConfig;
import org.keycloak.validate.validators.EmailValidator;
@ -155,15 +156,23 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide
if (UpdateEmail.isEnabled(realm)) {
if (UPDATE_PROFILE.equals(c.getContext())) {
UserModel user = c.getUser();
if (!isNewUser(c)) {
if (c.getUser().getEmail() == null || c.getUser().getEmail().isEmpty()) {
if (StringUtil.isBlank(user.getEmail())) {
// allow to set email via UPDATE_PROFILE if the email is not set for the user
return true;
}
} else if (UserModel.EMAIL.equals(c.getAttribute().getKey()) && c.getAttribute().getValue().isEmpty()) {
return true;
List<String> values = c.getAttribute().getValue();
if (values == null || values.isEmpty()) {
// ignore empty values if the user has an email set, email should be set via update email flow
return false;
}
}
}
return !(UPDATE_PROFILE.equals(c.getContext()) || ACCOUNT.equals(c.getContext()));
}
@ -185,15 +194,14 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide
if (UpdateEmail.isEnabled(session.getContext().getRealm())) {
if (UPDATE_PROFILE.equals(c.getContext())) {
if (!isNewUser(c)) {
if (c.getUser().getEmail() == null || c.getUser().getEmail().isEmpty()) {
// show email field in UPDATE_PROFILE page if the email is not set for the user
return true;
}
} else if (UserModel.EMAIL.equals(c.getAttribute().getKey()) && c.getAttribute().getValue().isEmpty()) {
List<String> value = c.getAttribute().getValue();
if (value.isEmpty() && !c.getMetadata().isReadOnly(c)) {
// show email field in UPDATE_PROFILE page if the email is not set for the user and is not read-only
return true;
}
}
return !UPDATE_PROFILE.equals(context);
}

View File

@ -131,6 +131,15 @@ public class LoginUpdateProfilePage extends AbstractPage {
}
}
public boolean isEmailInputPresent() {
try {
emailInput.isDisplayed();
return true;
} catch (NoSuchElementException e) {
return false;
}
}
public boolean isDepartmentPresent() {
try {
isDepartmentEnabled();

View File

@ -39,9 +39,9 @@ import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.auth.page.login.UpdateEmailPage;
import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.ErrorPage;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.LoginUpdateProfilePage;
import org.keycloak.testsuite.pages.VerifyEmailPage;
import org.keycloak.testsuite.util.SecondBrowser;
import org.keycloak.testsuite.util.UserBuilder;
import org.openqa.selenium.WebDriver;
@ -64,9 +64,12 @@ public abstract class AbstractRequiredActionUpdateEmailTest extends AbstractTest
@Page
protected AppPage appPage;
@Drone
@SecondBrowser
protected WebDriver driver2;
@Page
protected ErrorPage errorPage;
@Drone
@SecondBrowser
protected WebDriver driver2;
@Before
public void beforeTest() {

View File

@ -18,22 +18,30 @@ package org.keycloak.testsuite.actions;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.keycloak.userprofile.UserProfileConstants.ROLE_USER;
import static org.keycloak.userprofile.UserProfileConstants.ROLE_ADMIN;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.junit.Assert;
import org.junit.Test;
import org.keycloak.admin.client.resource.AuthenticationManagementResource;
import org.keycloak.admin.client.resource.UserProfileResource;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.authentication.requiredactions.UpdateEmail;
import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserModel.RequiredAction;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.idm.UserSessionRepresentation;
import org.keycloak.representations.userprofile.config.UPAttribute;
@ -135,4 +143,109 @@ public class RequiredActionUpdateEmailTest extends AbstractRequiredActionUpdateE
assertEquals(0, testUser.toRepresentation().getRequiredActions().size());
}
@Test
public void testUpdateProfileWhenEmailIsSetAndIsWritable() {
configureRequiredActionsToUser("test-user@localhost", RequiredAction.UPDATE_PROFILE.name());
UserResource testUser = testRealm().users().get(findUser("test-user@localhost").getId());
assertEquals(1, testUser.toRepresentation().getRequiredActions().size());
// login and update profile, email is already set and should not be visible
loginPage.open();
loginPage.login("test-user@localhost", "password");
updateProfilePage.assertCurrent();
assertFalse(updateProfilePage.isEmailInputPresent());
updateProfilePage.update("Tom", "Brady");
// successfully update the profile without providing the email
appPage.assertCurrent();
assertEquals(0, testUser.toRepresentation().getRequiredActions().size());
}
@Test
public void testUpdateProfileWhenEmailNotSetAndIsWritable() {
configureRequiredActionsToUser("test-user@localhost", RequiredAction.UPDATE_PROFILE.name());
UserResource testUser = testRealm().users().get(findUser("test-user@localhost").getId());
assertEquals(1, testUser.toRepresentation().getRequiredActions().size());
UserRepresentation rep = testUser.toRepresentation();
rep.setEmail("");
testUser.update(rep);
// login and update profile, including the email
loginPage.open();
loginPage.login("test-user@localhost", "password");
updateProfilePage.assertCurrent();
assertTrue(updateProfilePage.isEmailInputPresent());
updateProfilePage.update("Tom", "Brady", "test-user@localhost");
appPage.assertCurrent();
rep = testUser.toRepresentation();
assertEquals(0, rep.getRequiredActions().size());
assertNull(Optional.ofNullable(rep.getAttributes()).orElse(Map.of()).get(UserModel.EMAIL_PENDING));
assertEquals("test-user@localhost", rep.getEmail());
}
@Test
public void testUpdateProfileWhenEmailNotSetAndIsNotWritable() {
UPConfig upConfig = testRealm().users().userProfile().getConfiguration();
upConfig.getAttribute(UserModel.EMAIL).setPermissions(new UPAttributePermissions(Set.of(ROLE_USER, ROLE_ADMIN), Set.of(ROLE_ADMIN)));
testRealm().users().userProfile().update(upConfig);
getCleanup().addCleanup(() -> {
upConfig.getAttribute(UserModel.EMAIL).setPermissions(new UPAttributePermissions(Set.of(ROLE_USER, ROLE_ADMIN), Set.of(ROLE_USER, ROLE_ADMIN)));
testRealm().users().userProfile().update(upConfig);
});
configureRequiredActionsToUser("test-user@localhost", RequiredAction.UPDATE_PROFILE.name());
UserResource testUser = testRealm().users().get(findUser("test-user@localhost").getId());
assertEquals(1, testUser.toRepresentation().getRequiredActions().size());
UserRepresentation rep = testUser.toRepresentation();
rep.setEmail("");
testUser.update(rep);
// login and update profile, email is readonly for users and should not be visible
loginPage.open();
loginPage.login("test-user@localhost", "password");
updateProfilePage.assertCurrent();
assertFalse(updateProfilePage.isEmailInputPresent());
updateProfilePage.update("Tom", "Brady");
appPage.assertCurrent();
rep = testUser.toRepresentation();
assertEquals(0, rep.getRequiredActions().size());
assertNull(Optional.ofNullable(rep.getAttributes()).orElse(Map.of()).get(UserModel.EMAIL_PENDING));
assertNull(rep.getEmail());
}
@Test
public void testFailWhenSendingVerificationEmail() {
AuthenticationManagementResource authMgt = testRealm().flows();
RequiredActionProviderRepresentation requiredAction = authMgt.getRequiredActions().stream()
.filter(action -> RequiredAction.UPDATE_EMAIL.name().equals(action.getAlias()))
.findAny().get();
requiredAction.getConfig().put(UpdateEmail.CONFIG_VERIFY_EMAIL, Boolean.TRUE.toString());
authMgt.updateRequiredAction(requiredAction.getAlias(), requiredAction);
getCleanup().addCleanup(() -> {
requiredAction.getConfig().remove(UpdateEmail.CONFIG_VERIFY_EMAIL);
authMgt.updateRequiredAction(requiredAction.getAlias(), requiredAction);
});
configureRequiredActionsToUser("test-user@localhost", RequiredAction.UPDATE_PROFILE.name());
UserResource testUser = testRealm().users().get(findUser("test-user@localhost").getId());
assertEquals(1, testUser.toRepresentation().getRequiredActions().size());
UserRepresentation rep = testUser.toRepresentation();
rep.setEmail("");
testUser.update(rep);
loginPage.open();
loginPage.login("test-user@localhost", "password");
updateProfilePage.assertCurrent();
assertTrue(updateProfilePage.isEmailInputPresent());
updateProfilePage.update("Tom", "Brady", "test-user@localhost");
errorPage.assertCurrent();
assertEquals("Failed to send email, please try again later.", errorPage.getError());
rep = testUser.toRepresentation();
assertEquals(1, rep.getRequiredActions().size());
assertEquals(RequiredAction.UPDATE_EMAIL.name(), rep.getRequiredActions().get(0));
assertNull(Optional.ofNullable(rep.getAttributes()).orElse(Map.of()).get(UserModel.EMAIL_PENDING));
assertNull(rep.getEmail());
}
}

View File

@ -589,4 +589,37 @@ public class RequiredActionUpdateEmailTestWithVerificationTest extends AbstractR
ApiUtil.removeUserByUsername(testRealm(), "realmverifyuser");
}
}
@Test
public void testUpdateProfileWithVerificationWhenEmailIsNotSetAndIsWritable() throws MessagingException, IOException {
configureRequiredActionsToUser("test-user@localhost", RequiredAction.UPDATE_PROFILE.name());
UserResource testUser = testRealm().users().get(findUser("test-user@localhost").getId());
assertEquals(1, testUser.toRepresentation().getRequiredActions().size());
UserRepresentation rep = testUser.toRepresentation();
rep.setEmail("");
testUser.update(rep);
// login and update profile, email is empty and writable, so email input should be present
loginPage.open();
loginPage.login("test-user@localhost", "password");
updateProfilePage.assertCurrent();
assertTrue(updateProfilePage.isEmailInputPresent());
updateProfilePage.update("Tom", "Brady", "test-user@localhost");
// Should send verification email and show pending verification message
assertThat("Should show pending verification message with realm verification enabled",
driver.getPageSource(), containsString("A confirmation email has been sent to test-user@localhost."));
String confirmationLink = fetchEmailConfirmationLink("test-user@localhost");
rep = testUser.toRepresentation();
assertEquals(1, rep.getRequiredActions().size());
assertEquals(RequiredAction.UPDATE_EMAIL.name(), rep.getRequiredActions().get(0));
assertEquals("test-user@localhost", testUser.toRepresentation().getAttributes().get(UserModel.EMAIL_PENDING).get(0));
assertNull(testUser.toRepresentation().getEmail());
// confirm the email and authenticate to the app
driver.navigate().to(confirmationLink);
infoPage.assertCurrent();
infoPage.clickBackToApplicationLink();
appPage.assertCurrent();
}
}

View File

@ -819,6 +819,22 @@ public class UserProfileTest extends AbstractUserProfileTest {
profile.update();
assertNull(user.getFirstAttribute(UserModel.EMAIL_PENDING));
config.getAttribute(UserModel.EMAIL).getPermissions().setEdit(Set.of(ROLE_ADMIN));
provider.setConfiguration(config);
profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes, user);
profile.update();
assertEquals("myemail@foo.bar", user.getFirstAttribute(UserModel.EMAIL));
assertNull(user.getFirstAttribute(UserModel.EMAIL_PENDING));
assertFalse(profile.getAttributes().getWritable().containsKey(UserModel.EMAIL));
user.setEmail(null);
profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes, user);
profile.update();
assertNull(user.getFirstAttribute(UserModel.EMAIL));
assertFalse(profile.getAttributes().getWritable().containsKey(UserModel.EMAIL));
config.getAttribute(UserModel.EMAIL).getPermissions().setEdit(Set.of(ROLE_USER, ROLE_ADMIN));
provider.setConfiguration(config);
user.setEmail("myemail@foo.bar");
profile = provider.create(UserProfileContext.USER_API, attributes, user);
profile.update();
assertNotNull(user.getFirstAttribute(UserModel.EMAIL_PENDING));
@ -2451,6 +2467,52 @@ public class UserProfileTest extends AbstractUserProfileTest {
}
}
@Test
public void testEmailFieldHiddenWhenEmptyAndReadOnlyWithUpdateEmailEnabled() {
ApiUtil.enableRequiredAction(testRealm(), RequiredAction.UPDATE_EMAIL, true);
try {
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testEmailFieldHiddenWhenEmptyAndReadOnlyWithUpdateEmailEnabled);
} finally {
ApiUtil.enableRequiredAction(testRealm(), RequiredAction.UPDATE_EMAIL, false);
}
}
private static void testEmailFieldHiddenWhenEmptyAndReadOnlyWithUpdateEmailEnabled(KeycloakSession session) {
UserProfileProvider provider = getUserProfileProvider(session);
String userName = org.keycloak.models.utils.KeycloakModelUtils.generateId();
Map<String, String> attributes = new HashMap<>();
// Enable UPDATE_EMAIL feature
RealmModel realm = session.getContext().getRealm();
realm.getRequiredActionProviderByAlias(UserModel.RequiredAction.UPDATE_EMAIL.name()).setEnabled(true);
// Create user without email
attributes.put(UserModel.USERNAME, userName);
attributes.put(UserModel.FIRST_NAME, "John");
attributes.put(UserModel.LAST_NAME, "Doe");
// Deliberately not setting email to test empty email scenario
UserProfile profile = provider.create(UserProfileContext.USER_API, attributes);
UserModel user = profile.create();
// Configure email as read-only for users (only admins can edit)
UPConfig upConfig = provider.getConfiguration();
UPAttribute emailAttr = upConfig.getAttribute(UserModel.EMAIL);
if (emailAttr == null) {
emailAttr = new UPAttribute(UserModel.EMAIL);
upConfig.addOrReplaceAttribute(emailAttr);
}
emailAttr.setPermissions(new UPAttributePermissions(Set.of(), Set.of("admin")));
provider.setConfiguration(upConfig);
profile = provider.create(UserProfileContext.UPDATE_PROFILE, user);
Map<String, List<String>> readableAttributes = profile.getAttributes().getReadable();
// Email should NOT be visible in UPDATE_PROFILE context when empty and read-only
assertFalse("Email field should be hidden when empty, read-only, and UPDATE_EMAIL is enabled",
readableAttributes.containsKey(UserModel.EMAIL));
}
@Test
public void testMultivalued() {
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testMultivalued);