8000 Reset email background fallback by gaoyikeshuer · Pull Request #53991 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/documentation/upgrading/topics/changes/changes-27_0_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,17 @@ Log parsers that interpret backslash escapes still read the original value and n
Log parsers that match on the exact logged text must be updated to expect the doubled backslash.
The change does not apply when the listener is configured with `--spi-events-listener--jboss-logging--quotes=none` or `--spi-events-listener--jboss-logging--sanitize=false`.

=== Reset password emails are sent in the background

The *Forgot password* form no longer waits for the mail server. It responds right away and sends the email in the background. Before, the form was slower for users that exist and have an email, so someone could find out which usernames exist. Now every username takes the same path, whether an email is sent or not.

The `SEND_RESET_PASSWORD` event is still recorded during the request. It now means the email was accepted for sending. If sending fails later, a `SEND_RESET_PASSWORD_ERROR` event is recorded too, from a background thread without the HTTP request context. The email is also not sent, and an error event is recorded, if the link expired or the user was deleted or disabled before it could be sent.

The emails are sent by the `email` executor. It has 4 threads by default and starts more, up to 64, once 128 emails are waiting. If even more are waiting, the email is not sent and the request records a `SEND_RESET_PASSWORD_ERROR` event instead of `SEND_RESET_PASSWORD`. Emails still waiting when the server shuts down are not sent, which is only logged. You can change these numbers with `spi-executors--default--email-min`, `spi-executors--default--email-max` and `spi-executors--default--email-queue`. The `queue` option is new and also available for the other executors, which keep an unbounded queue unless you set it.

Custom email template providers, including ones that extend `FreeMarkerEmailTemplateProvider`, still send the email right away, so for them the response time still depends on the mail server.
To send it in the background, a provider that extends `FreeMarkerEmailTemplateProvider` can override `supportsBackgroundDelivery()`. Other providers need to implement `EmailTemplateProvider.renderPasswordReset` and `send(RenderedEmail, int, EmailDeliveryListener)`.

// ------------------------ Deprecated features ------------------------ //
== Deprecated features

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright 2026 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.email;

import org.keycloak.models.KeycloakSession;

