【问题标题】:How to set SameSite and Secure attribute to JSESSIONID cookie如何将 SameSite 和 Secure 属性设置为 JSESSIONID cookie
【发布时间】:2021-01-04 09:48:26
【问题描述】:

我有一个 Spring Boot Web 应用程序(Spring Boot 版本 2.0.3.RELEASE)并在 Apache Tomcat 8.5.5 服务器中运行。

根据谷歌浏览器最近实施的安全政策(自 80.0 起推出),要求应用新的SameSite 属性以使跨站点 cookie 访问以更安全的方式而不是 CSRF 方式访问。由于我没有做任何相关的事情,并且 Chrome 已为第一方 cookie 设置默认值 SameSite=Lax,因此我的第三方服务集成之一失败,原因是 chrome 在 @987654323 时限制跨站点 cookie 的访问@ 并且如果第三方响应来自 POST 请求(Onnce 过程完成第三方服务重定向到我们的站点并带有 POST 请求)。在那里,Tomcat 无法找到会话,因此它在 URL 的末尾附加了一个新的 JSESSIONID(带有一个新会话并且前一个会话被终止)。因此 Spring 拒绝该 URL,因为它包含一个由新的 JSESSIONID 附加引入的分号。

所以我需要更改JSESSIONID cookie 属性(SameSite=None; Secure) 并尝试了多种方式,包括 WebFilters。我在 Stackoverflow 中看到了相同的问题和答案,并尝试了其中的大部分,但最终无果。

有人能想出一个解决方案来更改 Spring Boot 中的这些标头吗?

