36.1 引言

有人在接触了 Spring Security 后会觉得它实在麻烦,不如自己写一个 Filter 拦截请求来的简单。确实,自己写也可以实现一些基本的验证功能。但是大部分情况下,非专业的 Web 安全工程师考虑问题通常不过就是认证和授权,以为这两个问题处理好了,似乎系统就很安全了。但事实不是这样

各种各样的 Web 攻击每天都在发生,固定会话攻击、CSRF 攻击等等,如果不了解这些攻击,那么做出来的系统肯定也不能防御这些攻击。

使用 Spring Security 的好处就是,即使不了解这些攻击,也不用担心这些攻击,因为 Spring Security 已经帮你做好防御工作。

我们常说相比于 Shiro,Spring Security 更加重量级。但重量级有重量级的好处,比如功能更全、安全管理更加完备。用了 Spring Security,你都不知道自己的系统有多安全

36.2 Spring Security 的防火墙机制

36.2.1 Spring Security 防火墙概述

在 Spring Security 中提供了一个 HttpFirewall,看名字就知道这是一个请求防火墙,它可以自动处理掉一些非法请求。HttpFirewall 目前一共有两个实现类:一个是严格模式的防火墙(Strict HTTP Firewall)设置,还有一个默认防火墙(Default HTTP Firewall)设置。

image.png
DefaultHttpFirewall 的限制相对于 StrictHttpFirewall 要宽松一些,当然也意味着安全性弱一些 。Spring Security 中默认使用的是 StrictHttpFirewall

36.2.2 Spring Security 防火墙防护措施

1. 只允许白名单中的方法

首先,对于请求的方法,只允许白名单中的方法,也就是说,不是所有的 HTTP 请求方法都可以执行。

你的 HTTP 请求方法必须是 DELETE、GET、HEAD、OPTIONS、PATCH、POST 以及 PUT 中的一个,请求才能发送成功,否则的话,就会抛出 RequestRejectedException 异常。

如果你想发送其他 HTTP 请求方法,例如 TRACE ,需要自己重新提供一个 StrictHttpFirewall 实例,如下:

  1. @Bean
  2. HttpFirewall httpFirewall() {
  3. StrictHttpFirewall firewall = new StrictHttpFirewall();
  4. firewall.setUnsafeAllowAnyHttpMethod(true);
  5. return firewall;
  6. }

其中,setUnsafeAllowAnyHttpMethod 方法表示不做 HTTP 请求方法校验,也就是什么方法都可以过。或者也可以通过 setAllowedHttpMethods 方法来重新定义可以通过的方法。

2. 请求地址不能有分号

根据默认的设置,使用了 Spring Security后,请求地址是不能有 ; 的,如果请求地址有 ; 则会得到500的错误,并且在后端可以看到如下的输出:

  1. Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception
  2. org.springframework.security.web.firewall.RequestRejectedException: The request was rejected because the URL contained a potentially malicious String ";"

这里的提示中已经说了,因为你的请求地址中包含 ;,所以请求失败。

注意,在 URL 地址中,; 编码之后是 %3b 或者 %3B,所以地址中同样不能出现 %3b 或者 %3B

Spring3.2 开始带来了一种全新的传参方式 @MatrixVariable,拓展了请求参数的传递格式,使得参数之间可以用 ; 隔开。因为 Spring Security 默认禁止这种传参方式,所以一般情况下,如果你需要使用 @MatrixVariable 来标记参数,就得在 Spring Security 中额外放行。

  1. @Configuration
  2. public class WebMvcConfig extends WebMvcConfigurationSupport {
  3. @Override
  4. protected void configurePathMatch(PathMatchConfigurer configurer) {
  5. UrlPathHelper urlPathHelper = new UrlPathHelper();
  6. urlPathHelper.setRemoveSemicolonContent(false);
  7. configurer.setUrlPathHelper(urlPathHelper);
  8. }
  9. }

3. 必须是标准化 URL

