From 9ebab2f0175745b2852b13ed57d55f110bf88e08 Mon Sep 17 00:00:00 2001 From: Martin Kanis Date: Tue, 4 Nov 2025 11:04:13 +0100 Subject: [PATCH] Add rate limiter for sending verification emails in context of update email Closes #43076 Signed-off-by: Martin Kanis --- .../requiredactions/UpdateEmail.java | 25 +++++- .../requiredactions/VerifyEmail.java | 54 +----------- .../util/EmailCooldownManager.java | 83 +++++++++++++++++++ ...onUpdateEmailTestWithVerificationTest.java | 49 +++++++++++ 4 files changed, 160 insertions(+), 51 deletions(-) create mode 100644 services/src/main/java/org/keycloak/authentication/requiredactions/util/EmailCooldownManager.java 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 f994f5bc444..5e040342a58 100644 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateEmail.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateEmail.java @@ -69,6 +69,7 @@ import org.keycloak.userprofile.UserProfile; import org.keycloak.userprofile.UserProfileContext; import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.userprofile.ValidationException; +import org.keycloak.authentication.requiredactions.util.EmailCooldownManager; public class UpdateEmail implements RequiredActionProvider, RequiredActionFactory, EnvironmentDependentProviderFactory { @@ -76,6 +77,7 @@ public class UpdateEmail implements RequiredActionProvider, RequiredActionFactor public static final String CONFIG_VERIFY_EMAIL = "verifyEmail"; private static final String FORCE_EMAIL_VERIFICATION = "forceEmailVerification"; + public static final String EMAIL_RESEND_COOLDOWN_KEY_PREFIX = "update-email-cooldown-"; public static boolean isEnabled(RealmModel realm) { if (realm == null) { @@ -212,6 +214,22 @@ public class UpdateEmail implements RequiredActionProvider, RequiredActionFactor } private void sendEmailUpdateConfirmation(RequiredActionContext context, boolean logoutSessions) { + // Check rate limiting cooldown + Long remaining = EmailCooldownManager.retrieveCooldownEntry(context, EMAIL_RESEND_COOLDOWN_KEY_PREFIX); + if (remaining != null) { + // Pre-fill form with pending email during cooldown + String pendingEmail = getPendingEmailVerification(context); + MultivaluedMap formDataWithPendingEmail = new MultivaluedHashMap<>(); + if (pendingEmail != null) { + formDataWithPendingEmail.putSingle(UserModel.EMAIL, pendingEmail); + } + context.challenge(context.form() + .setError(Messages.COOLDOWN_VERIFICATION_EMAIL, remaining) + .setFormData(formDataWithPendingEmail) + .createResponse(UserModel.RequiredAction.UPDATE_EMAIL)); + return; + } + UserModel user = context.getUser(); String oldEmail = user.getEmail(); String newEmail = context.getHttpRequest().getDecodedFormParameters().getFirst(UserModel.EMAIL); @@ -247,6 +265,9 @@ public class UpdateEmail implements RequiredActionProvider, RequiredActionFactor } context.getEvent().success(); + // Add cooldown entry after successful email send + EmailCooldownManager.addCooldownEntry(context, EMAIL_RESEND_COOLDOWN_KEY_PREFIX); + setPendingEmailVerification(context, newEmail); LoginFormsProvider forms = context.form(); @@ -319,7 +340,9 @@ public class UpdateEmail implements RequiredActionProvider, RequiredActionFactor .helpText("If enabled, the user will be forced to verify the email regardless if email verification is enabled at the realm level or not. Otherwise, verification will be based on the realm level setting.") .type(ProviderConfigProperty.BOOLEAN_TYPE) .defaultValue(Boolean.FALSE) - .add().build()); + .add() + .build()); + config.add(EmailCooldownManager.createCooldownConfigProperty()); return config; } 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 fd03245d958..7827064fef9 100755 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java @@ -41,8 +41,6 @@ import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.RealmModel; -import org.keycloak.models.RequiredActionProviderModel; -import org.keycloak.models.SingleUseObjectProvider; import org.keycloak.models.UserModel; import org.keycloak.policy.MaxAuthAgePasswordPolicyProviderFactory; import org.keycloak.protocol.AuthorizationEndpointBase; @@ -53,9 +51,9 @@ import org.keycloak.services.validation.Validation; import org.keycloak.sessions.AuthenticationSessionCompoundId; import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.authentication.requiredactions.util.EmailCooldownManager; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.concurrent.TimeUnit; @@ -64,11 +62,8 @@ import java.util.concurrent.TimeUnit; * @version $Revision: 1 $ */ public class VerifyEmail implements RequiredActionProvider, RequiredActionFactory { - private static final String EMAIL_RESEND_COOLDOWN_SECONDS = "emailResendCooldownSeconds"; - private static final int EMAIL_RESEND_COOLDOWN_DEFAULT_SECONDS = 30; public static final String EMAIL_RESEND_COOLDOWN_KEY_PREFIX = "verify-email-cooldown-"; private static final Logger logger = Logger.getLogger(VerifyEmail.class); - private static final String KEY_EXPIRE = "expire"; @Override public void evaluateTriggers(RequiredActionContext context) { @@ -116,7 +111,7 @@ public class VerifyEmail implements RequiredActionProvider, RequiredActionFactor // Do not allow resending e-mail by simple page refresh, i.e. when e-mail sent, it should be resent properly via email-verification endpoint if (!Objects.equals(authSession.getAuthNote(Constants.VERIFY_EMAIL_KEY), email) && !(isCurrentActionTriggeredFromAIA(context) && isChallenge)) { // Adding the cooldown entry first to prevent concurrent operations - addCooldownEntry(context); + EmailCooldownManager.addCooldownEntry(context, EMAIL_RESEND_COOLDOWN_KEY_PREFIX); authSession.setAuthNote(Constants.VERIFY_EMAIL_KEY, email); EventBuilder event = context.getEvent().clone().event(EventType.SEND_VERIFY_EMAIL).detail(Details.EMAIL, email); challenge = sendVerifyEmail(context, event); @@ -135,7 +130,7 @@ public class VerifyEmail implements RequiredActionProvider, RequiredActionFactor public void processAction(RequiredActionContext context) { logger.debugf("Re-sending email requested for user: %s", context.getUser().getUsername()); - Long remaining = retrieveCooldownEntry(context); + Long remaining = EmailCooldownManager.retrieveCooldownEntry(context, EMAIL_RESEND_COOLDOWN_KEY_PREFIX); if (remaining != null) { Response retryPage = context.form() .setError(Messages.COOLDOWN_VERIFICATION_EMAIL, remaining) @@ -152,22 +147,6 @@ public class VerifyEmail implements RequiredActionProvider, RequiredActionFactor } - private Long retrieveCooldownEntry(RequiredActionContext context) { - SingleUseObjectProvider singleUseCache = context.getSession().singleUseObjects(); - Map cooldownDetails = singleUseCache.get(getCacheKey(context)); - if (cooldownDetails == null) { - return null; - } - long remaining = (Long.parseLong(cooldownDetails.get(KEY_EXPIRE)) - Time.currentTime()); - // Avoid the awkward situation where due to rounding the value is zero - return remaining > 0 ? remaining : null; - } - - private void addCooldownEntry(RequiredActionContext context) { - SingleUseObjectProvider cache = context.getSession().singleUseObjects(); - long cooldownSeconds = getCooldownInSeconds(context); - cache.put(getCacheKey(context), cooldownSeconds, Map.of("expire", Long.toString(Time.currentTime() + cooldownSeconds))); - } @Override public void close() { @@ -212,13 +191,7 @@ public class VerifyEmail implements RequiredActionProvider, RequiredActionFactor maxAge.setType(ProviderConfigProperty.STRING_TYPE); maxAge.setDefaultValue(MaxAuthAgePasswordPolicyProviderFactory.DEFAULT_MAX_AUTH_AGE); - ProviderConfigProperty cooldown = new ProviderConfigProperty(); - cooldown.setName(EMAIL_RESEND_COOLDOWN_SECONDS); - cooldown.setLabel("Cooldown Between Email Resend (seconds)"); - cooldown.setHelpText("Minimum delay in seconds before another email verification email can be sent."); - cooldown.setType(ProviderConfigProperty.STRING_TYPE); - cooldown.setDefaultValue(String.valueOf(EMAIL_RESEND_COOLDOWN_DEFAULT_SECONDS)); - return List.of(maxAge,cooldown); + return List.of(maxAge, EmailCooldownManager.createCooldownConfigProperty()); } @@ -262,23 +235,4 @@ public class VerifyEmail implements RequiredActionProvider, RequiredActionFactor } } - private static String getCacheKey(RequiredActionContext context) { - return EMAIL_RESEND_COOLDOWN_KEY_PREFIX + context.getUser().getId(); - } - - private long getCooldownInSeconds(RequiredActionContext context) { - try { - RequiredActionProviderModel model = context.getRealm().getRequiredActionProviderByAlias(getId()); - if (model == null || model.getConfig() == null) { - logger.warn("No RequiredActionProviderModel found for alias: " + getId()); - return EMAIL_RESEND_COOLDOWN_DEFAULT_SECONDS; - } - - String value = model.getConfig().getOrDefault(EMAIL_RESEND_COOLDOWN_SECONDS, String.valueOf(EMAIL_RESEND_COOLDOWN_DEFAULT_SECONDS)); - return Long.parseLong(value); - } catch (RuntimeException e) { - logger.error("Failed to fetch cooldown from config: ", e); - return EMAIL_RESEND_COOLDOWN_DEFAULT_SECONDS; - } - } } diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/util/EmailCooldownManager.java b/services/src/main/java/org/keycloak/authentication/requiredactions/util/EmailCooldownManager.java new file mode 100644 index 00000000000..faa76d426f1 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/util/EmailCooldownManager.java @@ -0,0 +1,83 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.authentication.requiredactions.util; + +import java.util.Map; + +import org.jboss.logging.Logger; +import org.keycloak.authentication.RequiredActionContext; +import org.keycloak.common.util.Time; +import org.keycloak.models.RequiredActionProviderModel; +import org.keycloak.models.SingleUseObjectProvider; +import org.keycloak.provider.ProviderConfigProperty; + +public class EmailCooldownManager { + + private static final Logger logger = Logger.getLogger(EmailCooldownManager.class); + + public static final String EMAIL_RESEND_COOLDOWN_SECONDS = "emailResendCooldownSeconds"; + public static final int EMAIL_RESEND_COOLDOWN_DEFAULT_SECONDS = 30; + private static final String KEY_EXPIRE = "expire"; + + public static Long retrieveCooldownEntry(RequiredActionContext context, String keyPrefix) { + SingleUseObjectProvider singleUseCache = context.getSession().singleUseObjects(); + Map cooldownDetails = singleUseCache.get(getCacheKey(context, keyPrefix)); + if (cooldownDetails == null) { + return null; + } + long remaining = (Long.parseLong(cooldownDetails.get(KEY_EXPIRE)) - Time.currentTime()); + // Avoid the awkward situation where due to rounding the value is zero + return remaining > 0 ? remaining : null; + } + + public static void addCooldownEntry(RequiredActionContext context, String keyPrefix) { + SingleUseObjectProvider cache = context.getSession().singleUseObjects(); + long cooldownSeconds = getCooldownInSeconds(context); + cache.put(getCacheKey(context, keyPrefix), cooldownSeconds, Map.of(KEY_EXPIRE, Long.toString(Time.currentTime() + cooldownSeconds))); + } + + public static ProviderConfigProperty createCooldownConfigProperty() { + ProviderConfigProperty cooldown = new ProviderConfigProperty(); + cooldown.setName(EMAIL_RESEND_COOLDOWN_SECONDS); + cooldown.setLabel("Cooldown Between Email Resend (seconds)"); + cooldown.setHelpText("Minimum delay in seconds before another email verification email can be sent."); + cooldown.setType(ProviderConfigProperty.STRING_TYPE); + cooldown.setDefaultValue(String.valueOf(EMAIL_RESEND_COOLDOWN_DEFAULT_SECONDS)); + return cooldown; + } + + private static String getCacheKey(RequiredActionContext context, String keyPrefix) { + return keyPrefix + context.getUser().getId(); + } + + private static long getCooldownInSeconds(RequiredActionContext context) { + try { + RequiredActionProviderModel model = context.getRealm().getRequiredActionProviderByAlias(context.getAction()); + if (model == null || model.getConfig() == null) { + logger.warn("No RequiredActionProviderModel found for alias: " + context.getAction()); + return EMAIL_RESEND_COOLDOWN_DEFAULT_SECONDS; + } + + String value = model.getConfig().getOrDefault(EMAIL_RESEND_COOLDOWN_SECONDS, String.valueOf(EMAIL_RESEND_COOLDOWN_DEFAULT_SECONDS)); + return Long.parseLong(value); + } catch (RuntimeException e) { + logger.error("Failed to fetch cooldown from config: ", e); + return EMAIL_RESEND_COOLDOWN_DEFAULT_SECONDS; + } + } +} 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 26612d07a13..44feba651c1 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 @@ -653,4 +653,53 @@ public class RequiredActionUpdateEmailTestWithVerificationTest extends AbstractR errorPage.assertCurrent(); assertEquals("This email verification has been cancelled by an administrator.", errorPage.getError()); } + + @Test + public void testUpdateEmailVerificationResendTooFast() throws Exception { + UserRepresentation testUser = testRealm().users().search("test-user@localhost").get(0); + + loginPage.open(); + loginPage.login("test-user@localhost", "password"); + + updateEmailPage.assertCurrent(); + updateEmailPage.changeEmail("newemail@localhost"); + + // First email should be sent + assertEquals(1, greenMail.getReceivedMessages().length); + assertThat("Should show pending verification message", + driver.getPageSource(), containsString("A confirmation email has been sent to newemail@localhost.")); + + // Logout and login again to get back to update email page for resend + testRealm().users().get(testUser.getId()).logout(); + loginPage.open(); + loginPage.login("test-user@localhost", "password"); + updateEmailPage.assertCurrent(); + + // Try to resend immediately - should be blocked by cooldown + updateEmailPage.changeEmail("newemail@localhost"); + assertThat("Should show cooldown error message", + driver.getPageSource(), containsString("You must wait")); + assertEquals("Email should not be sent again due to cooldown", 1, greenMail.getReceivedMessages().length); + + // Check that email field is pre-filled with the pending email after cooldown error + assertEquals("Email field should be pre-filled with pending email during cooldown", + "newemail@localhost", updateEmailPage.getEmail()); + + try { + // Move time forward beyond cooldown period (default 30 seconds) + setTimeOffset(40); + + // Logout and login again to retry after cooldown + testRealm().users().get(testUser.getId()).logout(); + loginPage.open(); + loginPage.login("test-user@localhost", "password"); + updateEmailPage.assertCurrent(); + + // Now resend should work + updateEmailPage.changeEmail("newemail@localhost"); + assertEquals("Second email should be sent after cooldown expires", 2, greenMail.getReceivedMessages().length); + } finally { + setTimeOffset(0); + } + } }