1、问题复现
如果使用Spring Security,当我们登录之后,可以通过如下方式获取登录用户信息:
SecurityContextHolder.getContext().getAuthentication()- 在 controller 方法中加入
Authentication参数
正常情况下,我们通过如上两种方式中的任意一种都可以获取到已经登录的用户信息;然而,异常情况下,就是两种方式获取的登录用户信息都为null。这就意味着系统收到当前请求并不知道你已经登录了(因为你没有在系统中留下任何有效信息),这就会带来两个问题:
- 无法获取到当前登录用户信息
- 当你发送任何请求,系统都会给你返回401
2、顺藤摸瓜
要弄明白这个问题,我们就得明白Spring Security 中的用户信息到底保存在哪?前面说了两种获取登录用户信息的方式,但是获取到的数据又是从哪里来的呢?SecurityContextHolder中的数据,本质上是保存在ThreadLocal中,ThreadLocal的特点是存在它里面的数据,哪个线程存的,哪个线程才能访问到。
这样就带来一个问题,当不同的请求进入到服务端之后,由不同的thread去处理,按理说后面的请求就可能无法获取到登录请求的线程存入的数据,例如登录请求在线程A中将登录信息存入到ThreadLocal,后面的请求来了,在线程B中处理,那此时就无法获取到用户的登录信息。
但实际上,正常情况下,我们每次都能获取到登录用户信息,这又是怎么回事呢?
这个时候就要引入SpringSecurity中的SecurityContextPersistenceFilter过滤器,SpringSecurity中的一系列功能其实都是由过滤器来完成的。在前面讲的UsernamePasswordAuthenticationFilter过滤器前面还有一个SecurityContextPersistenceFilter过滤器,请求在到达UsernamePasswordAuthenticationFilter过滤器之前都会先经过SecurityContextPersistenceFilter过滤器。
接下来看下它的源码(部分):publicclass SecurityContextPersistenceFilter extends GenericFilterBean {public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)throws IOException, ServletException {HttpServletRequest request = (HttpServletRequest) req;HttpServletResponse response = (HttpServletResponse) res;HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request,response);SecurityContext contextBeforeChainExecution = repo.loadContext(holder);try {SecurityContextHolder.setContext(contextBeforeChainExecution);chain.doFilter(holder.getRequest(), holder.getResponse());}finally {SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();SecurityContextHolder.clearContext();repo.saveContext(contextAfterChainExecution, holder.getRequest(),holder.getResponse());}}}
SecurityContextPersistenceFilter继承自GenericFilterBean,而GenericFilterBean则是filter的实现,所以SecurityContextPersistenceFilter作为一个过滤器,它里边最重要得到方法就是doFilter。- 在
doFilter方法中,它首先会从repo中读取一个SecurityContext出来,这里的repo实际上就是HttpSessionSecurityContextRepository,读取SecurityContext的操作会进入到readSecurityContextFromSession方法中,在这里我们看到了读取的核心方法Object contextFromSession = httpSession.getAttribute_(_this.springSecurityContextKey_)_;,这里的springSecurityContextKey对象的值就是_SPRING_SECURITY_CONTEXT_KEY_常量,读取出来的对象最终会被强转为一个SecurityContext对象。 SecurityContext是一个接口,它有一个唯一的实现类SecurityContextImpl,这个实现类其实就是用户信息在session中保存的 value。- 在拿到
SecurityContext之后,通过SecurityContextHolder._setContext_方法将这个SecurityContext设置到ThreadLocal中去,这样,在当前请求中,SpringSecurity的后续操作,我们都可以直接从SecurityContextHolder中获取到用户信息了。 - 接下来,通过
chain.doFilter让请求继续往下走(这个时候请求就会进入到UsernamePasswordAuthenticationFilter过滤器中了)。 - 在过滤器链走完之后,数据响应给前端之后,finally 中还有一步收尾操作,这一步很关键。这里从
SecurityContextHolder中获取到SecurityContext,获取到之后,会把SecurityContextHolder清空,然后调用repo.saveContext方法将获取到的SecurityContext存入到session中。
至此,整个流程就很明了了。
✨小结:每一个请求到达服务端的时候,首先从session中找出来SecurityContext,然后设置到SecurityContextHolder中去,方便后续使用,当这个请求离开的时候,SecurityContextHolder会被清空,SecurityContext会被放回到session中,方便下一个请求来的时候获取。
3、问题解决
经过上面分析源码之后,再来回顾一下为什么会发生登录之后无法获取到当前登录用户信息这样的事情?
最简单的情况就是你在一个新的线程中去执行SecurityContextHolder.getContext().getAuthentication(),这肯定获取步到用户信息,无需多说。例如下面这样:
@GetMapping("/menu")public List<Menu> getMenusByHrId() {new Thread(new Runnable() {@Overridepublic void run() {Authentication authentication = SecurityContextHolder.getContext().getAuthentication();System.out.println(authentication);}}).start();return menuService.getMenusByHrId();}
还有一种隐藏比较深的就是在SecurityContextPersistenceFilter的doFilter方法中没能从session中加载到用户信息,进而导致SecurityContextHolder里面空空如也。
在SecurityContextPersistenceFilter中没能加载到用户信息,原因可能就比较多了:
- 上一个请求临走的时候,没有将数据存储到
session中去 - 当前请求没有走过滤器链
什么时候会发生这个问题呢?在配置SecurityConfig#configure(WebSecurity)方法时,会忽略掉一个重要的点。
当我们想让SpringSecurity中的资源可以匿名访问的时候,我们有两种办法:
- 不走SpringSecurity过滤器链
configure(WebSecurity web) - 继续走SpringSecurity过滤器链,但是可以匿名访问
configure(HttpSecurity http)
这两种办法对应了两种不同的配置方式。其中第一种配置可能会影响到我们获取登录用户信息,第二种则不影响,所以这里我们来重点看看第一种。
不想走SpringSecurity过滤器链,我们一般可以通过如下方式配置:
@Overridepublic void configure(WebSecurity web) throws Exception {web.ignoring().antMatchers("/css/**","/js/**","/index.html","/img/**","/fonts/**","/favicon.ico","/verifyCode");}
正常这样配置没有问题。但是如果你很不巧把登录请求地址放进来了,那就gg了。虽然登录请求可以被所有人访问,但是不能放在这里(而应该通过允许匿名访问的方式来给请求放行)。如果放在这里,登录请求将不走SecurityContextPersistenceFilter过滤器,也就意味着不会将登录用户信息存入到session,进而导致后续请求无法获取登录用户信息。