Spring Security 防火墙要求请求地址必须是标准化 URL。标准化 URL 主要从四个方面来判断:

  • getRequestURI 获取请求协议之外的字符
  • getContextPath 获取上下文路径,相当于是 project 的名字
  • getServletPath 请求的 servlet 路径
  • getPathInfo 除过 contextPath 和 servletPath 之后剩余的部分。

在这四种路径中,都不能包含如下字符串:”./“, “/../“ or “/.”

4. 必须是可打印的 ASCII 字符

如果请求地址中包含不可打印的 ASCII 字符,请求则会被拒绝

5. 不允许有双斜杠

如果请求地址中出现双斜杠,这个请求也将被拒绝。双斜杠 // 使用 URL 地址编码之后,是 %2F%2F,其中 F 大小写无所谓,所以请求地址中也能不出现 “%2f%2f”, “%2f%2F”, “%2F%2f”, “%2F%2F”。

6. 不允许有 %

如果请求地址中出现 %,这个请求也将被拒绝。URL 编码后的 % 是 %25,所以 %25 也不能出现在 URL 地址中。

7. 不允许有正反斜杠

如果请求地址中包含斜杠编码后的字符 %2F 或者 %2f ,则请求将被拒绝。如果请求地址中包含反斜杠 \ 或者反斜杠编码后的字符 %5C 或者 %5c ,则请求将被拒绝。

8. 不允许有点 .

如果请求地址中存在 . 或者编码之后的字符 %2e、%2E,则请求将被拒绝。如希望获得支持,可按照如下方式进行配置:

  1. @Bean
  2. HttpFirewall httpFirewall() {
  3. StrictHttpFirewall firewall = new StrictHttpFirewall();
  4. firewall.setAllowUrlEncodedPeriod(true);
  5. return firewall;
  6. }

36.2.3 小结

需要强调一点,上面所说的这些限制,都是针对请求的 requestURI 进行的限制,而不是针对请求参数。例如你的请求格式是:http://localhost:8080/hello?param=aa%2ebb,那么它并不会触发禁止规则,能够被正常放行。

此外,虽然我们可以手动修改 Spring Security 中的这些限制,但是不建议你做任何修改,每一条限制都有它的原由,每放开一个限制,都会带来未知的安全风险。

36.3 防御会话固定攻击

36.3.1 关于 HttpSession

也许你对 HttpSession 还不太熟悉,所以在讲会话固定攻击之前,我们先讨论一下关于 HttpSession 的知识点。

HttpSession 是一个服务端的概念,服务端生成的 HttpSession 都会有一个对应的 sessionid,这个 sessionid 会通过 cookie 传递给前端,前端以后发送请求的时候,就带上这个 sessionid 参数,服务端看到这个 sessionid 就会把这个前端请求和服务端的某一个 HttpSession 对应起来,形成“会话”的感觉。

关闭浏览器并不会导致服务端的 HttpSession 失效,实现这一目标,要么手动调用 HttpSession.invalidate 方法、要么等到 session 自动过期、要么重启服务端。但为什么有的人会感觉浏览器关闭之后 session 就失效了呢?这是因为浏览器关闭之后,保存在浏览器里边的 sessionid 就丢了(默认情况下),所以当浏览器再次访问服务端的时候,服务端会给浏览器重新分配一个 sessionid ,这个 sessionid 和之前的 HttpSession 对应不上,所以用户就会感觉 session 失效。

这里说的是“默认情况”,我们可以通过手动配置让浏览器重启之后 sessionid 不丢失,但是这样会带来安全隐患,所以一般希望你不要这么做

以 Spring Boot 为例,服务端生成 sessionid 之后,返回给前端的响应头是这样的:
image.png
在服务端的响应头中有一个 Set-Cookie 字段,该字段指示浏览器更新 sessionid,同时大家注意还有一个 HttpOnly 属性,这个表示通过 JS 脚本无法读取到 Cookie 信息,这样能有效的防止 XSS 攻击。
下一次浏览器再去发送请求的时候,就会自觉的携带上这个 jsessionid 了:
image.png

36.3.2 会话固定攻击的过程

