add configurable cooldown for email resend in VerifyEmail

Closes #41331

Signed-off-by: Moshie Samuel <moshie.samuel@gmail.com>
Signed-off-by: moshiem <moshiem@hardcorebiometric.com>
Signed-off-by: Alexander Schwartz <aschwart@redhat.com>
Co-authored-by: moshiem <moshiem@hardcorebiometric.com>
Co-authored-by: Alexander Schwartz <aschwart@redhat.com>
This commit is contained in:
Moshie Samuel
2025-08-15 06:31:00 +01:00
committed by GitHub
parent 7629b7dc53
commit 6958f57f0a
7 changed files with 143 additions and 1 deletions

View File

@@ -101,6 +101,12 @@ In order to maintain backwards compatibility, {project_name}'s upgrade will modi
For more information about client configuration, please see link:{adminguide_link}#_client-saml-configuration[Creating a SAML client] chapter in the {adminguide_name}.
=== Validate email action
When validating an email address as a required action or an application initiated action, a user can resend the verification email by default only every 30 seconds, while in earlier versions there was no limitation in re-sending the email.
Administrators can configure the interval per realm in the Verify Email required action in the Authentication section of the realmm.
=== Tracing extended for embedded Infinispan caches
When tracing is enabled, now also calls to other nodes of a {project_name} cluster will create spans in the traces.

View File

@@ -41,14 +41,21 @@ 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;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.services.Urls;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation;
import org.keycloak.sessions.AuthenticationSessionCompoundId;
import org.keycloak.sessions.AuthenticationSessionModel;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
@@ -57,7 +64,12 @@ 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) {
if (context.getRealm().isVerifyEmail() && !context.getUser().isEmailVerified()) {
@@ -98,6 +110,8 @@ 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);
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);
@@ -116,12 +130,39 @@ 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);
if (remaining != null) {
Response retryPage = context.form()
.setError(Messages.COOLDOWN_VERIFICATION_EMAIL, remaining)
.createResponse(UserModel.RequiredAction.VERIFY_EMAIL); // re-render same verify email page
context.challenge(retryPage);
return;
}
// This will allow user to re-send email again
context.getAuthenticationSession().removeAuthNote(Constants.VERIFY_EMAIL_KEY);
process(context, false);
}
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() {
@@ -154,6 +195,28 @@ public class VerifyEmail implements RequiredActionProvider, RequiredActionFactor
return UserModel.RequiredAction.VERIFY_EMAIL.name();
}
@Override
public List<ProviderConfigProperty> getConfigMetadata() {
ProviderConfigProperty maxAge = new ProviderConfigProperty();
maxAge.setName(Constants.MAX_AUTH_AGE_KEY);
maxAge.setLabel("Maximum Age of Authentication");
maxAge.setHelpText("Configures the duration in seconds this action can be used after the last authentication before the user is required to re-authenticate. " +
"This parameter is used just in the context of AIA when the kc_action parameter is available in the request, which is for instance when user " +
"himself updates his password in the account console.");
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);
}
private Response sendVerifyEmail(RequiredActionContext context, EventBuilder event) throws UriBuilderException, IllegalArgumentException {
RealmModel realm = context.getRealm();
UriInfo uriInfo = context.getUriInfo();
@@ -179,6 +242,7 @@ public class VerifyEmail implements RequiredActionProvider, RequiredActionFactor
.setUser(user)
.sendVerifyEmail(link, expirationInMinutes);
event.success();
return context.form().createResponse(UserModel.RequiredAction.VERIFY_EMAIL);
} catch (EmailException e) {
event.clone().event(EventType.SEND_VERIFY_EMAIL)
@@ -192,4 +256,24 @@ public class VerifyEmail implements RequiredActionProvider, RequiredActionFactor
.createErrorPage(Response.Status.INTERNAL_SERVER_ERROR);
}
}
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