/**
* Called from a background thread, with its own session, when an email could not be sent.
*/
@FunctionalInterface
public interface EmailDeliveryListener {

void onFailed(KeycloakSession session, EmailException e);
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,28 @@ public interface EmailTemplateProvider extends Provider {

EmailTemplateProvider setAttribute(String name, Object value);

/**
* Builds the reset password email without sending it. Override together with
* {@link #send(RenderedEmail, int, EmailDeliveryListener)}.
*
* @return the email, or {@code null} if this provider can only send right away
*/
default RenderedEmail renderPasswordReset(String link, long expirationInMinutes) throws EmailException {
return null;
}

/**
* Sends an email built by one of the {@code render} methods, in the background after the transaction commits when
* possible, otherwise right away.
*
* @param expiresAt time in seconds after which the email is no longer worth sending, {@code 0} for no limit
* @param onFailed called if sending in the background fails later
* @throws EmailException
*/
default void send(RenderedEmail email, int expiresAt, EmailDeliveryListener onFailed) throws EmailException {
throw new UnsupportedOperationException("Sending emails in the background is not supported by " + getClass().getName());
}

void sendEvent(Event event) throws EmailException;

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright 2026 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.email;

public record RenderedEmail(String subject, String textBody, String htmlBody) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ public EventBuilder(RealmModel realm, KeycloakSession session) {
realm(realm);
}

/**
* Starts from a copy of {@code event}, so it can be recorded later from another session.
*/
public EventBuilder(RealmModel realm, KeycloakSession session, Event event) {
this(realm, session);
this.event = event.clone();
realm(realm);
}

private static EventStoreProvider getEventStoreProvider(KeycloakSession session) {
EventStoreProvider store = session.getProvider(EventStoreProvider.class);
if (store == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ public interface ExecutorsProvider extends Provider {

String EXPIRATION_TASKS = "expiration-tasks";

String EMAIL = "email";

/**
* Impl will usually return different executors for different tasks.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,13 @@
import org.keycloak.credential.CredentialProvider;
import org.keycloak.credential.PasswordCredentialProvider;
import org.keycloak.credential.PasswordCredentialProviderFactory;
import org.keycloak.email.EmailDeliveryListener;
import org.keycloak.email.EmailException;
import org.keycloak.email.EmailTemplateProvider;
import org.keycloak.email.RenderedEmail;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.Event;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.AuthenticationExecutionModel;
Expand All @@ -51,6 +54,7 @@
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.FormMessage;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.services.ServicesLogger;
Expand All @@ -59,6 +63,7 @@
import org.keycloak.sessions.AuthenticationSessionCompoundId;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.storage.StorageId;
import org.keycloak.storage.adapter.InMemoryUserAdapter;

import org.jboss.logging.Logger;

Expand All @@ -80,64 +85,88 @@ public void authenticate(AuthenticationFlowContext context) {
AuthenticationSessionModel authenticationSession = context.getAuthenticationSession();
String username = authenticationSession.getAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME);

// we don't want people guessing usernames, so if there was a problem obtaining the user, the user will be null.
// just reset login for with a success message
if (user == null) {
context.forkWithSuccessMessage(new FormMessage(Messages.EMAIL_SENT));
return;
}

String actionTokenUserId = authenticationSession.getAuthNote(DefaultActionTokenKey.ACTION_TOKEN_USER_ID);
if (actionTokenUserId != null && Objects.equals(user.getId(), actionTokenUserId)) {
logger.debugf("Forget-password triggered when reauthenticating user after authentication via action token. Skipping " + PROVIDER_ID + " screen and using user '%s' ", user.getUsername());
if (forceLogin(context.getAuthenticatorConfig(), user)) {
// force end of auth session after the required actions
context.getAuthenticationSession().setAuthNote(AuthenticationManager.END_AFTER_REQUIRED_ACTIONS, "true");
if (user != null) {
String actionTokenUserId = authenticationSession.getAuthNote(DefaultActionTokenKey.ACTION_TOKEN_USER_ID);
if (actionTokenUserId != null && Objects.equals(user.getId(), actionTokenUserId)) {
logger.debugf("Forget-password triggered when reauthenticating user after authentication via action token. Skipping " + PROVIDER_ID + " screen and using user '%s' ", user.getUsername());
if (forceLogin(context.getAuthenticatorConfig(), user)) {
// force end of auth session after the required actions
context.getAuthenticationSession().setAuthNote(AuthenticationManager.END_AFTER_REQUIRED_ACTIONS, "true");
}
context.success();
return;
}
context.success();
return;
}


// we don't want people guessing usernames, so every request does the same work, even when no email is sent
EventBuilder event = context.getEvent();
// we don't want people guessing usernames, so if there is a problem, just continuously challenge
if (user.getEmail() == null || user.getEmail().trim().length() == 0) {
boolean deliverable = user != null && user.getEmail() != null && user.getEmail().trim().length() != 0;
if (user != null && !deliverable) {
event.user(user)
.detail(Details.USERNAME, username)
.error(Errors.INVALID_EMAIL);

context.forkWithSuccessMessage(new FormMessage(Messages.EMAIL_SENT));
return;
}
UserModel recipient = deliverable ? user : dummyUser(context, username);

int validityInSecs = context.getRealm().getActionTokenGeneratedByUserLifespan(ResetCredentialsActionToken.TOKEN_TYPE);
int absoluteExpirationInSecs = Time.currentTime() + validityInSecs;

// We send the secret in the email in a link as a query param.
String authSessionEncodedId = AuthenticationSessionCompoundId.fromAuthSession(authenticationSession).getEncodedId();
ResetCredentialsActionToken token = new ResetCredentialsActionToken(user.getId(), user.getEmail(), absoluteExpirationInSecs, authSessionEncodedId, authenticationSession.getClient().getClientId());
ResetCredentialsActionToken token = new ResetCredentialsActionToken(recipient.getId(), recipient.getEmail(), absoluteExpirationInSecs, authSessionEncodedId, authenticationSession.getClient().getClientId());
String link = UriBuilder
.fromUri(context.getActionTokenUrl(token.serialize(context.getSession(), context.getRealm(), context.getUriInfo())))
.build()
.toString();
long expirationInMinutes = TimeUnit.SECONDS.toMinutes(validityInSecs);
try {
context.getSession().getProvider(EmailTemplateProvider.class).setRealm(context.getRealm()).setUser(user).setAuthenticationSession(authenticationSession).sendPasswordReset(link, expirationInMinutes);

event.clone().event(EventType.SEND_RESET_PASSWORD)
.user(user)
.detail(Details.USERNAME, username)
.detail(Details.EMAIL, user.getEmail()).detail(Details.CODE_ID, authenticationSession.getParentSession().getId()).success();
context.forkWithSuccessMessage(new FormMessage(Messages.EMAIL_SENT));
EmailTemplateProvider emailTemplateProvider = context.getSession().getProvider(EmailTemplateProvider.class).setRealm(context.getRealm()).setUser(recipient).setAuthenticationSession(authenticationSession);
RenderedEmail email = emailTemplateProvider.renderPasswordReset(link, expirationInMinutes);
if (deliverable) {
if (email == null) {
// this provider can only send right away
emailTemplateProvider.sendPasswordReset(link, expirationInMinutes);
} else {
emailTemplateProvider.send(email, absoluteExpirationInSecs, failedListener(event, user, username));
}
sentEvent(event, user, username, authenticationSession).success();
}
} catch (EmailException e) {
event.clone().event(EventType.SEND_RESET_PASSWORD)
.detail(Details.REASON, e.getMessage())
.detail(Details.USERNAME, username)
.user(user)
.error(Errors.EMAIL_SEND_FAILED);
ServicesLogger.LOGGER.failedToSendPwdResetEmail(e);
context.forkWithSuccessMessage(new FormMessage(Messages.EMAIL_SENT));
if (deliverable) {
failedEvent(event, user, username).detail(Details.REASON, e.getMessage()).error(Errors.EMAIL_SEND_FAILED);
ServicesLogger.LOGGER.failedToSendPwdResetEmail(e);
}
}
context.forkWithSuccessMessage(new FormMessage(Messages.EMAIL_SENT));
}

private static UserModel dummyUser(AuthenticationFlowContext context, String username) {
UserModel user = new InMemoryUserAdapter(context.getSession(), context.getRealm(), KeycloakModelUtils.generateId());
user.setUsername(username == null ? "unknown" : username);
user.setEmail("unknown@invalid");
return user;
}

private static EventBuilder sentEvent(EventBuilder event, UserModel user, String username, AuthenticationSessionModel authenticationSession) {
return event.clone().event(EventType.SEND_RESET_PASSWORD)
.user(user)
.detail(Details.USERNAME, username)
.detail(Details.EMAIL, user.getEmail()).detail(Details.CODE_ID, authenticationSession.getParentSession().getId());
}

private static EventBuilder failedEvent(EventBuilder event, UserModel user, String username) {
return event.clone().event(EventType.SEND_RESET_PASSWORD)
.detail(Details.USERNAME, username)
.user(user);
}

private static EmailDeliveryListener failedListener(EventBuilder event, UserModel user, String username) {
// runs later with another session, so keep only the event data
Event failedEvent = failedEvent(event, user, username).getEvent();
return (session, e) -> {
new EventBuilder(session.getContext().getRealm(), session, failedEvent).detail(Details.REASON, e.getMessage()).error(Errors.EMAIL_SEND_FAILED);
ServicesLogger.LOGGER.failedToSendPwdResetEmail(e);
};
}

public static Long getLastChangedTimestamp(KeycloakSession session, RealmModel realm, UserModel user) {
Expand Down
Loading
Loading
0