会话固定攻击的英语是 Session Fixation Attack。通常的情况只要不关闭浏览器,并且服务端的 HttpSession 也没有过期,那么维系服务端和浏览器的 sessionid 是不会发生变化的,而会话固定攻击,则是利用这一机制,借助受害者用相同的会话 ID 获取认证和授权,然后利用该会话 ID 劫持受害者的会话以冒充受害者,造成会话固定攻击。

以淘宝为例,会话固定攻击的通常流程是这样:

  • 攻击者自己可以正常访问淘宝网站,在访问的过程中,淘宝网站给攻击者分配了一个 sessionid。
  • 攻击者利用自己拿到的 sessionid 构造一个淘宝网站的链接,并把该链接发送给受害者。
  • 受害者使用该链接登录淘宝网站(该链接中含有 sessionid),登录成功后,一个合法的会话就成功建立。
  • 攻击者利用手里的 sessionid 冒充受害者。

在这个过程中,如果淘宝网站支持 URL 重写,那么攻击还会变得更加容易。

如果在浏览器中禁用了 cookie,那么 sessionid 自然也用不了了,所以有的服务端就支持把 sessionid 放在请求地址中,这种操作就是 URL 重写。如果服务端支持 URL 重写,那么对于攻击者来说,按照上面的攻击流程,构造一个这种地址就更加简单。

36.3.3 如何防御会话固定攻击

会话攻击所用机制的根源在于 sessionid 不变,如果用户在未登录时拿到的是一个 sessionid,登录之后服务端给用户重新换一个 sessionid 便就可以防止会话固定攻击。

当然,你使用 Spring Security 时并不用担心这个问题,因为 Spring Security 中默认已经做了防御工作了。主要体现在三个方面:

  1. 首先是通过 StrictHttpFirewall 禁止申请有 ; 的请求地址(直接拒绝)。
  2. 然后是响应的 Set-Cookie 字段中有 HttpOnly 属性,这种方式避免了通过 XSS 攻击来获取 Cookie 中的会话信息进而达成会话固定攻击。
  3. 第三是让 sessionid 变一下。既然问题是由于 sessionid 不变导致的,那就改变它。具体配置如下:

image.png
可以看到,在这里有四个选项:

  • migrateSession 表示在登录成功之后,创建一个新的会话,然后将旧 session 中的信息复制到新的 session 中。这是默认设置。。
  • none 表示不做任何事情,继续使用旧的 session。
  • changeSessionId 表示 session 不变,但是会修改 sessionid,这实际上用到了 Servlet 容器提供的防御会话固定攻击。
  • newSession 表示登录后创建一个新的 session。

Spring Security 默认选择的方式是 migrateSession——在用户匿名访问的时候是一个 sessionid,当用户成功登录之后,又是另外一个 sessionid,这样就可以有效避免会话固定攻击。

36.4 防御 CSRF 攻击

CSRF 就是跨域请求伪造,英文全称是 Cross Site Request Forgery。这是一种非常常见的 Web 攻击方式,尽管很好防御,但是经常被很多开发者忽略,进而导致很多网站实际上都存在 CSRF 攻击的安全隐患。

36.4.1 CSRF原理

想要防御 CSRF 攻击,得先搞清楚什么是 CSRF 攻击。下图是 CSRF 攻击流程:
image.png
这个流程很简单:

  • 用户打开了银行网上银行网站,并且登录。
  • 登录成功后,网上银行返回 Cookie 给前端,浏览器保存 Cookie。
  • 用户在没有登出网上银行的情况下,在浏览器里边打开了一个新的选项卡访问了一个危险网站。
  • 这个危险网站上有一个超链接,超链接的地址指向了银行网上银行。
  • 用户点击了这个超链接,由于这个超链接会自动携带上浏览器中保存的 Cookie,所以用户不知不觉中就访问了网上银行,进而可能给自己造成了损失。

Spring Security 中防御 CSRF 整体来说就是两个思路:

  • 生成 csrfToken 保存在 HttpSession 或者 Cookie 中。
  • 请求到来时,从请求中提取出来 csrfToken,和保存的 csrfToken 做比较,进而判断出当前请求是否合法。

    36.4.2 模拟CSRF攻击过程

    现在我们创建一个使用 Spring Security 的 Spring Boot Web 项目1,这个项目相当于上面所说的网上银行网站。它工作在 8080 接口。

