org.springframework.web.servlet.i18n.CookieLocaleResolver Maven / Gradle / Ivy
/*
* Copyright 2002-2022 the original author or authors.
*
* 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
*
* https://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.springframework.web.servlet.i18n;
import java.time.Duration;
import java.util.Locale;
import java.util.TimeZone;
import java.util.function.Function;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.context.i18n.LocaleContext;
import org.springframework.context.i18n.TimeZoneAwareLocaleContext;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.util.WebUtils;
/**
* {@link LocaleResolver} implementation that uses a cookie sent back to the user
* in case of a custom setting, with a fallback to the configured default locale,
* the request's {@code Accept-Language} header, or the default locale for the server.
*
* This is particularly useful for stateless applications without user sessions.
* The cookie may optionally contain an associated time zone value as well;
* alternatively, you may specify a default time zone.
*
*
Custom controllers can override the user's locale and time zone by calling
* {@code #setLocale(Context)} on the resolver, e.g. responding to a locale change
* request. As a more convenient alternative, consider using
* {@link org.springframework.web.servlet.support.RequestContext#changeLocale}.
*
* @author Juergen Hoeller
* @author Jean-Pierre Pawlak
* @author Vedran Pavic
* @author Sam Brannen
* @since 27.02.2003
* @see #setDefaultLocale
* @see #setDefaultTimeZone
*/
public class CookieLocaleResolver extends AbstractLocaleContextResolver {
/**
* The name of the request attribute that holds the {@code Locale}.
*
Only used for overriding a cookie value if the locale has been
* changed in the course of the current request!
*
Use {@code RequestContext(Utils).getLocale()}
* to retrieve the current locale in controllers or views.
* @see org.springframework.web.servlet.support.RequestContext#getLocale
* @see org.springframework.web.servlet.support.RequestContextUtils#getLocale
*/
public static final String LOCALE_REQUEST_ATTRIBUTE_NAME = CookieLocaleResolver.class.getName() + ".LOCALE";
/**
* The name of the request attribute that holds the {@code TimeZone}.
*
Only used for overriding a cookie value if the locale has been
* changed in the course of the current request!
*
Use {@code RequestContext(Utils).getTimeZone()}
* to retrieve the current time zone in controllers or views.
* @see org.springframework.web.servlet.support.RequestContext#getTimeZone
* @see org.springframework.web.servlet.support.RequestContextUtils#getTimeZone
*/
public static final String TIME_ZONE_REQUEST_ATTRIBUTE_NAME = CookieLocaleResolver.class.getName() + ".TIME_ZONE";
/**
* The default cookie name used if none is explicitly set.
*/
public static final String DEFAULT_COOKIE_NAME = CookieLocaleResolver.class.getName() + ".LOCALE";
private static final Log logger = LogFactory.getLog(CookieLocaleResolver.class);
private ResponseCookie cookie;
private boolean languageTagCompliant = true;
private boolean rejectInvalidCookies = true;
private Function defaultLocaleFunction = request -> {
Locale defaultLocale = getDefaultLocale();
return (defaultLocale != null ? defaultLocale : request.getLocale());
};
private Function defaultTimeZoneFunction = request -> getDefaultTimeZone();
/**
* Constructor with a given cookie name.
* @since 6.0
*/
public CookieLocaleResolver(String cookieName) {
Assert.notNull(cookieName, "'cookieName' must not be null");
this.cookie = ResponseCookie.from(cookieName).path("/").sameSite("Lax").build();
}
/**
* Constructor with a {@linkplain #DEFAULT_COOKIE_NAME default cookie name}.
*/
public CookieLocaleResolver() {
this(DEFAULT_COOKIE_NAME);
}
/**
* Set the name of cookie created by this resolver.
* @param cookieName the cookie name
* @deprecated as of 6.0 in favor of {@link #CookieLocaleResolver(String)}
*/
@Deprecated
public void setCookieName(String cookieName) {
Assert.notNull(cookieName, "cookieName must not be null");
this.cookie = ResponseCookie.from(cookieName)
.maxAge(this.cookie.getMaxAge())
.domain(this.cookie.getDomain())
.path(this.cookie.getPath())
.secure(this.cookie.isSecure())
.httpOnly(this.cookie.isHttpOnly())
.sameSite(this.cookie.getSameSite())
.build();
}
/**
* Set the cookie "Max-Age" attribute.
* By default, this is set to -1 in which case the cookie persists until
* browser shutdown.
* @since 6.0
* @see org.springframework.http.ResponseCookie.ResponseCookieBuilder#maxAge(Duration)
*/
public void setCookieMaxAge(Duration cookieMaxAge) {
Assert.notNull(cookieMaxAge, "'cookieMaxAge' must not be null");
this.cookie = this.cookie.mutate().maxAge(cookieMaxAge).build();
}
/**
* Variant of {@link #setCookieMaxAge(Duration)} with a value in seconds.
* @deprecated as of 6.0 in favor of {@link #setCookieMaxAge(Duration)}
*/
@Deprecated
public void setCookieMaxAge(@Nullable Integer cookieMaxAge) {
setCookieMaxAge(Duration.ofSeconds((cookieMaxAge != null) ? cookieMaxAge : -1));
}
/**
* Set the cookie "Path" attribute.
*
By default, this is set to {@code "/"}.
* @see org.springframework.http.ResponseCookie.ResponseCookieBuilder#path(String)
*/
public void setCookiePath(@Nullable String cookiePath) {
this.cookie = this.cookie.mutate().path(cookiePath).build();
}
/**
* Set the cookie "Domain" attribute.
* @see org.springframework.http.ResponseCookie.ResponseCookieBuilder#domain(String)
*/
public void setCookieDomain(@Nullable String cookieDomain) {
this.cookie = this.cookie.mutate().domain(cookieDomain).build();
}
/**
* Add the "Secure" attribute to the cookie.
* @see org.springframework.http.ResponseCookie.ResponseCookieBuilder#secure(boolean)
*/
public void setCookieSecure(boolean cookieSecure) {
this.cookie = this.cookie.mutate().secure(cookieSecure).build();
}
/**
* Add the "HttpOnly" attribute to the cookie.
* @see org.springframework.http.ResponseCookie.ResponseCookieBuilder#httpOnly(boolean)
*/
public void setCookieHttpOnly(boolean cookieHttpOnly) {
this.cookie = this.cookie.mutate().httpOnly(cookieHttpOnly).build();
}
/**
* Add the "SameSite" attribute to the cookie.
*
By default, this is set to {@code "Lax"}.
* @since 6.0
* @see org.springframework.http.ResponseCookie.ResponseCookieBuilder#sameSite(String)
*/
public void setCookieSameSite(String cookieSameSite) {
Assert.notNull(cookieSameSite, "cookieSameSite must not be null");
this.cookie = this.cookie.mutate().sameSite(cookieSameSite).build();
}
/**
* Specify whether this resolver's cookies should be compliant with BCP 47
* language tags instead of Java's legacy locale specification format.
*
The default is {@code true}, as of 5.1. Switch this to {@code false}
* for rendering Java's legacy locale specification format. For parsing,
* this resolver leniently accepts the legacy {@link Locale#toString}
* format as well as BCP 47 language tags in any case.
* @since 4.3
* @see #parseLocaleValue(String)
* @see #toLocaleValue(Locale)
* @see Locale#forLanguageTag(String)
* @see Locale#toLanguageTag()
*/
public void setLanguageTagCompliant(boolean languageTagCompliant) {
this.languageTagCompliant = languageTagCompliant;
}
/**
* Return whether this resolver's cookies should be compliant with BCP 47
* language tags instead of Java's legacy locale specification format.
* @since 4.3
*/
public boolean isLanguageTagCompliant() {
return this.languageTagCompliant;
}
/**
* Specify whether to reject cookies with invalid content (e.g. invalid format).
*
The default is {@code true}. Turn this off for lenient handling of parse
* failures, falling back to the default locale and time zone in such a case.
* @since 5.1.7
* @see #setDefaultLocale
* @see #setDefaultTimeZone
* @see #setDefaultLocaleFunction(Function)
* @see #setDefaultTimeZoneFunction(Function)
*/
public void setRejectInvalidCookies(boolean rejectInvalidCookies) {
this.rejectInvalidCookies = rejectInvalidCookies;
}
/**
* Return whether to reject cookies with invalid content (e.g. invalid format).
* @since 5.1.7
*/
public boolean isRejectInvalidCookies() {
return this.rejectInvalidCookies;
}
/**
* Set the function used to determine the default locale for the given request,
* called if no locale cookie has been found.
*
The default implementation returns the configured
* {@linkplain #setDefaultLocale(Locale) default locale}, if any, and otherwise
* falls back to the request's {@code Accept-Language} header locale or the
* default locale for the server.
* @param defaultLocaleFunction the function used to determine the default locale
* @since 6.0
* @see #setDefaultLocale
* @see jakarta.servlet.http.HttpServletRequest#getLocale()
*/
public void setDefaultLocaleFunction(Function defaultLocaleFunction) {
Assert.notNull(defaultLocaleFunction, "defaultLocaleFunction must not be null");
this.defaultLocaleFunction = defaultLocaleFunction;
}
/**
* Set the function used to determine the default time zone for the given request,
* called if no locale cookie has been found.
* The default implementation returns the configured default time zone,
* if any, or {@code null} otherwise.
* @param defaultTimeZoneFunction the function used to determine the default time zone
* @since 6.0
* @see #setDefaultTimeZone
*/
public void setDefaultTimeZoneFunction(Function defaultTimeZoneFunction) {
Assert.notNull(defaultTimeZoneFunction, "defaultTimeZoneFunction must not be null");
this.defaultTimeZoneFunction = defaultTimeZoneFunction;
}
@Override
public Locale resolveLocale(HttpServletRequest request) {
parseLocaleCookieIfNecessary(request);
return (Locale) request.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME);
}
@Override
public LocaleContext resolveLocaleContext(final HttpServletRequest request) {
parseLocaleCookieIfNecessary(request);
return new TimeZoneAwareLocaleContext() {
@Override
@Nullable
public Locale getLocale() {
return (Locale) request.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME);
}
@Override
@Nullable
public TimeZone getTimeZone() {
return (TimeZone) request.getAttribute(TIME_ZONE_REQUEST_ATTRIBUTE_NAME);
}
};
}
private void parseLocaleCookieIfNecessary(HttpServletRequest request) {
if (request.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME) == null) {
Locale locale = null;
TimeZone timeZone = null;
// Retrieve and parse cookie value.
Cookie cookie = WebUtils.getCookie(request, this.cookie.getName());
if (cookie != null) {
String value = cookie.getValue();
String localePart = value;
String timeZonePart = null;
int separatorIndex = localePart.indexOf('/');
if (separatorIndex == -1) {
// Leniently accept older cookies separated by a space...
separatorIndex = localePart.indexOf(' ');
}
if (separatorIndex >= 0) {
localePart = value.substring(0, separatorIndex);
timeZonePart = value.substring(separatorIndex + 1);
}
try {
locale = (!"-".equals(localePart) ? parseLocaleValue(localePart) : null);
if (timeZonePart != null) {
timeZone = StringUtils.parseTimeZoneString(timeZonePart);
}
}
catch (IllegalArgumentException ex) {
if (isRejectInvalidCookies() &&
request.getAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE) == null) {
throw new IllegalStateException("Encountered invalid locale cookie '" +
this.cookie.getName() + "': [" + value + "] due to: " + ex.getMessage());
}
else {
// Lenient handling (e.g. error dispatch): ignore locale/timezone parse exceptions
if (logger.isDebugEnabled()) {
logger.debug("Ignoring invalid locale cookie '" + this.cookie.getName() +
"': [" + value + "] due to: " + ex.getMessage());
}
}
}
if (logger.isTraceEnabled()) {
logger.trace("Parsed cookie value [" + cookie.getValue() + "] into locale '" + locale +
"'" + (timeZone != null ? " and time zone '" + timeZone.getID() + "'" : ""));
}
}
request.setAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME,
(locale != null ? locale : this.defaultLocaleFunction.apply(request)));
request.setAttribute(TIME_ZONE_REQUEST_ATTRIBUTE_NAME,
(timeZone != null ? timeZone : this.defaultTimeZoneFunction.apply(request)));
}
}
@Override
public void setLocaleContext(HttpServletRequest request, @Nullable HttpServletResponse response,
@Nullable LocaleContext localeContext) {
Assert.notNull(response, "HttpServletResponse is required for CookieLocaleResolver");
Locale locale = null;
TimeZone zone = null;
if (localeContext != null) {
locale = localeContext.getLocale();
if (localeContext instanceof TimeZoneAwareLocaleContext timeZoneAwareLocaleContext) {
zone = timeZoneAwareLocaleContext.getTimeZone();
}
String value = (locale != null ? toLocaleValue(locale) : "-") + (zone != null ? '/' + zone.getID() : "");
this.cookie = this.cookie.mutate().value(value).build();
}
response.addHeader(HttpHeaders.SET_COOKIE, this.cookie.toString());
request.setAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME,
(locale != null ? locale : this.defaultLocaleFunction.apply(request)));
request.setAttribute(TIME_ZONE_REQUEST_ATTRIBUTE_NAME,
(zone != null ? zone : this.defaultTimeZoneFunction.apply(request)));
}
/**
* Parse the given locale value coming from an incoming cookie.
* The default implementation calls {@link StringUtils#parseLocale(String)},
* accepting the {@link Locale#toString} format as well as BCP 47 language tags.
* @param localeValue the locale value to parse
* @return the corresponding {@code Locale} instance
* @since 4.3
* @see StringUtils#parseLocale(String)
*/
@Nullable
protected Locale parseLocaleValue(String localeValue) {
return StringUtils.parseLocale(localeValue);
}
/**
* Render the given locale as a text value for inclusion in a cookie.
*
The default implementation calls {@link Locale#toString()}
* or {@link Locale#toLanguageTag()}, depending on the
* {@link #setLanguageTagCompliant "languageTagCompliant"} configuration property.
* @param locale the locale to convert to a string
* @return a String representation for the given locale
* @since 4.3
* @see #isLanguageTagCompliant()
*/
protected String toLocaleValue(Locale locale) {
return (isLanguageTagCompliant() ? locale.toLanguageTag() : locale.toString());
}
/**
* Determine the default locale for the given request, called if no locale
* cookie has been found.
*
The default implementation returns the configured default locale, if any,
* and otherwise falls back to the request's {@code Accept-Language} header
* locale or the default locale for the server.
* @param request the request to resolve the locale for
* @return the default locale (never {@code null})
* @see #setDefaultLocale
* @see jakarta.servlet.http.HttpServletRequest#getLocale()
* @deprecated as of 6.0, in favor of {@link #setDefaultLocaleFunction(Function)}
*/
@Deprecated(since = "6.0")
protected Locale determineDefaultLocale(HttpServletRequest request) {
return this.defaultLocaleFunction.apply(request);
}
/**
* Determine the default time zone for the given request, called if no locale
* cookie has been found.
*
The default implementation returns the configured default time zone,
* if any, or {@code null} otherwise.
* @param request the request to resolve the time zone for
* @return the default time zone (or {@code null} if none defined)
* @see #setDefaultTimeZone
* @deprecated as of 6.0, in favor of {@link #setDefaultTimeZoneFunction(Function)}
*/
@Deprecated(since = "6.0")
@Nullable
protected TimeZone determineDefaultTimeZone(HttpServletRequest request) {
return this.defaultTimeZoneFunction.apply(request);
}
}