diff --git a/server-spi-private/src/main/java/org/keycloak/models/Constants.java b/server-spi-private/src/main/java/org/keycloak/models/Constants.java index f208187a853..404f1916bb9 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/Constants.java +++ b/server-spi-private/src/main/java/org/keycloak/models/Constants.java @@ -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."; diff --git a/server-spi/src/main/java/org/keycloak/locale/LocaleSelectorProvider.java b/server-spi/src/main/java/org/keycloak/locale/LocaleSelectorProvider.java index c957ef686ee..c8824e6d810 100644 --- a/server-spi/src/main/java/org/keycloak/locale/LocaleSelectorProvider.java +++ b/server-spi/src/main/java/org/keycloak/locale/LocaleSelectorProvider.java @@ -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); + } + } diff --git a/server-spi/src/main/java/org/keycloak/models/KeycloakContext.java b/server-spi/src/main/java/org/keycloak/models/KeycloakContext.java index 3114165fbd7..04d9b9f8bdc 100755 --- a/server-spi/src/main/java/org/keycloak/models/KeycloakContext.java +++ b/server-spi/src/main/java/org/keycloak/models/KeycloakContext.java @@ -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. * diff --git a/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java b/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java index b6e4b74d2c9..70af64ade58 100755 --- a/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java +++ b/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java @@ -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 subjectAttributes, String template, Map 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(); diff --git a/services/src/main/java/org/keycloak/locale/DefaultLocaleSelectorProvider.java b/services/src/main/java/org/keycloak/locale/DefaultLocaleSelectorProvider.java index 20a63121aee..33a4cb51da9 100644 --- a/services/src/main/java/org/keycloak/locale/DefaultLocaleSelectorProvider.java +++ b/services/src/main/java/org/keycloak/locale/DefaultLocaleSelectorProvider.java @@ -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; } diff --git a/services/src/main/java/org/keycloak/services/DefaultKeycloakContext.java b/services/src/main/java/org/keycloak/services/DefaultKeycloakContext.java index ea2e0e503f3..41072d45528 100755 --- a/services/src/main/java/org/keycloak/services/DefaultKeycloakContext.java +++ b/services/src/main/java/org/keycloak/services/DefaultKeycloakContext.java @@ -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 diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java index fdae368897e..8d9082d528b 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java @@ -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(); diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPasswordResetPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPasswordResetPage.java index d44c0c7d603..58c79feee6c 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPasswordResetPage.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPasswordResetPage.java @@ -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"); + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java index a9a116cffac..0f589d83aa8 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java @@ -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 Michael Gerber @@ -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, "", 1); changeUserLocale("de"); + sendResetPasswordEmail(); verifyResetPassword(subjectDe, expectedBodyContentDe, "", 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 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 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];