Add rate limiter for sending verification emails in context of update email

Closes #43076

Signed-off-by: Martin Kanis <mkanis@redhat.com>
This commit is contained in:
Martin Kanis 2025-11-04 11:04:13 +01:00 committed by Pedro Igor
parent d8055acb45
commit 9ebab2f017
4 changed files with 160 additions and 51 deletions

View File

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

View File

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

View File

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

View File

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