mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-09 23:12:06 -03:30
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:
parent
d8055acb45
commit
9ebab2f017
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user