mirror of
https://github.com/keycloak/keycloak.git
synced 2025-12-16 20:15:46 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user