创建成功后,为简化过程直接将 Spring Security 用户名/密码 配置在 application.properties 文件中:

  1. spring.security.user.name=user
  2. spring.security.user.password=123

然后我们提供两个测试接口:在其中设计两个测试接口:

  1. @RestController
  2. public class HelloController {
  3. @PostMapping("/transfer")
  4. public void transferMoney(String name, Integer money) {
  5. System.out.println("name = " + name);
  6. System.out.println("money = " + money);
  7. }
  8. @GetMapping("/hello")
  9. public String hello() {
  10. return "hello";
  11. }
  12. }

假设 /transfer 是一个转账接口(这里是假设,主要是给大家演示 CSRF 攻击,真实的转账接口比这复杂)。

因为 Spring Security 中默认是可以自动防御 CSRF 攻击的,现在我们修改配置要把这个关闭:

  1. @Configuration
  2. public class SecurityConfig extends WebSecurityConfigurerAdapter {
  3. @Override
  4. protected void configure(HttpSecurity http) throws Exception {
  5. http.authorizeRequests().anyRequest().authenticated()
  6. .and()
  7. .formLogin()
  8. .and()
  9. .csrf()
  10. .disable();
  11. }
  12. }

接下来,我们再创建另外一个项目2,它相当于是一个危险网站,工作在8081。在这个项目的 resources/static 目录下创建一个 hello.html ,内容如下:

  1. <body>
  2. <form action="http://localhost:8080/transfer" method="post">
  3. <input type="hidden" value="user" name="name">
  4. <input type="hidden" value="10000" name="money">
  5. <input type="submit" value="点击查看美女图片">
  6. </form>
  7. </body>

这里有一个文本是点击查看美女图片的超链接,但点击了之后,会自动请求 http://localhost:8080/transfer 接口,同时隐藏域还携带了两个参数。

用户首先访问 项目1 中的接口,在访问的时候需要登录,用户就执行了登录操作,访问完整后,用户并没有执行登出操作,然后用户访问 项目2 中的页面,看到了超链接,好奇这美女到底长啥样,一点击,结果钱就被人转走了。

36.4.3 防御CSRF的思路

CSRF 防御的核心思路就是在前端请求中添加一个随机数。

因为在 CSRF 攻击中,黑客网站并不知道用户的 Cookie 具体是什么的,他是让用户自己发送请求到网上银行这个网站的,因为这个过程会自动携带上 Cookie 中的信息。所以通常防御思路是这样:用户在访问网上银行时,除了携带 Cookie 中的信息之外,还需要携带一个随机数,如果用户没有携带这个随机数,则网上银行网站会拒绝该请求。黑客网站诱导用户点击超链接时,会自动携带上 Cookie 中的信息,但是却不会自动携带随机数,这样就能够 防御 CSRF 攻击。

36.4.4 防御 CSRF的默认方案

Spring Security 中对防御 CSRF 攻击提供了很好的支持,但需要开发者做的事情比较多。

首先我们来创建一个新的 Spring Boot 工程,创建时引入 Spring Security、Thymeleaf 和 web 依赖。项目创建成功后,还是在 application.properties 中配置用户名/密码:

  1. spring.security.user.name=user
  2. spring.security.user.password=123

接下来,我们提供一个测试接口:

  1. @Controller
  2. public class HelloController {
  3. @PostMapping("/hello")
  4. @ResponseBody
  5. public String hello() {
  6. return "hello";
  7. }
  8. }

注意,这个测试接口是一个 POST 请求,因为默认情况下,GET、HEAD、TRACE 以及 OPTIONS 是不需要验证 CSRF 攻击的。

然后,在 resources/templates 目录下,新建一个 thymeleaf 模版,如下:

  1. <body>
  2. <form action="/hello" method="post">
  3. <input type="hidden" th:value="${_csrf.token}" th:name="${_csrf.parameterName}">
  4. <input type="submit" value="hello">
  5. </form>
  6. </body>