【问题讨论】:

    标签: java spring spring-boot cookies samesite


    【解决方案1】:

    我之前也遇到过同样的情况。由于 javax.servlet.http.Cookie 类中没有类似 SameSite 的内容,因此无法添加。

    第 1 部分: 所以我所做的是编写了一个过滤器,它只拦截所需的第三方请求。

    public class CustomFilter implements Filter {
    
        private static final String THIRD_PARTY_URI = "/third/party/uri";
    
    
        @Override
        public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
            HttpServletRequest request = (HttpServletRequest) req;
            HttpServletResponse response = (HttpServletResponse) res;
            if(THIRD_PARTY_URI.equals(request.getRequestURI())) {
                chain.doFilter(request, new CustomHttpServletResponseWrapper(response));
            } else {
                chain.doFilter(request, response);
            }
        }
    enter code here
        // ... init destroy methods here
        
    }
    

    第 2 部分: Cookie 以 Set-Cookie 响应标头发送。所以这个CustomHttpServletResponseWrapper覆盖了addCookie方法并检查它是否是所需的cookie(JSESSIONID),而不是将其添加到cookie中,而是直接添加到响应头Set-CookieSameSite=None属性。

    public class CustomHttpServletResponseWrapper extends HttpServletResponseWrapper {
    
        public CustomHttpServletResponseWrapper(HttpServletResponse response) {
            super(response);
        }
    
        @Override
        public void addCookie(Cookie cookie) {
            if ("JSESSIONID".equals(cookie.getName())) {
                super.addHeader("Set-Cookie", getCookieValue(cookie));
            } else {
                super.addCookie(cookie);
            }
        }
    
        private String getCookieValue(Cookie cookie) {
    
            StringBuilder builder = new StringBuilder();
            builder.append(cookie.getName()).append('=').append(cookie.getValue());
            builder.append(";Path=").append(cookie.getPath());
            if (cookie.isHttpOnly()) {
                builder.append(";HttpOnly");
            }
            if (cookie.getSecure()) {
                builder.append(";Secure");
            }
            // here you can append other attributes like domain / max-age etc.
            builder.append(";SameSite=None");
            return builder.toString();
        }
    }
    

    【讨论】:

    • 感谢您的帮助@Pratapi。但这也没有解决问题。这也适用于 Localhost 吗?
    • 是的,它对我有用。请确保未在 localhost 上设置 secure 属性。您可以通过 request.getRemoteHost() (方法名称可能不同)方法添加检查以不在 localhost 上添加安全。
    • 但是我没有设置来自 SameSite 和安全的设置。
    • 我使用的是旧版本的 spring boot versoin 2.0.3 RELEASE。
    • 对不起,这已经解决了我的问题。令人惊讶的是,谷歌浏览器没有显示设置的 cookie 属性,但它通过跨站点请求传递了 cookie。以前我只检查了仅在开发工具中显示的会话 cookie。谢谢@Pratapi。无论如何,需要将过滤器标记为组件,以便将其识别为过滤器
    【解决方案2】:

    更新于 06/07/2021 - 添加了正确的 Path 属性和新的 sameSite 属性,以避免使用 GenericFilterBean 方法的会话 cookie 重复。


    我能够为此提出自己的解决方案。

    我有两种在 Spring boot 上运行的应用程序,它们具有不同的 Spring 安全配置,它们需要不同的解决方案来解决这个问题。

    案例 1:没有用户身份验证

    解决方案 1

    在这里,您可能已经在您的应用程序中为第 3 方响应创建了一个端点。在您以控制器方法访问 httpSession 之前,您是安全的。如果您在不同的控制器方法中访问会话,则向那里发送一个临时重定向请求,如下所示。

    @Controller
    public class ThirdPartyResponseController{
    
    @RequestMapping(value=3rd_party_response_URL, method=RequestMethod.POST)
    public void thirdPartyresponse(HttpServletRequest request, HttpServletResponse httpServletResponse){
        // your logic
        // and you can set any data as an session attribute which you want to access over the 2nd controller 
        request.getSession().setAttribute(<data>)
        try {
            httpServletResponse.sendRedirect(<redirect_URL>);
        } catch (IOException e) {
            // handle error
        }
    }
    
    @RequestMapping(value=redirect_URL, method=RequestMethod.GET)
    public String thirdPartyresponse(HttpServletRequest request,  HttpServletResponse httpServletResponse, Model model,  RedirectAttributes redirectAttributes, HttpSession session){
        // your logic
            return <to_view>;
        }
    }
    

    不过,您需要在安全配置中允许 3rd_party_response_url。

    解决方案 2

    您可以尝试下面描述的相同GenericFilterBean 方法。

    案例2:用户需要认证/登录

    在您已通过HttpSecurityWebSecurity 配置大部分安全规则的 Spring Web 应用程序中,检查此解决方案。

    我已测试解决方案的示例安全配置:

    @Configuration
    @EnableWebSecurity
    @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
    public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.
              ......
              ..antMatchers(<3rd_party_response_URL>).permitAll();
              .....
              ..csrf().ignoringAntMatchers(<3rd_party_response_URL>);
        }
    }
    

    我想在此配置中强调的重点是您应该允许来自 Spring Security 和 CSRF 保护的第 3 方响应 URL(如果已启用)。

    然后我们需要通过扩展GenericFilterBean 类(Filter 类对我不起作用)并通过拦截每个HttpServletRequest 并将SameSite 属性设置为JSESSIONID cookie 并设置响应头。

    import org.apache.commons.lang3.StringUtils;
    import org.springframework.http.HttpHeaders;
    import org.springframework.web.filter.GenericFilterBean;
    
    import javax.servlet.FilterChain;
    import javax.servlet.ServletException;
    import javax.servlet.ServletRequest;
    import javax.servlet.ServletResponse;
    import javax.servlet.http.Cookie;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;
    import java.util.Arrays;
    import java.util.List;
    
    public class SessionCookieFilter extends GenericFilterBean {
    
        private final List<String> PATHS_TO_IGNORE_SETTING_SAMESITE = Arrays.asList("resources", <add other paths you want to exclude>);
        private final String SESSION_COOKIE_NAME = "JSESSIONID";
        private final String SESSION_PATH_ATTRIBUTE = ";Path=";
        private final String ROOT_CONTEXT = "/";
        private final String SAME_SITE_ATTRIBUTE_VALUES = ";HttpOnly;Secure;SameSite=None";
    
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
            HttpServletRequest req = (HttpServletRequest) request;
            HttpServletResponse resp = (HttpServletResponse) response;
            String requestUrl = req.getRequestURL().toString();
            boolean isResourceRequest = requestUrl != null ? StringUtils.isNoneBlank(PATHS_TO_IGNORE_SETTING_SAMESITE.stream().filter(s -> requestUrl.contains(s)).findFirst().orElse(null)) : null;
            if (!isResourceRequest) {
                Cookie[] cookies = ((HttpServletRequest) request).getCookies();
                if (cookies != null && cookies.length > 0) {
                    List<Cookie> cookieList = Arrays.asList(cookies);
                    Cookie sessionCookie = cookieList.stream().filter(cookie -> SESSION_COOKIE_NAME.equals(cookie.getName())).findFirst().orElse(null);
                    if (sessionCookie != null) {
                        String contextPath = request.getServletContext() != null && StringUtils.isNotBlank(request.getServletContext().getContextPath()) ? request.getServletContext().getContextPath() : ROOT_CONTEXT;
                        resp.setHeader(HttpHeaders.SET_COOKIE, sessionCookie.getName() + "=" + sessionCookie.getValue() + SESSION_PATH_ATTRIBUTE + contextPath + SAME_SITE_ATTRIBUTE_VALUES);
                    }
                }
            }
            chain.doFilter(request, response);
        }
    }
    

    然后通过

    将此过滤器添加到Spring Security过滤器链中
    @Override
    protected void configure(HttpSecurity http) throws Exception {
            http.
               ....
               .addFilterAfter(new SessionCookieFilter(), BasicAuthenticationFilter.class);
    }
    

    为了确定需要在 Spring 的安全过滤器链中放置新过滤器的位置,您可以轻松地debug Spring 安全过滤器链并确定过滤器链中的适当位置。除了BasicAuthenticationFilter之外,SecurityContextPersistanceFilter之后将是另一个理想的地方。

    SameSite cookie 属性将不支持某些old browser versions,在这种情况下,请检查浏览器并避免在不兼容的客户端中设置SameSite

    private static final String _I_PHONE_IOS_12 = "iPhone OS 12_";
        private static final String _I_PAD_IOS_12 = "iPad; CPU OS 12_";
        private static final String _MAC_OS_10_14 = " OS X 10_14_";
        private static final String _VERSION = "Version/";
        private static final String _SAFARI = "Safari";
        private static final String _EMBED_SAFARI = "(KHTML, like Gecko)";
        private static final String _CHROME = "Chrome/";
        private static final String _CHROMIUM = "Chromium/";
        private static final String _UC_BROWSER = "UCBrowser/";
        private static final String _ANDROID = "Android";
    
        /*
         * checks SameSite=None;Secure incompatible Browsers
         * https://www.chromium.org/updates/same-site/incompatible-clients
         */
        public static boolean isSameSiteInCompatibleClient(HttpServletRequest request) {
            String userAgent = request.getHeader("user-agent");
            if (StringUtils.isNotBlank(userAgent)) {
                boolean isIos12 = isIos12(userAgent), isMacOs1014 = isMacOs1014(userAgent), isChromeChromium51To66 = isChromeChromium51To66(userAgent), isUcBrowser = isUcBrowser(userAgent);
                //TODO : Added for testing purpose. remove before Prod release.
                LOG.info("*********************************************************************************");
                LOG.info("is iOS 12 = {}, is MacOs 10.14 = {}, is Chrome 51-66 = {}, is Android UC Browser = {}", isIos12, isMacOs1014, isChromeChromium51To66, isUcBrowser);
                LOG.info("*********************************************************************************");
                return isIos12 || isMacOs1014 || isChromeChromium51To66 || isUcBrowser;
            }
            return false;
        }
    
        private static boolean isIos12(String userAgent) {
            return StringUtils.contains(userAgent, _I_PHONE_IOS_12) || StringUtils.contains(userAgent, _I_PAD_IOS_12);
        }
    
        private static boolean isMacOs1014(String userAgent) {
            return StringUtils.contains(userAgent, _MAC_OS_10_14)
                && ((StringUtils.contains(userAgent, _VERSION) && StringUtils.contains(userAgent, _SAFARI))  //Safari on MacOS 10.14
                || StringUtils.contains(userAgent, _EMBED_SAFARI)); // Embedded browser on MacOS 10.14
        }
    
        private static boolean isChromeChromium51To66(String userAgent) {
            boolean isChrome = StringUtils.contains(userAgent, _CHROME), isChromium = StringUtils.contains(userAgent, _CHROMIUM);
            if (isChrome || isChromium) {
                int version = isChrome ? Integer.valueOf(StringUtils.substringAfter(userAgent, _CHROME).substring(0, 2))
                    : Integer.valueOf(StringUtils.substringAfter(userAgent, _CHROMIUM).substring(0, 2));
                return ((version >= 51) && (version <= 66));    //Chrome or Chromium V51-66
            }
            return false;
        }
    
        private static boolean isUcBrowser(String userAgent) {
            if (StringUtils.contains(userAgent, _UC_BROWSER) && StringUtils.contains(userAgent, _ANDROID)) {
                String[] version = StringUtils.splitByWholeSeparator(StringUtils.substringAfter(userAgent, _UC_BROWSER).substring(0, 7), ".");
                int major = Integer.valueOf(version[0]), minor = Integer.valueOf(version[1]), build = Integer.valueOf(version[2]);
                return ((major != 0) && ((major < 12) || (major == 12 && (minor < 13)) || (major == 12 && minor == 13 && (build < 2)))); //UC browser below v12.13.2 in android
            }
            return false;
        }
    

    在 SessionCookieFilter 中添加上述检查,如下所示,

    if (!isResourceRequest && !UserAgentUtils.isSameSiteInCompatibleClient(req)) {
    

    此过滤器在 localhost 环境中不起作用,因为它需要安全 (HTTPS) 连接来设置 Secure cookie 属性。

    有关详细说明,请阅读此blog post

    【讨论】:

      【解决方案3】:

      正如这个答案中提到的: Same-Site flag for session cookie in Spring Security

      @Configuration
      public static class WebConfig implements WebMvcConfigurer {
          @Bean
          public TomcatContextCustomizer sameSiteCookiesConfig() {
              return context -> {
                  final Rfc6265CookieProcessor cookieProcessor = new Rfc6265CookieProcessor();
                  cookieProcessor.setSameSiteCookies(SameSiteCookies.NONE.getValue());
                  context.setCookieProcessor(cookieProcessor);
              };
          }
      }
      

      但这似乎更简单

      @Configuration
      public static class WebConfig implements WebMvcConfigurer {
          @Bean
          public CookieSameSiteSupplier cookieSameSiteSupplier(){
              return CookieSameSiteSupplier.ofNone();
          }
      }
      

      或者...更简单,spring boot 从 2.6.0 开始支持在 application.properties 中设置。

      Spring documentation about SameSite Cookies

      server.servlet.session.cookie.same-site = none
      

      【讨论】:

      • 如您所说,上述解决方案适用于最新版本的 Spring boot(假设它在 2.6 之后的某个位置 - github.com/spring-projects/spring-framework/issues/27596)和最新的 Tomcat 版本。如果有人像我(v 2.0.3)一样需要通过保持相同的 Spring Boot 版本(因为我们知道在需要更新特定的 Spring Boot 版本时需要经历的困难)来解决问题,解决方案最佳答案中提到的可能会有所帮助。但是如果你使用的是 Spring Boot 2.6/7 或更高版本,那么上面的答案更容易实现。
      猜你喜欢
      • 2020-09-24
      • 2020-02-09
      • 2020-03-30
      • 1970-01-01
      • 2020-11-23
      • 2019-05-14
      • 2021-07-22
      • 1970-01-01
      • 2020-07-04
      相关资源
      最近更新 更多