diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateEmail.java b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateEmail.java index bd220ab42cf..0931ec345a3 100644 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateEmail.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateEmail.java @@ -18,6 +18,7 @@ package org.keycloak.authentication.requiredactions; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.concurrent.TimeUnit; import jakarta.ws.rs.core.MultivaluedHashMap; @@ -48,6 +49,7 @@ import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.RealmModel; import org.keycloak.models.RequiredActionConfigModel; import org.keycloak.models.RequiredActionProviderModel; +import org.keycloak.models.SingleUseObjectProvider; import org.keycloak.models.UserModel; import org.keycloak.models.UserModel.RequiredAction; import org.keycloak.models.utils.FormMessage; @@ -69,6 +71,7 @@ public class UpdateEmail implements RequiredActionProvider, RequiredActionFactor public static final String CONFIG_VERIFY_EMAIL = "verifyEmail"; private static final String FORCE_EMAIL_VERIFICATION = "forceEmailVerification"; + private static final String PENDING_EMAIL_CACHE_KEY_PREFIX = "update-email-pending-"; public static boolean isEnabled(RealmModel realm) { if (!Profile.isFeatureEnabled(Profile.Feature.UPDATE_EMAIL)) { @@ -131,6 +134,12 @@ public class UpdateEmail implements RequiredActionProvider, RequiredActionFactor return; } + // Check if email verification is pending and show message for subsequent visits + String pendingEmail = getPendingEmailVerification(context); + if (pendingEmail != null) { + context.form().setInfo("emailVerificationPending", pendingEmail); + } + if (session.getAttributeOrDefault(FORCE_EMAIL_VERIFICATION, Boolean.FALSE)) { sendEmailUpdateConfirmation(context, false); return; @@ -205,7 +214,13 @@ public class UpdateEmail implements RequiredActionProvider, RequiredActionFactor } context.getEvent().success(); + // Set cache entry only if not already set + if (getPendingEmailVerification(context) == null) { + setPendingEmailVerification(context, newEmail); + } + LoginFormsProvider forms = context.form(); + context.challenge(forms.setAttribute("messageHeader", forms.getMessage("emailUpdateConfirmationSentTitle")) .setInfo("emailUpdateConfirmationSent", newEmail).createForm(Templates.getTemplate(LoginFormsPages.INFO))); } @@ -214,6 +229,8 @@ public class UpdateEmail implements RequiredActionProvider, RequiredActionFactor UserProfile emailUpdateValidationResult) { updateEmailNow(context.getEvent(), context.getUser(), emailUpdateValidationResult); + // Clear pending verification cache since verification is complete + clearPendingEmailVerification(context); context.success(); } @@ -282,4 +299,27 @@ public class UpdateEmail implements RequiredActionProvider, RequiredActionFactor public boolean isSupported(Config.Scope config) { return Profile.isFeatureEnabled(Profile.Feature.UPDATE_EMAIL); } + + private static String getPendingEmailCacheKey(RequiredActionContext context) { + return PENDING_EMAIL_CACHE_KEY_PREFIX + context.getUser().getId(); + } + + public static void setPendingEmailVerification(RequiredActionContext context, String email) { + SingleUseObjectProvider cache = context.getSession().singleUseObjects(); + // Use same expiration as verification link + small buffer + int linkValidityInSecs = context.getRealm().getActionTokenGeneratedByUserLifespan(UpdateEmailActionToken.TOKEN_TYPE); + long expirationSeconds = linkValidityInSecs + 300; + cache.put(getPendingEmailCacheKey(context), expirationSeconds, Map.of("email", email)); + } + + private String getPendingEmailVerification(RequiredActionContext context) { + SingleUseObjectProvider cache = context.getSession().singleUseObjects(); + Map pendingData = cache.get(getPendingEmailCacheKey(context)); + return pendingData != null ? pendingData.get("email") : null; + } + + private void clearPendingEmailVerification(RequiredActionContext context) { + SingleUseObjectProvider cache = context.getSession().singleUseObjects(); + cache.remove(getPendingEmailCacheKey(context)); + } } diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java index 506212a6411..ee459c56392 100644 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java @@ -82,12 +82,19 @@ public class UpdateProfile implements RequiredActionProvider, RequiredActionFact profile.update(false, new EventAuditingAttributeChangeListener(profile, event)); - context.success(); - - if (isForceEmailVerification && !realm.isVerifyEmail()) { + if (isForceEmailVerification) { user.addRequiredAction(UserModel.RequiredAction.UPDATE_EMAIL); + + // Remove VERIFY_EMAIL to ensure UPDATE_EMAIL takes precedence when both realm verification and forced verification are enabled. + user.removeRequiredAction(UserModel.RequiredAction.VERIFY_EMAIL); + UpdateEmail.forceEmailVerification(context.getSession()); + + // Set cache entry so pending verification message shows on subsequent UPDATE_EMAIL visits + UpdateEmail.setPendingEmailVerification(context, newEmail); } + + context.success(); } catch (ValidationException pve) { List errors = Validation.getFormErrorsFromValidation(pve.getErrors()); diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java b/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java index 43ea13255a5..fd03245d958 100755 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java @@ -73,8 +73,13 @@ public class VerifyEmail implements RequiredActionProvider, RequiredActionFactor @Override public void evaluateTriggers(RequiredActionContext context) { if (context.getRealm().isVerifyEmail() && !context.getUser().isEmailVerified()) { - context.getUser().addRequiredAction(UserModel.RequiredAction.VERIFY_EMAIL); - logger.debug("User is required to verify email"); + // Don't add VERIFY_EMAIL if UPDATE_EMAIL is already present (UPDATE_EMAIL takes precedence) + if (context.getUser().getRequiredActionsStream().noneMatch(action -> UserModel.RequiredAction.UPDATE_EMAIL.name().equals(action))) { + context.getUser().addRequiredAction(UserModel.RequiredAction.VERIFY_EMAIL); + logger.debug("User is required to verify email"); + } else { + logger.debug("Skipping VERIFY_EMAIL because UPDATE_EMAIL is already present"); + } } } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/UpdateEmailPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/UpdateEmailPage.java index b96a9a06be1..46fce2cf5ab 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/UpdateEmailPage.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/UpdateEmailPage.java @@ -39,6 +39,9 @@ public class UpdateEmailPage extends LogoutSessionsPage { @FindBy(id = "kc-submit") private WebElement submitButton; + @FindBy(className = "kc-feedback-text") + private WebElement feedbackMessage; + @Override public boolean isCurrent() { return driver.getCurrentUrl().contains("login-actions/required-action") @@ -80,4 +83,12 @@ public class UpdateEmailPage extends LogoutSessionsPage { clickLink(submitButton); } + public String getInfo() { + try { + return getTextFromElement(feedbackMessage); + } catch (NoSuchElementException e) { + return null; + } + } + } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionUpdateEmailTestWithVerificationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionUpdateEmailTestWithVerificationTest.java index 66e339c196d..241e6631762 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionUpdateEmailTestWithVerificationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionUpdateEmailTestWithVerificationTest.java @@ -16,6 +16,16 @@ */ package org.keycloak.testsuite.actions; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot; + import jakarta.mail.Address; import jakarta.mail.Message; import jakarta.mail.MessagingException; @@ -47,6 +57,7 @@ import org.keycloak.testsuite.broker.util.SimpleHttpDefault; import org.keycloak.testsuite.pages.ErrorPage; import org.keycloak.testsuite.pages.InfoPage; import org.keycloak.testsuite.util.GreenMailRule; +import org.keycloak.testsuite.util.InfinispanTestTimeServiceRule; import org.keycloak.testsuite.util.MailUtils; import org.keycloak.testsuite.util.UserBuilder; import org.keycloak.testsuite.util.WaitUtils; @@ -56,18 +67,14 @@ import java.io.IOException; import java.util.List; import java.util.UUID; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; -import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot; - public class RequiredActionUpdateEmailTestWithVerificationTest extends AbstractRequiredActionUpdateEmailTest { @Rule public GreenMailRule greenMail = new GreenMailRule(); + @Rule + public InfinispanTestTimeServiceRule ispnTestTimeService = new InfinispanTestTimeServiceRule(this); + @Page private InfoPage infoPage; @@ -160,6 +167,18 @@ public class RequiredActionUpdateEmailTestWithVerificationTest extends AbstractR updateEmail(false); } + @Test + public void pendingVerificationIsNotDisplayedOnFirstVisit() { + loginPage.open(); + + loginPage.login("test-user@localhost", "password"); + + updateEmailPage.assertCurrent(); + + // Verify no pending verification message is shown on first visit + assertThat(updateEmailPage.getInfo(), not(containsString("A verification email was sent to"))); + } + @Test public void confirmEmailUpdateAfterThirdPartyEmailUpdate() throws MessagingException, IOException { loginPage.open(); @@ -304,7 +323,8 @@ public class RequiredActionUpdateEmailTestWithVerificationTest extends AbstractR loginPage.open(); loginPage.login("test-user@localhost", "password"); // user is forced to update the email because it was not yet confirmed - assertTrue(driver.getPageSource().contains("You need to update your email address to activate your account.")); + // The pending verification message takes precedence and is more informative + assertThat(updateEmailPage.getInfo(), containsString("A verification email was sent to new-email@localhost")); updateEmailPage.clickSubmitAction(); confirmationLink = fetchEmailConfirmationLink("new-email@localhost", greenMail.getLastReceivedMessage()); driver.navigate().to(confirmationLink); @@ -343,7 +363,8 @@ public class RequiredActionUpdateEmailTestWithVerificationTest extends AbstractR user = testRealm().users().search("test-user@localhost").get(0); assertEquals(1, user.getRequiredActions().size()); - assertEquals(RequiredAction.VERIFY_EMAIL.name(), user.getRequiredActions().get(0)); + // When UPDATE_EMAIL is configured with forced verification, it takes precedence over VERIFY_EMAIL + assertEquals(RequiredAction.UPDATE_EMAIL.name(), user.getRequiredActions().get(0)); } finally { requiredAction.getConfig().put(UpdateEmail.CONFIG_VERIFY_EMAIL, Boolean.FALSE.toString()); authMgt.updateRequiredAction(requiredAction.getAlias(), requiredAction); @@ -425,4 +446,123 @@ public class RequiredActionUpdateEmailTestWithVerificationTest extends AbstractR return MailUtils.getPasswordResetEmailLink(message).trim(); } + + @Test + public void testEmailVerificationPendingMessageOnReAuthentication() throws MessagingException, IOException { + // Save original configuration to restore later + RequiredActionConfigRepresentation originalConfig = testRealm().flows().getRequiredActionConfig(UserModel.RequiredAction.UPDATE_EMAIL.name()); + + try { + // Configure UPDATE_EMAIL to force email verification + RequiredActionConfigRepresentation config = new RequiredActionConfigRepresentation(); + config.getConfig().put(UpdateEmail.CONFIG_VERIFY_EMAIL, Boolean.TRUE.toString()); + testRealm().flows().updateRequiredActionConfig(UserModel.RequiredAction.UPDATE_EMAIL.name(), config); + + // Create user with empty email and UPDATE_PROFILE required action + UserRepresentation user = UserBuilder.create() + .enabled(true) + .username("pendinguser") + .email("") // Start with empty email + .firstName("John") + .lastName("Doe") + .requiredAction(UserModel.RequiredAction.UPDATE_PROFILE.name()) + .build(); + ApiUtil.createUserAndResetPasswordWithAdminClient(testRealm(), user, "password"); + + loginPage.open(); + loginPage.login("pendinguser", "password"); + updateProfilePage.assertCurrent(); + updateProfilePage.update("John", "Doe", "pending@localhost"); + + // Verification email should be sent and email should be set + UserRepresentation updatedUser = testRealm().users().get(findUser("pendinguser").getId()).toRepresentation(); + assertEquals("Email should be set immediately", "pending@localhost", updatedUser.getEmail()); + + assertTrue("User should have UPDATE_EMAIL required action", + updatedUser.getRequiredActions().contains(UserModel.RequiredAction.UPDATE_EMAIL.name())); + + infoPage.assertCurrent(); + + // Check that the email confirmation sent message is displayed + assertEquals("A confirmation email has been sent to pending@localhost. You must follow the instructions of the former to complete the email update.", + infoPage.getInfo()); + + loginPage.open(); + loginPage.login("pendinguser", "password"); + + // Should be on UPDATE_EMAIL page with pending verification message + updateEmailPage.assertCurrent(); + + // Check that the pending verification message is displayed + assertThat("Should show pending verification message", + updateEmailPage.getInfo(), containsString("A verification email was sent to pending@localhost")); + + updateEmailPage.changeEmail("pending@localhost"); // Same email to resend + + // Should send verification email + String confirmationLink = fetchEmailConfirmationLink("pending@localhost", greenMail.getLastReceivedMessage()); + assertNotNull("Should have received verification email", confirmationLink); + + } finally { + // Always restore original configuration and clean up + testRealm().flows().updateRequiredActionConfig(UserModel.RequiredAction.UPDATE_EMAIL.name(), originalConfig); + events.clear(); + ApiUtil.removeUserByUsername(testRealm(), "pendinguser"); + } + } + + @Test + public void testPendingVerificationMessageWithRealmVerificationEnabled() throws MessagingException, IOException { + try { + // Create user with verified email and UPDATE_EMAIL required action + UserRepresentation user = UserBuilder.create() + .enabled(true) + .username("realmverifyuser") + .email("realmverifyuser@localhost") + .firstName("John") + .lastName("Doe") + .emailVerified(true) + .requiredAction(UserModel.RequiredAction.UPDATE_EMAIL.name()) + .build(); + ApiUtil.createUserAndResetPasswordWithAdminClient(testRealm(), user, "password"); + + // Step 1: Login and change email (triggers verification due to realm verification setting) + loginPage.open(); + loginPage.login("realmverifyuser", "password"); + updateEmailPage.assertCurrent(); + + // Verify no pending message on first visit + assertThat("Should not show pending message on first visit", + updateEmailPage.getInfo(), not(containsString("A verification email was sent to"))); + + updateEmailPage.changeEmail("realmverify@localhost"); + + // Should send verification email and show confirmation + events.expect(EventType.SEND_VERIFY_EMAIL).detail(Details.EMAIL, "realmverify@localhost").user(findUser("realmverifyuser").getId()).assertEvent(); + String confirmationLink = fetchEmailConfirmationLink("realmverify@localhost"); + assertNotNull("Should have received verification email", confirmationLink); + + // Step 2: Logout and login again (should show pending verification message) + testRealm().users().get(findUser("realmverifyuser").getId()).logout(); + loginPage.open(); + loginPage.login("realmverifyuser", "password"); + + // Should be on UPDATE_EMAIL page with pending verification message + updateEmailPage.assertCurrent(); + + // Check that the pending verification message is displayed + assertThat("Should show pending verification message with realm verification enabled", + updateEmailPage.getInfo(), containsString("A verification email was sent to realmverify@localhost")); + + // Step 3: Complete verification to ensure cache is cleared + driver.navigate().to(confirmationLink); + infoPage.assertCurrent(); + + } finally { + // Clean up + events.clear(); + ApiUtil.removeUserByUsername(testRealm(), "realmverifyuser"); + } + } + } diff --git a/themes/src/main/resources/theme/base/login/messages/messages_en.properties b/themes/src/main/resources/theme/base/login/messages/messages_en.properties index b994b78b8a6..e94708a765c 100644 --- a/themes/src/main/resources/theme/base/login/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/login/messages/messages_en.properties @@ -557,3 +557,4 @@ organization.member.register.title=Create an account to join the ${kc.org.name} organization.select=Select an organization to proceed: notMemberOfOrganization=User is not a member of the organization {0} notMemberOfAnyOrganization=User is not a member of any organization +emailVerificationPending=A verification email was sent to {0}. You can submit without changes to resend the verification email, or enter a different email address.