注意,在发送 POST 请求的时候,还额外携带了一个隐藏域,隐藏域的 key 是 ${_csrf.parameterName},value 则是 ${_csrf.token}。这两个值服务端会自动带过来,我们只需要在前端渲染出来即可。

接下来给前端 hello.html 页面添加一个控制器,如下:

  1. @GetMapping("/hello")
  2. public String hello2() {
  3. return "hello";
  4. }

添加完成后,启动项目,访问 hello 页面,在访问时候,需要先登录,登录成功之后,我们可以看到登录请求中也多了一个参数,如下:
image.png
可以看到,这里也多了 _csrf 参数。

这里我们用了 Spring Security 的默认登录页面,如果大家使用自定义登录页面,可以参考上面 hello.html 的写法,通过一个隐藏域传递 _csrf 参数。

访问到 hello 页面之后,再去点击按钮,就可以访问到 hello 接口了。

你可以自行尝试在 hello.html 页面中去掉 _csrf 参数,看看访问 hello 接口的效果。

这是 Spring Security 中默认的方案,通过 Model 将相关的数据带到前端来。如果你的项目是前后端不分离的,这种方案就可以了,如果你的项目是前后端分离项目,这种方案很明显不够用。

36.4.5 前后端分离项目的防御方案

如果是前后端分离项目,Spring Security 也提供了解决方案。此时不是将 _csrf 放在 Model 中返回前端,而是放在 Cookie 中返回前端,配置方式如下:

  1. @Configuration
  2. public class SecurityConfig extends WebSecurityConfigurerAdapter {
  3. @Override
  4. protected void configure(HttpSecurity http) throws Exception {
  5. http.authorizeRequests().anyRequest().authenticated()
  6. .and()
  7. .formLogin()
  8. .and()
  9. .csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
  10. }
  11. }

你可能会有疑惑:放在 Cookie 中不是又被黑客网站盗用了吗?其实并不会。请注意如下两个问题:

  1. 黑客网站根本不知道 Cookie 里边存的是什么,当然也不需要知道,因为 CSRF 攻击是浏览器自动携带上 Cookie 中的数据的。
  2. 服务端生成的随机数放在 Cookie 中,前端需要从 Cookie 中自己提取出来 _csrf参数,然后拼接成参数传递给后端,单纯的将 Cookie 中的数据传到服务端是没用的。

理解了上面两点,你会明白 _csrf 放在 Cookie 中是没有问题的。但要注意,配置中的 withHttpOnlyFalse 方法获取了 CookieCsrfTokenRepository 的实例,该方法会设置 Cookie 中的 HttpOnly 属性为 false,也就是允许前端通过 js 操作 Cookie(否则你就没有办法获取到 _csrf)。配置完成后,重启项目,此时我们就发现返回的 Cookie 中多了一项:
image.png
接下来,我们通过自定义登录页面,来看看前端要如何操作。

首先在 resources/static 目录下新建一个 html 页面叫做 login.html:

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>Title</title>
  6. <script src="js/jquery.min.js"></script>
  7. <script src="js/jquery.cookie.js"></script>
  8. </head>
  9. <body>
  10. <div>
  11. <input type="text" id="username">
  12. <input type="password" id="password">
  13. <input type="button" value="登录" id="loginBtn">
  14. </div>
  15. <script>
  16. $("#loginBtn").click(function () {
  17. let _csrf = $.cookie('XSRF-TOKEN');
  18. $.post('/login.html', {
  19. username:$("#username").val(),
  20. password:$("#password").val(),
  21. _csrf:_csrf
  22. }, function (data) {
  23. alert(data);
  24. })
  25. })
  26. </script>
  27. </body>
  28. </html>

这段 HTML 解释如下:

  • 首先引入 JQuery 和 JQuery.Cookie,方便操作 Cookie。
  • 定义三个 input,前两个是用户名和密码,第三个是登录按钮。
  • 点击登录按钮之后,先从 Cookie 中提取出 XSRF-TOKEN(这就是要上传的 csrf 参数)。
  • 通过一个 POST 请求执行登录操作,注意携带上 _csrf 参数。

