Introduce pending email verification message for UPDATE_EMAIL

Closes #42770

Signed-off-by: Martin Kanis <mkanis@redhat.com>
This commit is contained in:
Martin Kanis 2025-09-24 11:40:44 +02:00 committed by Pedro Igor
parent 53007546ad
commit 88eea73cdc
6 changed files with 218 additions and 14 deletions

View File

@ -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<String, String> 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));
}
}

View File

@ -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<FormMessage> errors = Validation.getFormErrorsFromValidation(pve.getErrors());

View File

@ -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");
}
}
}

View File

@ -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;
}
}
}

View File

@ -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");
}
}
}

View File

@ -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.