mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-09 23:12:06 -03:30
Introduce pending email verification message for UPDATE_EMAIL
Closes #42770 Signed-off-by: Martin Kanis <mkanis@redhat.com>
This commit is contained in:
parent
53007546ad
commit
88eea73cdc
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user