Ignore Accept-Language header for reset email from admin api

Closes #36986

Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
Giuseppe Graziano
2025-05-20 10:34:21 +02:00
committed by Marek Posolda
parent fe66bb7cdf
commit 8833c0aa5d
9 changed files with 96 additions and 23 deletions

View File

@@ -108,6 +108,7 @@ public final class Constants {
public static final String SKIP_LINK = "skipLink";
public static final String TEMPLATE_ATTR_ACTION_URI = "actionUri";
public static final String TEMPLATE_ATTR_REQUIRED_ACTIONS = "requiredActions";
public static final String IGNORE_ACCEPT_LANGUAGE_HEADER = "IGNORE_ACCEPT_LANGUAGE_HEADER";
// Prefix for user attributes used in various "context"data maps
public static final String USER_ATTRIBUTES_PREFIX = "user.attributes.";

View File

@@ -41,4 +41,8 @@ public interface LocaleSelectorProvider extends Provider {
return resolveLocale(realm, user);
}
default Locale resolveLocale(RealmModel realm, UserModel user, boolean ignoreAcceptLanguageHeader) {
return resolveLocale(realm, user);
}
}

View File

@@ -87,6 +87,10 @@ public interface KeycloakContext {
return resolveLocale(user);
}
default Locale resolveLocale(UserModel user, boolean ignoreAcceptLanguageHeader) {
return resolveLocale(user);
}
/**
* Get current AuthenticationSessionModel, can be null out of the AuthenticationSession context.
*

View File

@@ -36,6 +36,7 @@ import org.keycloak.email.freemarker.beans.ProfileBean;
import org.keycloak.events.Event;
import org.keycloak.events.EventType;
import org.keycloak.forms.login.freemarker.model.UrlBean;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakUriInfo;
import org.keycloak.models.OrganizationModel;
@@ -200,7 +201,7 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
attributes.put("link", link);
attributes.put("linkExpiration", expirationInMinutes);
try {
Locale locale = session.getContext().resolveLocale(user, getTheme().getType());
Locale locale = session.getContext().resolveLocale(user, Boolean.parseBoolean(String.valueOf(attributes.get(Constants.IGNORE_ACCEPT_LANGUAGE_HEADER))));
attributes.put("linkExpirationFormatter", new LinkExpirationFormatterMethod(getTheme().getMessages(locale), locale));
} catch (IOException e) {
throw new EmailException("Failed to template email", e);
@@ -214,10 +215,10 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
protected EmailTemplate processTemplate(String subjectKey, List<Object> subjectAttributes, String template, Map<String, Object> attributes) throws EmailException {
try {
Theme theme = getTheme();
Locale locale = session.getContext().resolveLocale(user, theme.getType());
Locale locale = session.getContext().resolveLocale(user, Boolean.parseBoolean(String.valueOf(attributes.get(Constants.IGNORE_ACCEPT_LANGUAGE_HEADER))));
attributes.put("locale", locale);
Theme theme = getTheme();
Properties messages = theme.getEnhancedMessages(realm, locale);
String currentLanguageTag = locale.getLanguage();

View File

@@ -43,11 +43,11 @@ public class DefaultLocaleSelectorProvider implements LocaleSelectorProvider {
@Override
public Locale resolveLocale(RealmModel realm, UserModel user) {
return resolveLocale(realm, user, null);
return resolveLocale(realm, user, false);
}
@Override
public Locale resolveLocale(RealmModel realm, UserModel user, Theme.Type themeType) {
public Locale resolveLocale(RealmModel realm, UserModel user, boolean ignoreAcceptLanguageHeader) {
HttpHeaders requestHeaders = session.getContext().getRequestHeaders();
AuthenticationSessionModel session = this.session.getContext().getAuthenticationSession();
@@ -55,7 +55,7 @@ public class DefaultLocaleSelectorProvider implements LocaleSelectorProvider {
return Locale.ENGLISH;
}
Locale userLocale = getUserLocale(realm, session, user, requestHeaders, themeType);
Locale userLocale = getUserLocale(realm, session, user, requestHeaders, ignoreAcceptLanguageHeader);
if (userLocale != null) {
return userLocale;
}
@@ -68,7 +68,7 @@ public class DefaultLocaleSelectorProvider implements LocaleSelectorProvider {
return Locale.ENGLISH;
}
private Locale getUserLocale(RealmModel realm, AuthenticationSessionModel session, UserModel user, HttpHeaders requestHeaders, Theme.Type themeType) {
private Locale getUserLocale(RealmModel realm, AuthenticationSessionModel session, UserModel user, HttpHeaders requestHeaders, boolean ignoreAcceptLanguageHeader) {
Locale locale;
locale = getUserSelectedLocale(realm, session);
@@ -81,10 +81,6 @@ public class DefaultLocaleSelectorProvider implements LocaleSelectorProvider {
return locale;
}
if(Theme.Type.EMAIL.equals(themeType)) {
return null;
}
locale = getClientSelectedLocale(realm, session);
if (locale != null) {
return locale;
@@ -95,7 +91,7 @@ public class DefaultLocaleSelectorProvider implements LocaleSelectorProvider {
return locale;
}
locale = getAcceptLanguageHeaderLocale(realm, requestHeaders);
locale = getAcceptLanguageHeaderLocale(realm, requestHeaders, ignoreAcceptLanguageHeader);
if (locale != null) {
return locale;
}
@@ -151,7 +147,12 @@ public class DefaultLocaleSelectorProvider implements LocaleSelectorProvider {
return findLocale(realm, localeCookie);
}
private Locale getAcceptLanguageHeaderLocale(RealmModel realm, HttpHeaders httpHeaders) {
private Locale getAcceptLanguageHeaderLocale(RealmModel realm, HttpHeaders httpHeaders, boolean ignoreAcceptLanguageHeader) {
if (ignoreAcceptLanguageHeader) {
return null;
}
if (httpHeaders == null) {
return null;
}

View File

@@ -162,8 +162,8 @@ public abstract class DefaultKeycloakContext implements KeycloakContext {
}
@Override
public Locale resolveLocale(UserModel user, Theme.Type themeType) {
return session.getProvider(LocaleSelectorProvider.class).resolveLocale(getRealm(), user, themeType);
public Locale resolveLocale(UserModel user, boolean ignoreAcceptLanguageHeader) {
return session.getProvider(LocaleSelectorProvider.class).resolveLocale(getRealm(), user, ignoreAcceptLanguageHeader);
}
@Override

View File

@@ -1014,12 +1014,13 @@ public class UserResource {
builder.queryParam("key", token.serialize(session, realm, session.getContext().getUri()));
String link = builder.build(realm.getName()).toString();
this.session.getProvider(EmailTemplateProvider.class)
.setAttribute(Constants.TEMPLATE_ATTR_REQUIRED_ACTIONS, token.getRequiredActions())
.setRealm(realm)
.setUser(user)
.sendExecuteActions(link, TimeUnit.SECONDS.toMinutes(result.lifespan));
.setAttribute(Constants.TEMPLATE_ATTR_REQUIRED_ACTIONS, token.getRequiredActions())
.setAttribute(Constants.IGNORE_ACCEPT_LANGUAGE_HEADER, true)
.setRealm(realm)
.setUser(user)
.sendExecuteActions(link, TimeUnit.SECONDS.toMinutes(result.lifespan));
//audit.user(user).detail(Details.EMAIL, user.getEmail()).detail(Details.CODE_ID, accessCode.getCodeId()).success();

View File

@@ -47,6 +47,9 @@ public class LoginPasswordResetPage extends LanguageComboboxAwarePage {
@FindBy(id = "kc-info-wrapper")
private WebElement infoWrapper;
@FindBy(id = "kc-reset-password-form")
private WebElement formResetPassword;
public void changePassword() {
UIUtils.clickLink(submitButton);
}
@@ -97,4 +100,8 @@ public class LoginPasswordResetPage extends LanguageComboboxAwarePage {
return null;
}
}
public String getFormUrl() {
return formResetPassword.getAttribute("action");
}
}

View File

@@ -26,11 +26,22 @@ import static org.junit.Assert.assertEquals;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
import jakarta.ws.rs.core.HttpHeaders;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.message.BasicNameValuePair;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Assert;
import org.junit.Rule;
@@ -50,6 +61,7 @@ import org.keycloak.testsuite.util.GreenMailRule;
import org.keycloak.testsuite.util.MailUtils;
import org.keycloak.testsuite.util.WaitUtils;
import org.openqa.selenium.By;
import org.openqa.selenium.Cookie;
/**
* @author <a href="mailto:gerbermichi@me.com">Michael Gerber</a>
@@ -81,10 +93,11 @@ public class EmailTest extends AbstractI18NTest {
@Test
public void restPasswordEmail() throws MessagingException, IOException {
String expectedBodyContent = "Someone just requested to change";
sendResetPasswordEmail();
verifyResetPassword("Reset password", expectedBodyContent, null, 1);
changeUserLocale("en");
sendResetPasswordEmail();
verifyResetPassword("Reset password", expectedBodyContent, null, 2);
}
@@ -109,10 +122,12 @@ public class EmailTest extends AbstractI18NTest {
getCleanup().addLocalization(Locale.GERMAN.toLanguageTag());
try {
sendResetPasswordEmail();
verifyResetPassword(subjectEn, expectedBodyContentEn, "<html lang=\"en\" dir=\"ltr\">", 1);
changeUserLocale("de");
sendResetPasswordEmail();
verifyResetPassword(subjectDe, expectedBodyContentDe, "<html lang=\"de\" dir=\"ltr\">", 2);
} finally {
// Revert
@@ -124,6 +139,7 @@ public class EmailTest extends AbstractI18NTest {
public void restPasswordEmailGerman() throws MessagingException, IOException {
changeUserLocale("de");
try {
sendResetPasswordEmail();
verifyResetPassword("Passwort zurücksetzen", "Es wurde eine Änderung", null, 1);
} finally {
// Revert
@@ -158,12 +174,50 @@ public class EmailTest extends AbstractI18NTest {
}
}
private void verifyResetPassword(String expectedSubject, String expectedTextBodyContent, String expectedHtmlBodyContent, int expectedMsgCount)
throws MessagingException, IOException {
@Test
public void restPasswordEmailWithAcceptLanguageHeader() throws MessagingException, IOException {
changeUserLocale(null);
try {
loginPage.open();
loginPage.resetPassword();
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
Set<Cookie> cookies = oauth.getDriver().manage().getCookies();
String cookieHeader = cookies.stream()
.map(cookie -> cookie.getName() + "=" + cookie.getValue())
.collect(Collectors.joining("; "));
String resetFormUrl = resetPasswordPage.getFormUrl();
HttpPost post = new HttpPost(resetFormUrl);
post.setHeader(HttpHeaders.COOKIE, cookieHeader);
post.addHeader(HttpHeaders.ACCEPT_LANGUAGE, "de");
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair("username", "login-test"));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
post.setEntity(formEntity);
CloseableHttpResponse response = client.execute(post);
assertEquals(200, response.getStatusLine().getStatusCode());
}
verifyResetPassword("Passwort zurücksetzen", "Es wurde eine Änderung", null, 1);
} finally {
// Revert
changeUserLocale("en");
}
}
private void sendResetPasswordEmail() {
loginPage.open();
loginPage.resetPassword();
resetPasswordPage.changePassword("login-test");
}
private void verifyResetPassword(String expectedSubject, String expectedTextBodyContent, String expectedHtmlBodyContent, int expectedMsgCount)
throws MessagingException, IOException {
assertEquals(expectedMsgCount, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getReceivedMessages()[expectedMsgCount - 1];