服务端我们也稍作修改,如下:

  1. @Configuration
  2. public class SecurityConfig extends WebSecurityConfigurerAdapter {
  3. @Override
  4. public void configure(WebSecurity web) throws Exception {
  5. web.ignoring().antMatchers("/js/**");
  6. }
  7. @Override
  8. protected void configure(HttpSecurity http) throws Exception {
  9. http.authorizeRequests().anyRequest().authenticated()
  10. .and()
  11. .formLogin()
  12. .loginPage("/login.html")
  13. .successHandler((req,resp,authentication)->{
  14. resp.getWriter().write("success");
  15. })
  16. .permitAll()
  17. .and()
  18. .csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
  19. }
  20. }

在这个配置里面,一方面这里给 JS 文件放行,另一方面配置登录页面以及登录成功的回调。所有事情做完之后访问 login.html 页面,输入用户名密码进行登录,可以看到,我们的 _csrf 配置已经生效了。你可以自行尝试从登录参数中去掉 _csrf,然后再看看效果。

36.4.6 小结

CSRF 攻击主要是借助了浏览器默认发送 Cookie 的这一机制,所以如果你的前端是 App、小程序之类的应用,不涉及浏览器应用,其实可以忽略这个问题,如果你的前端包含浏览器应用的话,这个问题就要认真考虑。

另外,显然在每次前端请求都显式地附加 _csrf 参数是一个比较比较繁琐且容易遗漏的工作。比较好的解决方案是在全端开发项目中做好统一的全局自动化处理。

36.5 可开放访问资源的放行策略

36.5.1 两种不同的资源放行策略

在 Spring Security 中,如果你希望用户不用登录就能访问某些资源,那么一般来说有两种配置策略:

  • 第一种是在 configure(WebSecurity web) 方法中配置放行,像下面这样:

    1. @Override
    2. public void configure(WebSecurity web) throws Exception {
    3. web.ignoring().antMatchers(
    4. "/css/**",
    5. "/js/**",
    6. "/index.html",
    7. "/favicon.ico"
    8. );
    9. }
  • 第二种方式是在 configure(HttpSecurity http) 方法中进行配置:

    1. @Override
    2. public void configure(HttpSecurity http)
    3. http.authorizeRequests()
    4. .antMatchers("/hello").permitAll()
    5. .anyRequest().authenticated();
    6. }

    两种方式最大的区别在于访问请求的处理路径。第一种方式是访问请求不经过 Spring Security 过滤器链,而第二种方式要经过 Spring Security 过滤器链,在过滤器链中给请求放行。

在使用 Spring Security 的时候,类似静态页面、图片、CSS文件等静态资源可以使用第一种方式额外放行,不需要验证。而类似登录接口这样虽然也是必须要暴露出来、不需登录就能访问,但不能将他们用第一种方式直接暴露方向,访问他们的请求必须要经过 Spring Security 过滤器链,因为在这个过程中,还有其他事情要做。

36.5.2 登录请求过程分析

使用 Spring Security 时,用户登录成功之后有两种方式获取用户登录信息:

  • SecurityContextHolder.getContext().getAuthentication()
  • 在 Controller 的方法中,加入 Authentication 参数

这两种办法都可以获取到当前登录用户信息。他们获取到的数据都来自 SecurityContextHolder中的数据,本质上是保存在 ThreadLocal 中。ThreadLocal 的特点是存在它里边的数据只能在当前线程中访问。这样就带来一个问题:用户登录成功之后将用户用户数据存在 SecurityContextHolder 中,而下一个请求会处于另外的线程,想从 SecurityContextHolder 中获取用户登录信息却获取不到,因为它们不是同一个Thread。

我们使用 Spring Security 登录成功后每次都能够获取到登录用户信息的原因是 Spring Security 中的的过滤器 SecurityContextPersistenceFilter 起到了作用。这个过滤器位于 UsernamePasswordAuthenticationFilter 之前,请求在到达 UsernamePasswordAuthenticationFilter 之前都会先经过它。

