diff --git a/docs/src/test/java/docs/security/RememberMeSecurityConfigurationTests.java b/docs/src/test/java/docs/security/RememberMeSecurityConfigurationTests.java index d0c9a79e..a3c00299 100644 --- a/docs/src/test/java/docs/security/RememberMeSecurityConfigurationTests.java +++ b/docs/src/test/java/docs/security/RememberMeSecurityConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2017 the original author or authors. + * Copyright 2014-2018 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. @@ -16,16 +16,18 @@ package docs.security; +import java.net.HttpCookie; import java.time.Duration; import java.util.Base64; -import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletResponse; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; import org.springframework.session.Session; import org.springframework.session.SessionRepository; import org.springframework.session.web.http.SessionRepositoryFilter; @@ -43,6 +45,7 @@ import static org.springframework.security.test.web.servlet.setup.SecurityMockMv /** * @author rwinch + * @author Vedran Pavic */ @RunWith(SpringRunner.class) @ContextConfiguration(classes = RememberMeSecurityConfiguration.class) @@ -78,7 +81,7 @@ public class RememberMeSecurityConfigurationTests { .andReturn(); // @formatter:on - Cookie cookie = result.getResponse().getCookie("SESSION"); + HttpCookie cookie = getSessionCookie(result.getResponse()); assertThat(cookie.getMaxAge()).isEqualTo(Integer.MAX_VALUE); T session = this.sessions .findById(new String(Base64.getDecoder().decode(cookie.getValue()))); @@ -86,5 +89,15 @@ public class RememberMeSecurityConfigurationTests { .isEqualTo(Duration.ofDays(30)); } + + private HttpCookie getSessionCookie(HttpServletResponse response) { + for (HttpCookie cookie : HttpCookie.parse(response.getHeader(HttpHeaders.SET_COOKIE))) { + if ("SESSION".equals(cookie.getName())) { + return cookie; + } + } + return null; + } + } // end::class[] diff --git a/docs/src/test/java/docs/security/RememberMeSecurityConfigurationXmlTests.java b/docs/src/test/java/docs/security/RememberMeSecurityConfigurationXmlTests.java index 35ec3323..c937a7c4 100644 --- a/docs/src/test/java/docs/security/RememberMeSecurityConfigurationXmlTests.java +++ b/docs/src/test/java/docs/security/RememberMeSecurityConfigurationXmlTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2017 the original author or authors. + * Copyright 2014-2018 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. @@ -16,16 +16,18 @@ package docs.security; +import java.net.HttpCookie; import java.time.Duration; import java.util.Base64; -import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletResponse; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; import org.springframework.session.Session; import org.springframework.session.SessionRepository; import org.springframework.session.web.http.SessionRepositoryFilter; @@ -43,6 +45,7 @@ import static org.springframework.security.test.web.servlet.setup.SecurityMockMv /** * @author rwinch + * @author Vedran Pavic */ @RunWith(SpringRunner.class) @ContextConfiguration @@ -78,7 +81,7 @@ public class RememberMeSecurityConfigurationXmlTests { .andReturn(); // @formatter:on - Cookie cookie = result.getResponse().getCookie("SESSION"); + HttpCookie cookie = getSessionCookie(result.getResponse()); assertThat(cookie.getMaxAge()).isEqualTo(Integer.MAX_VALUE); T session = this.sessions .findById(new String(Base64.getDecoder().decode(cookie.getValue()))); @@ -86,5 +89,15 @@ public class RememberMeSecurityConfigurationXmlTests { .isEqualTo(Duration.ofDays(30)); } + + private HttpCookie getSessionCookie(HttpServletResponse response) { + for (HttpCookie cookie : HttpCookie.parse(response.getHeader(HttpHeaders.SET_COOKIE))) { + if ("SESSION".equals(cookie.getName())) { + return cookie; + } + } + return null; + } + } // end::class[] diff --git a/samples/boot/findbyusername/src/integration-test/java/sample/FindByUsernameTests.java b/samples/boot/findbyusername/src/integration-test/java/sample/FindByUsernameTests.java index 8cb00a8c..0cd39b68 100644 --- a/samples/boot/findbyusername/src/integration-test/java/sample/FindByUsernameTests.java +++ b/samples/boot/findbyusername/src/integration-test/java/sample/FindByUsernameTests.java @@ -16,6 +16,20 @@ package sample; +import java.io.IOException; +import java.net.HttpCookie; +import java.util.List; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletResponseWrapper; + import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -30,8 +44,11 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; +import org.springframework.core.Ordered; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.http.HttpHeaders; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.htmlunit.webdriver.MockMvcHtmlUnitDriverBuilder; @@ -96,6 +113,62 @@ public class FindByUsernameTests { redisContainer().getFirstMappedPort()); } + @Bean + public FilterRegistrationBean testFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>( + new SetCookieHandlerFilter()); + registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); + return registrationBean; + } + + } + + private static class SetCookieHandlerFilter implements Filter { + + @Override + public void init(FilterConfig filterConfig) { + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, + FilterChain chain) throws IOException, ServletException { + final HttpServletResponse httpServletResponse = (HttpServletResponse) response; + HttpServletResponseWrapper responseWrapper = new HttpServletResponseWrapper( + httpServletResponse) { + + @Override + public void addHeader(String name, String value) { + if (HttpHeaders.SET_COOKIE.equals(name)) { + List cookies = HttpCookie.parse(value); + if (!cookies.isEmpty()) { + addCookie(toServletCookie(cookies.get(0))); + } + } + super.setHeader(name, value); + } + + }; + + chain.doFilter(request, responseWrapper); + } + + @Override + public void destroy() { + } + + private static Cookie toServletCookie(HttpCookie httpCookie) { + Cookie cookie = new Cookie(httpCookie.getName(), httpCookie.getValue()); + String domain = httpCookie.getDomain(); + if (domain != null) { + cookie.setDomain(domain); + } + cookie.setMaxAge((int) httpCookie.getMaxAge()); + cookie.setPath(httpCookie.getPath()); + cookie.setSecure(httpCookie.getSecure()); + cookie.setHttpOnly(httpCookie.isHttpOnly()); + return cookie; + } + } } diff --git a/samples/boot/jdbc/src/integration-test/java/sample/BootTests.java b/samples/boot/jdbc/src/integration-test/java/sample/BootTests.java index 8ef19ba9..d1ceb09d 100644 --- a/samples/boot/jdbc/src/integration-test/java/sample/BootTests.java +++ b/samples/boot/jdbc/src/integration-test/java/sample/BootTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2017 the original author or authors. + * Copyright 2014-2018 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. @@ -16,6 +16,20 @@ package sample; +import java.io.IOException; +import java.net.HttpCookie; +import java.util.List; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletResponseWrapper; + import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -28,12 +42,18 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.core.Ordered; +import org.springframework.http.HttpHeaders; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.htmlunit.webdriver.MockMvcHtmlUnitDriverBuilder; /** * @author EddĂș MelĂ©ndez + * @author Vedran Pavic */ @RunWith(SpringRunner.class) @AutoConfigureMockMvc @@ -79,4 +99,65 @@ public class BootTests { login.assertAt(); } + @TestConfiguration + static class Config { + + @Bean + public FilterRegistrationBean testFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>( + new SetCookieHandlerFilter()); + registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); + return registrationBean; + } + + } + + private static class SetCookieHandlerFilter implements Filter { + + @Override + public void init(FilterConfig filterConfig) { + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, + FilterChain chain) throws IOException, ServletException { + final HttpServletResponse httpServletResponse = (HttpServletResponse) response; + HttpServletResponseWrapper responseWrapper = new HttpServletResponseWrapper( + httpServletResponse) { + + @Override + public void addHeader(String name, String value) { + if (HttpHeaders.SET_COOKIE.equals(name)) { + List cookies = HttpCookie.parse(value); + if (!cookies.isEmpty()) { + addCookie(toServletCookie(cookies.get(0))); + } + } + super.setHeader(name, value); + } + + }; + + chain.doFilter(request, responseWrapper); + } + + @Override + public void destroy() { + } + + private static Cookie toServletCookie(HttpCookie httpCookie) { + Cookie cookie = new Cookie(httpCookie.getName(), httpCookie.getValue()); + String domain = httpCookie.getDomain(); + if (domain != null) { + cookie.setDomain(domain); + } + cookie.setMaxAge((int) httpCookie.getMaxAge()); + cookie.setPath(httpCookie.getPath()); + cookie.setSecure(httpCookie.getSecure()); + cookie.setHttpOnly(httpCookie.isHttpOnly()); + return cookie; + } + + } + } diff --git a/samples/boot/redis-json/src/integration-test/java/sample/HttpRedisJsonTest.java b/samples/boot/redis-json/src/integration-test/java/sample/HttpRedisJsonTest.java index df607165..7201ee3b 100644 --- a/samples/boot/redis-json/src/integration-test/java/sample/HttpRedisJsonTest.java +++ b/samples/boot/redis-json/src/integration-test/java/sample/HttpRedisJsonTest.java @@ -16,8 +16,20 @@ package sample; +import java.io.IOException; +import java.net.HttpCookie; import java.util.List; +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletResponseWrapper; + import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -33,8 +45,11 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; +import org.springframework.core.Ordered; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.http.HttpHeaders; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.htmlunit.webdriver.MockMvcHtmlUnitDriverBuilder; @@ -120,6 +135,62 @@ public class HttpRedisJsonTest { redisContainer().getFirstMappedPort()); } + @Bean + public FilterRegistrationBean testFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>( + new SetCookieHandlerFilter()); + registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); + return registrationBean; + } + + } + + private static class SetCookieHandlerFilter implements Filter { + + @Override + public void init(FilterConfig filterConfig) { + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, + FilterChain chain) throws IOException, ServletException { + final HttpServletResponse httpServletResponse = (HttpServletResponse) response; + HttpServletResponseWrapper responseWrapper = new HttpServletResponseWrapper( + httpServletResponse) { + + @Override + public void addHeader(String name, String value) { + if (HttpHeaders.SET_COOKIE.equals(name)) { + List cookies = HttpCookie.parse(value); + if (!cookies.isEmpty()) { + addCookie(toServletCookie(cookies.get(0))); + } + } + super.setHeader(name, value); + } + + }; + + chain.doFilter(request, responseWrapper); + } + + @Override + public void destroy() { + } + + private static Cookie toServletCookie(HttpCookie httpCookie) { + Cookie cookie = new Cookie(httpCookie.getName(), httpCookie.getValue()); + String domain = httpCookie.getDomain(); + if (domain != null) { + cookie.setDomain(domain); + } + cookie.setMaxAge((int) httpCookie.getMaxAge()); + cookie.setPath(httpCookie.getPath()); + cookie.setSecure(httpCookie.getSecure()); + cookie.setHttpOnly(httpCookie.isHttpOnly()); + return cookie; + } + } } diff --git a/samples/boot/redis/src/integration-test/java/sample/BootTests.java b/samples/boot/redis/src/integration-test/java/sample/BootTests.java index 7127d22f..60b4af4d 100644 --- a/samples/boot/redis/src/integration-test/java/sample/BootTests.java +++ b/samples/boot/redis/src/integration-test/java/sample/BootTests.java @@ -16,6 +16,20 @@ package sample; +import java.io.IOException; +import java.net.HttpCookie; +import java.util.List; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletResponseWrapper; + import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -30,8 +44,11 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; +import org.springframework.core.Ordered; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.http.HttpHeaders; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.htmlunit.webdriver.MockMvcHtmlUnitDriverBuilder; @@ -102,6 +119,62 @@ public class BootTests { redisContainer().getFirstMappedPort()); } + @Bean + public FilterRegistrationBean testFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>( + new SetCookieHandlerFilter()); + registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); + return registrationBean; + } + + } + + private static class SetCookieHandlerFilter implements Filter { + + @Override + public void init(FilterConfig filterConfig) { + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, + FilterChain chain) throws IOException, ServletException { + final HttpServletResponse httpServletResponse = (HttpServletResponse) response; + HttpServletResponseWrapper responseWrapper = new HttpServletResponseWrapper( + httpServletResponse) { + + @Override + public void addHeader(String name, String value) { + if (HttpHeaders.SET_COOKIE.equals(name)) { + List cookies = HttpCookie.parse(value); + if (!cookies.isEmpty()) { + addCookie(toServletCookie(cookies.get(0))); + } + } + super.setHeader(name, value); + } + + }; + + chain.doFilter(request, responseWrapper); + } + + @Override + public void destroy() { + } + + private static Cookie toServletCookie(HttpCookie httpCookie) { + Cookie cookie = new Cookie(httpCookie.getName(), httpCookie.getValue()); + String domain = httpCookie.getDomain(); + if (domain != null) { + cookie.setDomain(domain); + } + cookie.setMaxAge((int) httpCookie.getMaxAge()); + cookie.setPath(httpCookie.getPath()); + cookie.setSecure(httpCookie.getSecure()); + cookie.setHttpOnly(httpCookie.isHttpOnly()); + return cookie; + } + } } diff --git a/spring-session-core/src/main/java/org/springframework/session/web/http/DefaultCookieSerializer.java b/spring-session-core/src/main/java/org/springframework/session/web/http/DefaultCookieSerializer.java index 427a4129..7a260603 100644 --- a/spring-session-core/src/main/java/org/springframework/session/web/http/DefaultCookieSerializer.java +++ b/spring-session-core/src/main/java/org/springframework/session/web/http/DefaultCookieSerializer.java @@ -16,8 +16,13 @@ package org.springframework.session.web.http; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Base64; +import java.util.BitSet; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -41,6 +46,22 @@ public class DefaultCookieSerializer implements CookieSerializer { private static final Log logger = LogFactory.getLog(DefaultCookieSerializer.class); + private static final BitSet domainValid = new BitSet(128); + + static { + for (char c = '0'; c <= '9'; c++) { + domainValid.set(c); + } + for (char c = 'a'; c <= 'z'; c++) { + domainValid.set(c); + } + for (char c = 'A'; c <= 'Z'; c++) { + domainValid.set(c); + } + domainValid.set('.'); + domainValid.set('-'); + } + private String cookieName = "SESSION"; private Boolean useSecureCookie; @@ -61,6 +82,8 @@ public class DefaultCookieSerializer implements CookieSerializer { private String rememberMeRequestAttribute; + private String sameSite = "Lax"; + /* * (non-Javadoc) * @@ -75,7 +98,8 @@ public class DefaultCookieSerializer implements CookieSerializer { for (Cookie cookie : cookies) { if (this.cookieName.equals(cookie.getName())) { String sessionId = (this.useBase64Encoding - ? base64Decode(cookie.getValue()) : cookie.getValue()); + ? base64Decode(cookie.getValue()) + : cookie.getValue()); if (sessionId == null) { continue; } @@ -101,37 +125,43 @@ public class DefaultCookieSerializer implements CookieSerializer { HttpServletRequest request = cookieValue.getRequest(); HttpServletResponse response = cookieValue.getResponse(); - String requestedCookieValue = cookieValue.getCookieValue(); - String actualCookieValue = (this.jvmRoute != null - ? requestedCookieValue + this.jvmRoute : requestedCookieValue); - - Cookie sessionCookie = new Cookie(this.cookieName, this.useBase64Encoding - ? base64Encode(actualCookieValue) : actualCookieValue); - sessionCookie.setSecure(isSecureCookie(request)); - sessionCookie.setPath(getCookiePath(request)); - String domainName = getDomainName(request); - if (domainName != null) { - sessionCookie.setDomain(domainName); + StringBuilder sb = new StringBuilder(); + sb.append(this.cookieName).append('='); + String value = getValue(cookieValue); + if (value != null && value.length() > 0) { + validateValue(value); + sb.append(value); + } + int maxAge = getMaxAge(cookieValue); + if (maxAge > -1) { + sb.append("; Max-Age=").append(cookieValue.getCookieMaxAge()); + OffsetDateTime expires = (maxAge != 0 + ? OffsetDateTime.now().plusSeconds(maxAge) + : Instant.EPOCH.atOffset(ZoneOffset.UTC)); + sb.append("; Expires=") + .append(expires.format(DateTimeFormatter.RFC_1123_DATE_TIME)); + } + String domain = getDomainName(request); + if (domain != null && domain.length() > 0) { + validateDomain(domain); + sb.append("; Domain=").append(domain); + } + String path = getCookiePath(request); + if (path != null && path.length() > 0) { + validatePath(path); + sb.append("; Path=").append(path); + } + if (isSecureCookie(request)) { + sb.append("; Secure"); } - if (this.useHttpOnlyCookie) { - sessionCookie.setHttpOnly(true); + sb.append("; HttpOnly"); + } + if (this.sameSite != null) { + sb.append("; SameSite=").append(this.sameSite); } - if (cookieValue.getCookieMaxAge() < 0) { - if (this.rememberMeRequestAttribute != null - && request.getAttribute(this.rememberMeRequestAttribute) != null) { - // the cookie is only written at time of session creation, so we rely on - // session expiration rather than cookie expiration if remember me is enabled - cookieValue.setCookieMaxAge(Integer.MAX_VALUE); - } - else if (this.cookieMaxAge != null) { - cookieValue.setCookieMaxAge(this.cookieMaxAge); - } - } - sessionCookie.setMaxAge(cookieValue.getCookieMaxAge()); - - response.addCookie(sessionCookie); + response.addHeader("Set-Cookie", sb.toString()); } /** @@ -162,6 +192,81 @@ public class DefaultCookieSerializer implements CookieSerializer { return new String(encodedCookieBytes); } + private String getValue(CookieValue cookieValue) { + String requestedCookieValue = cookieValue.getCookieValue(); + String actualCookieValue = requestedCookieValue; + if (this.jvmRoute != null) { + actualCookieValue = requestedCookieValue + this.jvmRoute; + } + if (this.useBase64Encoding) { + actualCookieValue = base64Encode(actualCookieValue); + } + return actualCookieValue; + } + + private void validateValue(String value) { + int start = 0; + int end = value.length(); + if ((end > 1) && (value.charAt(0) == '"') && (value.charAt(end - 1) == '"')) { + start = 1; + end--; + } + char[] chars = value.toCharArray(); + for (int i = start; i < end; i++) { + char c = chars[i]; + if (c < 0x21 || c == 0x22 || c == 0x2c || c == 0x3b || c == 0x5c + || c == 0x7f) { + throw new IllegalArgumentException( + "Invalid character in cookie value: " + Integer.toString(c)); + } + } + } + + private int getMaxAge(CookieValue cookieValue) { + int maxAge = cookieValue.getCookieMaxAge(); + if (maxAge < 0) { + if (this.rememberMeRequestAttribute != null && cookieValue.getRequest() + .getAttribute(this.rememberMeRequestAttribute) != null) { + // the cookie is only written at time of session creation, so we rely on + // session expiration rather than cookie expiration if remember me is + // enabled + cookieValue.setCookieMaxAge(Integer.MAX_VALUE); + } + else if (this.cookieMaxAge != null) { + cookieValue.setCookieMaxAge(this.cookieMaxAge); + } + } + return cookieValue.getCookieMaxAge(); + } + + private void validateDomain(String domain) { + int i = 0; + int cur = -1; + int prev; + char[] chars = domain.toCharArray(); + while (i < chars.length) { + prev = cur; + cur = chars[i]; + if (!domainValid.get(cur) + || ((prev == '.' || prev == -1) && (cur == '.' || cur == '-')) + || (prev == '-' && cur == '.')) { + throw new IllegalArgumentException("Invalid cookie domain: " + domain); + } + i++; + } + if (cur == '.' || cur == '-') { + throw new IllegalArgumentException("Invalid cookie domain: " + domain); + } + } + + private void validatePath(String path) { + for (char ch : path.toCharArray()) { + if (ch < 0x20 || ch > 0x7E || ch == ';') { + throw new IllegalArgumentException("Invalid cookie path: " + path); + } + } + } + /** * Sets if a Cookie marked as secure should be used. The default is to use the value * of {@link HttpServletRequest#isSecure()}. @@ -317,6 +422,16 @@ public class DefaultCookieSerializer implements CookieSerializer { this.rememberMeRequestAttribute = rememberMeRequestAttribute; } + /** + * Set the value for the {@code SameSite} cookie directive. The default value is + * {@code Lax}. + * @param sameSite the SameSite directive value + * @since 2.1.0 + */ + public void setSameSite(String sameSite) { + this.sameSite = sameSite; + } + private String getDomainName(HttpServletRequest request) { if (this.domainName != null) { return this.domainName; diff --git a/spring-session-core/src/test/java/org/springframework/session/web/http/CookieHttpSessionIdResolverTests.java b/spring-session-core/src/test/java/org/springframework/session/web/http/CookieHttpSessionIdResolverTests.java index 38160f11..b13187bd 100644 --- a/spring-session-core/src/test/java/org/springframework/session/web/http/CookieHttpSessionIdResolverTests.java +++ b/spring-session-core/src/test/java/org/springframework/session/web/http/CookieHttpSessionIdResolverTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2017 the original author or authors. + * Copyright 2014-2018 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. @@ -18,12 +18,14 @@ package org.springframework.session.web.http; import java.util.Base64; import java.util.Collections; +import java.util.List; import javax.servlet.http.Cookie; import org.junit.Before; import org.junit.Test; +import org.springframework.http.ResponseCookie; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.session.MapSession; @@ -83,7 +85,7 @@ public class CookieHttpSessionIdResolverTests { this.strategy.setSessionId(this.request, this.response, this.session.getId()); this.strategy.setSessionId(this.request, this.response, this.session.getId()); - assertThat(this.response.getCookies()).hasSize(1); + assertThat(this.response.getHeaders("Set-Cookie")).hasSize(1); } @Test @@ -93,11 +95,12 @@ public class CookieHttpSessionIdResolverTests { this.strategy.setSessionId(this.request, this.response, this.session.getId()); this.strategy.setSessionId(this.request, this.response, newSession.getId()); - Cookie[] cookies = this.response.getCookies(); + List cookies = ResponseCookieParser.parse(this.response); assertThat(cookies).hasSize(2); - assertThat(base64Decode(cookies[0].getValue())).isEqualTo(this.session.getId()); - assertThat(base64Decode(cookies[1].getValue())).isEqualTo(newSession.getId()); + assertThat(base64Decode(cookies.get(0).getValue())) + .isEqualTo(this.session.getId()); + assertThat(base64Decode(cookies.get(1).getValue())).isEqualTo(newSession.getId()); } @Test @@ -105,7 +108,7 @@ public class CookieHttpSessionIdResolverTests { this.request.setContextPath("/somethingunique"); this.strategy.setSessionId(this.request, this.response, this.session.getId()); - Cookie sessionCookie = this.response.getCookie(this.cookieName); + ResponseCookie sessionCookie = getCookie(); assertThat(sessionCookie.getPath()) .isEqualTo(this.request.getContextPath() + "/"); } @@ -128,7 +131,7 @@ public class CookieHttpSessionIdResolverTests { this.request.setContextPath("/somethingunique"); this.strategy.expireSession(this.request, this.response); - Cookie sessionCookie = this.response.getCookie(this.cookieName); + ResponseCookie sessionCookie = getCookie(); assertThat(sessionCookie.getPath()) .isEqualTo(this.request.getContextPath() + "/"); } @@ -173,8 +176,12 @@ public class CookieHttpSessionIdResolverTests { this.request.setCookies(new Cookie(this.cookieName, base64Encode(value))); } + private ResponseCookie getCookie() { + return ResponseCookieParser.parse(this.response, this.cookieName); + } + private String getSessionId() { - return base64Decode(this.response.getCookie(this.cookieName).getValue()); + return base64Decode(getCookie().getValue()); } private static String base64Encode(String value) { diff --git a/spring-session-core/src/test/java/org/springframework/session/web/http/DefaultCookieSerializerTests.java b/spring-session-core/src/test/java/org/springframework/session/web/http/DefaultCookieSerializerTests.java index b7c2a28a..97c8a176 100644 --- a/spring-session-core/src/test/java/org/springframework/session/web/http/DefaultCookieSerializerTests.java +++ b/spring-session-core/src/test/java/org/springframework/session/web/http/DefaultCookieSerializerTests.java @@ -26,6 +26,7 @@ import org.junit.runner.RunWith; import org.junit.runners.Parameterized; import org.junit.runners.Parameterized.Parameters; +import org.springframework.http.ResponseCookie; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.session.web.http.CookieSerializer.CookieValue; @@ -324,7 +325,7 @@ public class DefaultCookieSerializerTests { public void writeCookieCookieMaxAgeDefault() { this.serializer.writeCookieValue(cookieValue(this.sessionId)); - assertThat(getCookie().getMaxAge()).isEqualTo(-1); + assertThat(getCookie().getMaxAge().getSeconds()).isEqualTo(-1); } @Test @@ -333,7 +334,7 @@ public class DefaultCookieSerializerTests { this.serializer.writeCookieValue(cookieValue(this.sessionId)); - assertThat(getCookie().getMaxAge()).isEqualTo(100); + assertThat(getCookie().getMaxAge().getSeconds()).isEqualTo(100); } @Test @@ -342,7 +343,7 @@ public class DefaultCookieSerializerTests { this.serializer.writeCookieValue(cookieValue("")); - assertThat(getCookie().getMaxAge()).isEqualTo(0); + assertThat(getCookie().getMaxAge().getSeconds()).isEqualTo(0); } @Test @@ -352,7 +353,7 @@ public class DefaultCookieSerializerTests { this.serializer.writeCookieValue(cookieValue); - assertThat(getCookie().getMaxAge()).isEqualTo(100); + assertThat(getCookie().getMaxAge().getSeconds()).isEqualTo(100); } // --- secure --- @@ -361,7 +362,7 @@ public class DefaultCookieSerializerTests { public void writeCookieDefaultInsecureRequest() { this.serializer.writeCookieValue(cookieValue(this.sessionId)); - assertThat(getCookie().getSecure()).isFalse(); + assertThat(getCookie().isSecure()).isFalse(); } @Test @@ -371,7 +372,7 @@ public class DefaultCookieSerializerTests { this.serializer.writeCookieValue(cookieValue(this.sessionId)); - assertThat(getCookie().getSecure()).isTrue(); + assertThat(getCookie().isSecure()).isTrue(); } @Test @@ -380,7 +381,7 @@ public class DefaultCookieSerializerTests { this.serializer.writeCookieValue(cookieValue(this.sessionId)); - assertThat(getCookie().getSecure()).isTrue(); + assertThat(getCookie().isSecure()).isTrue(); } @Test @@ -390,7 +391,7 @@ public class DefaultCookieSerializerTests { this.serializer.writeCookieValue(cookieValue(this.sessionId)); - assertThat(getCookie().getSecure()).isFalse(); + assertThat(getCookie().isSecure()).isFalse(); } @Test @@ -399,7 +400,7 @@ public class DefaultCookieSerializerTests { this.serializer.writeCookieValue(cookieValue(this.sessionId)); - assertThat(getCookie().getSecure()).isFalse(); + assertThat(getCookie().isSecure()).isFalse(); } // --- jvmRoute --- @@ -452,7 +453,7 @@ public class DefaultCookieSerializerTests { this.serializer.setRememberMeRequestAttribute("rememberMe"); this.serializer.writeCookieValue(cookieValue(this.sessionId)); - assertThat(getCookie().getMaxAge()).isEqualTo(Integer.MAX_VALUE); + assertThat(getCookie().getMaxAge().getSeconds()).isEqualTo(Integer.MAX_VALUE); } @Test @@ -463,7 +464,40 @@ public class DefaultCookieSerializerTests { cookieValue.setCookieMaxAge(100); this.serializer.writeCookieValue(cookieValue); - assertThat(getCookie().getMaxAge()).isEqualTo(100); + assertThat(getCookie().getMaxAge().getSeconds()).isEqualTo(100); + } + + // --- sameSite --- + + @Test + public void writeCookieDefaultSameSiteLax() { + this.serializer.writeCookieValue(cookieValue(this.sessionId)); + + assertThat(getCookie().getSameSite()).isEqualTo("Lax"); + } + + @Test + public void writeCookieSetSameSiteLax() { + this.serializer.setSameSite("Lax"); + this.serializer.writeCookieValue(cookieValue(this.sessionId)); + + assertThat(getCookie().getSameSite()).isEqualTo("Lax"); + } + + @Test + public void writeCookieSetSameSiteStrict() { + this.serializer.setSameSite("Strict"); + this.serializer.writeCookieValue(cookieValue(this.sessionId)); + + assertThat(getCookie().getSameSite()).isEqualTo("Strict"); + } + + @Test + public void writeCookieSetSameSiteNull() { + this.serializer.setSameSite(null); + this.serializer.writeCookieValue(cookieValue(this.sessionId)); + + assertThat(getCookie().getSameSite()).isNull(); } public void setCookieName(String cookieName) { @@ -478,8 +512,8 @@ public class DefaultCookieSerializerTests { return new Cookie(name, value); } - private Cookie getCookie() { - return this.response.getCookie(this.cookieName); + private ResponseCookie getCookie() { + return ResponseCookieParser.parse(this.response, this.cookieName); } private String getCookieValue() { @@ -487,9 +521,6 @@ public class DefaultCookieSerializerTests { if (!this.useBase64Encoding) { return value; } - if (value == null) { - return null; - } return new String(Base64.getDecoder().decode(value)); } diff --git a/spring-session-core/src/test/java/org/springframework/session/web/http/ResponseCookieParser.java b/spring-session-core/src/test/java/org/springframework/session/web/http/ResponseCookieParser.java new file mode 100644 index 00000000..a9abe5cc --- /dev/null +++ b/spring-session-core/src/test/java/org/springframework/session/web/http/ResponseCookieParser.java @@ -0,0 +1,91 @@ +/* + * Copyright 2014-2018 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 + * + * 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.springframework.session.web.http; + +import java.util.ArrayList; +import java.util.List; + +import javax.servlet.http.HttpServletResponse; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.lang.NonNull; + +final class ResponseCookieParser { + + private ResponseCookieParser() { + } + + static List parse(HttpServletResponse response) { + return doParse(response, null); + } + + static ResponseCookie parse(HttpServletResponse response, String cookieName) { + List responseCookies = doParse(response, cookieName); + return (!responseCookies.isEmpty() ? responseCookies.get(0) : null); + } + + @NonNull + private static List doParse(HttpServletResponse response, + String cookieName) { + List responseCookies = new ArrayList<>(); + for (String setCookieHeader : response.getHeaders(HttpHeaders.SET_COOKIE)) { + String[] cookieParts = setCookieHeader.split("\\s*=\\s*", 2); + if (cookieParts.length != 2) { + return null; + } + String name = cookieParts[0]; + if (cookieName != null && !name.equals(cookieName)) { + continue; + } + String[] valueAndDirectives = cookieParts[1].split("\\s*;\\s*", 2); + String value = valueAndDirectives[0]; + String[] directives = valueAndDirectives[1].split("\\s*;\\s*"); + String domain = null; + int maxAge = -1; + String path = null; + boolean secure = false; + boolean httpOnly = false; + String sameSite = null; + for (String directive : directives) { + if (directive.startsWith("Domain")) { + domain = directive.split("=")[1]; + } + if (directive.startsWith("Max-Age")) { + maxAge = Integer.parseInt(directive.split("=")[1]); + } + if (directive.startsWith("Path")) { + path = directive.split("=")[1]; + } + if (directive.startsWith("Secure")) { + secure = true; + } + if (directive.startsWith("HttpOnly")) { + httpOnly = true; + } + if (directive.startsWith("SameSite")) { + sameSite = directive.split("=")[1]; + } + } + responseCookies.add(ResponseCookie.from(name, value).maxAge(maxAge).path(path) + .domain(domain).secure(secure).httpOnly(httpOnly).sameSite(sameSite) + .build()); + } + return responseCookies; + } + +} diff --git a/spring-session-core/src/test/java/org/springframework/session/web/http/SessionRepositoryFilterTests.java b/spring-session-core/src/test/java/org/springframework/session/web/http/SessionRepositoryFilterTests.java index cd1fad20..42e8e1c1 100644 --- a/spring-session-core/src/test/java/org/springframework/session/web/http/SessionRepositoryFilterTests.java +++ b/spring-session-core/src/test/java/org/springframework/session/web/http/SessionRepositoryFilterTests.java @@ -51,6 +51,7 @@ import org.mockito.junit.MockitoJUnitRunner; import org.springframework.core.Ordered; import org.springframework.core.annotation.AnnotationAwareOrderComparator; +import org.springframework.http.ResponseCookie; import org.springframework.mock.web.MockFilterChain; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; @@ -423,7 +424,7 @@ public class SessionRepositoryFilterTests { } }); - assertThat(this.response.getCookie("SESSION")).isNull(); + assertThat(getSessionCookie()).isNull(); } @Test @@ -441,7 +442,7 @@ public class SessionRepositoryFilterTests { wrappedRequest.getSession(); } }); - assertThat(this.response.getCookie("SESSION")).isNotNull(); + assertThat(getSessionCookie()).isNotNull(); nextRequest(); @@ -453,7 +454,7 @@ public class SessionRepositoryFilterTests { } }); - assertThat(this.response.getCookie("SESSION")).isNotNull(); + assertThat(getSessionCookie()).isNotNull(); } @Test @@ -653,10 +654,10 @@ public class SessionRepositoryFilterTests { } }); - Cookie session = getSessionCookie(); + ResponseCookie session = getSessionCookie(); assertThat(session.isHttpOnly()).describedAs("Session Cookie should be HttpOnly") .isTrue(); - assertThat(session.getSecure()) + assertThat(session.isSecure()) .describedAs("Session Cookie should be marked as Secure").isTrue(); } @@ -1509,13 +1510,13 @@ public class SessionRepositoryFilterTests { // --- helper methods private void assertNewSession() { - Cookie cookie = getSessionCookie(); + ResponseCookie cookie = getSessionCookie(); assertThat(cookie).isNotNull(); - assertThat(cookie.getMaxAge()).isEqualTo(-1); + assertThat(cookie.getMaxAge().getSeconds()).isEqualTo(-1); assertThat(cookie.getValue()).isNotEqualTo("INVALID"); assertThat(cookie.isHttpOnly()).describedAs("Cookie is expected to be HTTP Only") .isTrue(); - assertThat(cookie.getSecure()) + assertThat(cookie.isSecure()) .describedAs( "Cookie secured is expected to be " + this.request.isSecure()) .isEqualTo(this.request.isSecure()); @@ -1525,15 +1526,15 @@ public class SessionRepositoryFilterTests { } private void assertNoSession() { - Cookie cookie = getSessionCookie(); + ResponseCookie cookie = getSessionCookie(); assertThat(cookie).isNull(); assertThat(this.request.getSession(false)) .describedAs("The original HttpServletRequest HttpSession should be null") .isNull(); } - private Cookie getSessionCookie() { - return this.response.getCookie("SESSION"); + private ResponseCookie getSessionCookie() { + return ResponseCookieParser.parse(this.response, "SESSION"); } private void setSessionCookie(String sessionId) { @@ -1556,6 +1557,9 @@ public class SessionRepositoryFilterTests { for (Cookie cookie : this.response.getCookies()) { nameToCookie.put(cookie.getName(), cookie); } + ResponseCookieParser.parse(this.response) + .forEach((responseCookie) -> nameToCookie.put(responseCookie.getName(), + toServletCookie(responseCookie))); Cookie[] nextRequestCookies = new ArrayList<>(nameToCookie.values()) .toArray(new Cookie[0]); @@ -1586,6 +1590,19 @@ public class SessionRepositoryFilterTests { return new String(Base64.getDecoder().decode(value)); } + private static Cookie toServletCookie(ResponseCookie responseCookie) { + Cookie cookie = new Cookie(responseCookie.getName(), responseCookie.getValue()); + String domain = responseCookie.getDomain(); + if (domain != null) { + cookie.setDomain(domain); + } + cookie.setMaxAge((int) responseCookie.getMaxAge().getSeconds()); + cookie.setPath(responseCookie.getPath()); + cookie.setSecure(responseCookie.isSecure()); + cookie.setHttpOnly(responseCookie.isHttpOnly()); + return cookie; + } + private static class SessionRepositoryFilterDefaultOrder implements Ordered { @Override