@@ -119,6 +119,8 @@ public class Messages {
public static final String VERIFY_EMAIL = "verifyEmailMessage";
public static final String COOLDOWN_VERIFICATION_EMAIL = "emailVerifySendCooldown";
public static final String UPDATE_EMAIL = "updateEmailMessage";
public static final String LINK_IDP = "linkIdpMessage";

View File

@@ -36,6 +36,9 @@ public class VerifyEmailPage extends AbstractPage {
@FindBy(name = "cancel-aia")
private WebElement cancelAIAButton;
@FindBy(className = "kc-feedback-text")
private WebElement feedbackText;
public boolean isCurrent() {
return PageUtils.getPageTitle(driver).equals("Email verification");
}
@@ -48,6 +51,10 @@ public class VerifyEmailPage extends AbstractPage {
return resendEmailLink.getAttribute("href");
}
public String getFeedbackText() {
return feedbackText.getText();
}
public void cancel() {
cancelAIAButton.click();
}

View File

@@ -37,6 +37,7 @@ import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.services.managers.AuthenticationSessionManager;
import org.keycloak.sessions.RootAuthenticationSessionModel;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
@@ -86,6 +87,7 @@ import static org.hamcrest.core.Is.is;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.keycloak.authentication.requiredactions.VerifyEmail.EMAIL_RESEND_COOLDOWN_KEY_PREFIX;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@@ -147,6 +149,15 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
.emailVerified(false)
.email("test-user@localhost").build();
testUserId = ApiUtil.createUserAndResetPasswordWithAdminClient(testRealm(), user, "password");
clearCooldownForUser();
}
private void clearCooldownForUser() {
String cooldownKey = EMAIL_RESEND_COOLDOWN_KEY_PREFIX + testUserId;
testingClient.server().run(session -> {
session.singleUseObjects().remove(cooldownKey);
});
}
protected boolean removeVerifyProfileAtImport() {
@@ -329,6 +340,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
.assertEvent();
String mailCodeId = sendEvent.getDetails().get(Details.CODE_ID);
clearCooldownForUser();
verifyEmailPage.clickResendEmail();
verifyEmailPage.assertCurrent();
@@ -357,6 +369,28 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
events.expectLogin().user(testUserId).session(mailCodeId).detail(Details.USERNAME, "test-user@localhost").assertEvent();
}
@Test
public void verifyEmailResendTooFast() {
loginPage.open();
loginPage.login("test-user@localhost", "password");
verifyEmailPage.assertCurrent();
Assert.assertEquals(1, greenMail.getReceivedMessages().length);
verifyEmailPage.clickResendEmail();
assertThat(verifyEmailPage.getFeedbackText(), Matchers.containsString("You must wait"));
Assert.assertEquals(1, greenMail.getReceivedMessages().length);
try {
setTimeOffset(40);
verifyEmailPage.clickResendEmail();
Assert.assertEquals(2, greenMail.getReceivedMessages().length);
} finally {
setTimeOffset(0);
}
}
@Test
public void verifyEmailResendWithRefreshes() throws IOException, MessagingException {
loginPage.open();
@@ -372,6 +406,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
.assertEvent();
String mailCodeId = sendEvent.getDetails().get(Details.CODE_ID);
clearCooldownForUser();
verifyEmailPage.clickResendEmail();
verifyEmailPage.assertCurrent();
driver.navigate().refresh();
@@ -407,6 +442,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
loginPage.open();
loginPage.login("test-user@localhost", "password");
clearCooldownForUser();
verifyEmailPage.clickResendEmail();
verifyEmailPage.assertCurrent();
@@ -441,6 +477,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
loginPage.open();
loginPage.login("test-user@localhost", "password");
clearCooldownForUser();
verifyEmailPage.clickResendEmail();
verifyEmailPage.assertCurrent();
@@ -468,6 +505,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
// Email verification can be performed any number of times
loginPage.open();
loginPage.login("test-user@localhost", "password");
clearCooldownForUser();
verifyEmailPage.clickResendEmail();
verifyEmailPage.assertCurrent();
Assert.assertEquals(2, greenMail.getReceivedMessages().length);
@@ -985,7 +1023,8 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
String realmId = testRealm().toRepresentation().getId();
testingClient.server().run(session -> {
RealmModel realm = session.realms().getRealm(realmId);
RootAuthenticationSessionModel ras = session.authenticationSessions().getRootAuthenticationSession(realm, authSessionId);
RootAuthenticationSessionModel ras = session.authenticationSessions()
.getRootAuthenticationSession(realm, new AuthenticationSessionManager(session).decodeBase64AndValidateSignature(authSessionId, false));
assertThat("Expecting single auth session", ras.getAuthenticationSessions().keySet(), Matchers.hasSize(1));
ras.getAuthenticationSessions().forEach((id, as) -> as.addRequiredAction(RequiredAction.VERIFY_EMAIL));
});

View File

@@ -539,6 +539,7 @@ public class RegisterTest extends AbstractTestRealmKeycloakTest {
.user(userId)
.assertEvent();
setTimeOffset(40);
verifyEmailPage.clickResendEmail();
verifyEmailPage.assertCurrent();
@@ -567,6 +568,8 @@ public class RegisterTest extends AbstractTestRealmKeycloakTest {
// test that timestamp is current with 10s tollerance
// test user info is set from form
} finally {
setTimeOffset(0);
}
}

View File

@@ -174,6 +174,7 @@ emailVerifyInstruction3=to re-send the email.
emailVerifyInstruction4=To verify your email address, we are about to send you email with instructions to the address {0}.
emailVerifyResend=Re-send verification email
emailVerifySend=Send verification email
emailVerifySendCooldown=You must wait {0} seconds before resending the verification email.
emailLinkIdpTitle=Link {0}
emailLinkIdp1=An email with instructions to link {0} account {1} with your {2} account has been sent to you.