下面是它的部分源码:

  1. public class SecurityContextPersistenceFilter extends GenericFilterBean {
  2. public void doFilter(ServletRequest req,
  3. ServletResponse res,
  4. FilterChain chain) throws IOException, ServletException {
  5. HttpServletRequest request = (HttpServletRequest) req;
  6. HttpServletResponse response = (HttpServletResponse) res;
  7. HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
  8. SecurityContext contextBeforeChainExecution = repo.loadContext(holder);
  9. try {
  10. SecurityContextHolder.setContext(contextBeforeChainExecution);
  11. chain.doFilter(holder.getRequest(), holder.getResponse());
  12. } finally {
  13. SecurityContext contextAfterChainExecution =
  14. SecurityContextHolder.getContext();
  15. SecurityContextHolder.clearContext();
  16. repo.saveContext(contextAfterChainExecution, holder.getRequest(),
  17. holder.getResponse());
  18. }
  19. }
  20. }

原本的方法很长,这里列出来了比较关键的几个部分:

  • SecurityContextPersistenceFilter 继承自 GenericFilterBean,而 GenericFilterBean 则是 Filter 的实现,所以 SecurityContextPersistenceFilter 作为一个过滤器,它里边最重要的方法就是 doFilter 。

  • doFilter 方法首先会从 repo(HttpSessionSecurityContextRepository) 中读取一个 SecurityContext 出来,读取 SecurityContext 的操作会进入到 readSecurityContextFromSession 方法中,读取的核心方法是 Object contextFromSession = httpSession.getAttribute(springSecurityContextKey);
    这里的 springSecurityContextKey 对象的值就是 SPRING_SECURITY_CONTEXT,读取出来的对象最终会被转为一个 SecurityContext 对象。

  • SecurityContext 是一个接口,它有一个唯一的实现类 SecurityContextImpl,这个实现类其实就是用户信息在 session 中保存的 value。

  • 在拿到 SecurityContext 之后,通过 SecurityContextHolder.setContext 方法将这个 SecurityContext 设置到 ThreadLocal 中去,这样,在当前请求中,Spring Security 的后续操作,我们都可以直接从 SecurityContextHolder 中获取到用户信息了。

  • 通过 chain.doFilter 让请求继续向下走,会进入到 UsernamePasswordAuthenticationFilter 过滤器中。

  • 在过滤器链走完之后以及数据响应给前端之后,finally 中还有一步收尾操作,这一步很关键。这里从 SecurityContextHolder 中获取到 SecurityContext,获取到之后,会把 SecurityContextHolder 清空,然后调用 repo.saveContext 方法将获取到的 SecurityContext 存入 session 中。

至此,整个流程就很明了了:
每个请求到达时,首先从 session 中找出来 SecurityContext ,然后设置到 SecurityContextHolder 中去方便后续使用。当这个请求离开的时候,SecurityContextHolder 会被清空,SecurityContext 被放回 session 中,方便下一个请求来的时候获取。

登录请求来的时候还没有登录用户数据,但是登录请求走的时候,会将用户登录数据存入 session 中,下个请求到来的时候,就可以直接取出来用了。

看了上面的分析,我们可以至少得出两点结论:

  • 如果我们暴露登录接口的时候,使用了前面提到的第一种方式,没有经过 Spring Security过滤器链,则在登录成功后不会将登录用户信息存入 session 中,进而导致后来的请求都无法获取到登录用户信息(后来的请求在系统眼里也都是未认证的请求)

  • 如果你的登录请求正常,经过了 Spring Security 过滤器链,但是后来的请求没经过滤器链(即用第一种方式放行),那么在该请求中也无法通过 SecurityContextHolder 获取到登录用户信息。

总之,前端静态资源放行时可以直接不走 Spring Security 过滤器链,但放行后端接口时需要仔细考虑场景,一般来说不建议使用不经过过滤链的方式。

版权说明:本文由北京朗思云网科技股份有限公司原创,向互联网开放全部内容但保留所有权力。