前言
在使用spring security开发的过程中,我们常常会用到这样的写法:
UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication();
来获取UserDetails,即使是在web多线程环境下,我们也总是能拿到想要的结果。很奇怪spring security是如何做到的,因此开了这篇文章来分析。
注:本篇文章采用spring security4.2.3版本
spring security的过滤器链
首先我们得对spring security的过滤器链有一个整体上的认识。
在使用配置web.xml这种开发方式时,我们如果要使用spring security就必须得在web.xml中写入这样一段:
<filter>
<filter-name>springSecurityFilterChain</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
<filter-name>springSecurityFilterChain</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
这个意味着向tomcat容器注册一个Filter
,它会过滤/*
所有的请求。看DelegatingFilterProxy,顾名思义,这是一个代理类,真的过滤操作是由FilterChainProxy
来中的内部类VirtualFilterChain
来完成的。其中注册了一定数量的Filter
(一般是12个),来达到对请求的权限管理操作。具体代码:
public void doFilter(ServletRequest request, ServletResponse response)
throws IOException, ServletException {
//如果每个过滤器都已通过,会转回tomcat中ApplicationFilterChain
if (currentPosition == size) {
if (logger.isDebugEnabled()) {
logger.debug(UrlUtils.buildRequestUrl(firewalledRequest)
+ " reached end of additional filter chain; proceeding with original chain");
}
// Deactivate path stripping as we exit the security filter chain
this.firewalledRequest.reset();
originalChain.doFilter(request, response);
}
else {
//按顺序取出过滤器
currentPosition++;
Filter nextFilter = additionalFilters.get(currentPosition - 1);
if (logger.isDebugEnabled()) {
logger.debug(UrlUtils.buildRequestUrl(firewalledRequest)
+ " at position " + currentPosition + " of " + size
+ " in additional filter chain; firing Filter: '"
+ nextFilter.getClass().getSimpleName() + "'");
}
nextFilter.doFilter(request, response, this);
}
}
在一般情况下,spring security有一系列的Filter
:
WebAsyncManagerIntegrationFilter
:提供了对securityContext和WebAsyncManager的集成,其会把SecurityContext设置到异步线程中,使其也能获取到用户上下文认证信息SecurityContextPersistenceFilter
:这个是本篇文章的重点,它会根据策略获取一个SecurityContext放到SecurityContextHolder中,并且在请求结束后清空HeaderWriterFilter
:其会往该请求的Header中添加相应的信息,在http标签内部使用security:headers来控制LogoutFilter
:匹配URL,默认为/logout,匹配成功后则用户退出,清除认证信息.如果有自己的退出逻辑,那么这个过滤器可以disableUsernamePasswordAuthenticationFilter
:登录认证过滤器,根据用户名密码进行认证ConcurrentSessionFilter
:session同步过滤器,主要有两个功能,一是会刷新当前session的最后访问时间,二是判断当前session是否失效,失效了的话会做退出操作并触发相应事件。RequestCacheAwareFilter
:重新恢复被打断的请求SecurityContextHolderAwareRequestFilter
:将request包装成HttpServletRequestAnonymousAuthenticationFilter
:判断SecurityContext中是否有一个Authentication对象,如果没有创建一个新的(AnonymousAuthenticationToken)SessionManagementFilter
:检查session在spring security中是否是失效了(注意不是在web容器中),比如说配置设置了最大session数量为1,那么之前的session会被设置expired = trueExceptionTranslationFilter
:处理AccessDeniedException和AuthenticationException,为java exceptions和HTTP responses提供了桥梁FilterSecurityInterceptor
:对http资源做权限拦截,我们平时设置的不同角色不同权限访问就是借此Filter过滤
ThreadLocal
在详细介绍SecurityContextPersistenceFilter
之前,必须了解ThreadLocal这个类,spring中也许多地方用到了ThreadLocal。
先看一下它的百科:
JDK 1.2的版本中就提供java.lang.ThreadLocal,ThreadLocal为解决多线程程序的并发问题提供了一种新的思路。使用这个工具类可以很简洁地编写出优美的多线程程序,ThreadLocal并不是一个Thread,而是Thread的局部变量。
其中说的很清楚,ThreadLocal并不是一个Thread,而是Thread的局部变量,我想这也是大部分人将其翻译为“本地线程变量”的原因。
然后介绍一下其中的数据结构。
每个Thread会维护一个本地变量ThreadLocalMap,它是HashMap的另一种实现,key是ThreadLocal变量本身,value才是要存储的值。ThreadLocal本事并不存储任何值,它只是充当了key的作用。如下图:
ThreadLocalMap具体实现在这里我们不做分析,大可以将其想象为一个普通的Map,其中key是ThreadLocal本身,value是要存储的值。
SecurityContextPersistenceFilter
最关键的步骤就是SecurityContextPersistenceFilter这个过滤器了。先介绍下其中关键的两个类:
SecurityContextRepository
顾名思义,是存储SecurityContext的仓库,默认实现是HttpSessionSecurityContextRepository
,基于session,将SecurityContext用key="SPRING_SECURITY_CONTEXT"存入session中。
Object contextFromSession = httpSession.getAttribute(springSecurityContextKey);
SecurityContextHolder
在请求之间保存SecurityContext,提供了一系列的静态方法。使用了策略设计模式,默认使用的策略是ThreadLocalSecurityContextHolderStrategy
,这其中便是使用ThreadLocal进行存储的。
我们再说下该过滤器的大概流程:首先会从SecurityContextRepository
中获取SecurityContext
,然后将其设置到SecurityContextHolder
中,之后会转到下一个过滤器。在请求结束之后,清空SecurityContextHolder
,并将请求后的SecurityContext
在保存到SecurityContextRepository
中。
贴上源码:
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);
//从session中根据key取出SecurityContext,如果没有会创建一个新的
SecurityContext contextBeforeChainExecution = repo.loadContext(holder);
try {
//设置到ThreadLoacal中
SecurityContextHolder.setContext(contextBeforeChainExecution);
chain.doFilter(holder.getRequest(), holder.getResponse());
}
finally {
SecurityContext contextAfterChainExecution = SecurityContextHolder
.getContext();
// Crucial removal of SecurityContextHolder contents - do this before anything
// else.
SecurityContextHolder.clearContext();
repo.saveContext(contextAfterChainExecution, holder.getRequest(),
holder.getResponse());
request.removeAttribute(FILTER_APPLIED);
if (debug) {
logger.debug("SecurityContextHolder now cleared, as request processing completed");
}
}
}
注意:这个finally
语句块是在request经过Filter,到达DispatcherServlet完成业务处理之后才会运行的。所以我们可以在controller中直接使用文章开头的方式获取到当前登录用户。
总结
最后再来梳理一遍流程:
浏览器发起一个Http请求到达Tomcat,Tomcat将其封装成一个Request,先经过Filter,其中经过spring security的SecurityContextPersistenceFilter,从session中取出SecurityContext(如果没有就创建新的)存入当前线程的ThreadLocalMap中,因为是当前线程,所以不同的线程之间根本互不影响。之后完成Servlet调用,执行finally语句块,清除当前线程中ThreadLocalMap对应的SecurityContext,再将其覆盖session中之前的部分。
这里多说一句,为什么在每次请求之后要清空当前线程呢?看一下spring的官方api说明:
In an application which receives concurrent requests in a single session, the same SecurityContext instance will be shared between threads. Even though a ThreadLocal is being used, it is the same instance that is retrieved from the HttpSession for each thread. This has implications if you wish to temporarily change the context under which a thread is running. If you just use SecurityContextHolder.getContext(), and call setAuthentication(anAuthentication) on the returned context object, then the Authentication object will change in all concurrent threads which share the same SecurityContext instance. You can customize the behaviour of SecurityContextPersistenceFilter to create a completely new SecurityContext for each request, preventing changes in one thread from affecting another. Alternatively you can create a new instance just at the point where you temporarily change the context. The method SecurityContextHolder.createEmptyContext() always returns a new context instance.
简而言之,因为SecurityContext,是放在session中的,所有一个session下的request的都是共享一个SecurityContext,也就是会有多个Thread共享一个SecurityContext。如果我们在某一个线程中只是想临时对SecurityContext做点更改,那么其他线程中SecurityContext也会受到影响,这是不被允许的。