前言
在《Spring Security之认证过滤器》中,我们知道认证信息在用户登录成功后,会通过SecurityContextRepository保存。而在《Spring Security之Session管理》中,我们知道SessionManagementConfigurer会创建他。但上面两篇文章都没有仔细说,今天作为主角,咱就好好说道说道,并聊聊与之相关的SecurityContextHolder。
SecurityContextRepository
从名字,我们就知道负责维护SecurityContext。先来看看他的定义:
public interface SecurityContextRepository {
/**
* 保存安全上下文
*/
void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response);
/**
* 从仓库中查询当前请求是否存在上下文
*/
boolean containsContext(HttpServletRequest request);
/**
* 从提供的请求中获取SecurityContext。对于未认证的用户,应返回空SecurityContext,而不是null。
* HttpRequestResponseHolder参数的作用是允许实现者返回二次封装的request和response,以便访问特定的request或者response(也可以都返回)。
* 当最后一次调用的时候,会显式保存SecurityContext,从holder获取的值将被传到过滤器链条和saveContext方法中。
* 实现类可能希望返回-当发生错误或者重定向时,确保上下文已经保存的-SaveContextOnUpdateOrErrorResponseWrapper的子类作为response对象。
* 实现类可能允许传入原始的request的response来实现显式保存。
* @deprecated 请使用#loadDeferredContext(HttpServletRequest)方法代替.
*/
@Deprecated
SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder);
/**
* 延迟从HttpServletRequest加载SecurityContext,在需要的时候才加载。
* 很明显这个方法是前者的代替者。
*/
default DeferredSecurityContext loadDeferredContext(HttpServletRequest request) {
Supplier<SecurityContext> supplier = () -> loadContext(new HttpRequestResponseHolder(request, null));
return new SupplierDeferredSecurityContext(SingletonSupplier.of(supplier),
SecurityContextHolder.getContextHolderStrategy());
}
}
前面聊session的时候,我们也说过,这个组件有两个实现,一个负责stateless应用,另一个负责stateful应用。我们重点看看后者。
为什么使用Session存储认证信息
我们知道Session也就是会话,会话可以用于维护用户当前一系列操作请求的状态。而我们的认证信息,就是其中的状态之一。他表示用户已经登录,并且是哪个账号在访问系统。但是很显然的是,我们这些信息只能是一个时间段内的,因为我们的账号可能会退出登录态,也就是登出。因此,虽然我们可以在后台数据库中维持这一状态,但是随着用户的退出,我们保存在数据库中的状态,也就不存在任何意义了,浪费空间。
使用Session来存储这一信息,正好符合诉求。需要使用时,直接读取,不用时(退出登录态)自动清除,腾出空间。
但值得注意的是,使用Session需要留意一下,在登录后,通常我们都会重建Session,这可能会导致一些问题。
HttpSessionSecurityContextRepository
/**
* SecurityContextRepository的一个实现,将安全上下文保存在HttpSession中。
* 默认情况下,会通过loadContext方法(key: #SPRING_SECURITY_CONTEXT_KEY)从HttpSession中查询获取SecurityContext。
* 如果不能从HttpSession中获取到一个有效的SecurityContext,则不管是什么原因,都会调用新的SecurityContextHolder#createEmptyContext()方法创建一个新的SecurityContext,并返回该实例。
*
* 当saveContext方法被调用时,上下文将使用同样的key进行保存,为如下场景提供服务:
* <ol>
* <li>值变更</li>
* <li>配置好的AuthenticationTrustResolver不认为该内容代表一个匿名用户</li>
* </ol>
*
* 在标准配置中,即便是真的不存在HttpSession,loadContext方法也不会创建新的HttpSession。当saveContext在web请求即将结束被调用,只有在传入的SecurityContext不是一个空的SecurityContext实例时,才会创建一个新的HttpSession。这能够避免不必要的HttpSession的创建,但会自动存储请求期间对上下文所做的变更。
* 注意:如果SecurityContextPersistenceFilter配置了提前HttpSession,那么session最小化的逻辑将不再生效。
* 如果你使用急切的session创建方式,那么你应该确保这个类的allowSessionCreation属性设置为true(默认)。
* <p>
* 如果由于某些原因(例如,使用Basic认证方式或者多个类似的客户端永远不会出现相同的jsessionid的情况)导致HttpSession没有被创建,那么应当通过#setAllowSessionCreation(boolean)将allowSessionCreation属性设置为false。
* 只有你真正需要节省服务器内存时,并确保所有使用SecurityContextHolder的类,都被设计成:在不同的web请求之间都不需要持久化的SecurityContext时,你才能这样做。
* @since 3.0
*/
public class HttpSessionSecurityContextRepository implements SecurityContextRepository {
// 这个方法重写了,并没有使用接口上定义的default实现
// 这里省略了loadContext方法,这个方法的实现比较复杂,还整出了个内部类。相比之下,新的方法就简单好多。
@Override
public DeferredSecurityContext loadDeferredContext(HttpServletRequest request) {
Supplier<SecurityContext> supplier = () -> readSecurityContextFromSession(request.getSession(false));
return new SupplierDeferredSecurityContext(supplier, this.securityContextHolderStrategy);
}
@Override
public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
// 这个地方是为了兼容老方法:loadContext
SaveContextOnUpdateOrErrorResponseWrapper responseWrapper = WebUtils.getNativeResponse(response,
SaveContextOnUpdateOrErrorResponseWrapper.class);
if (responseWrapper == null) {
// 这个是新逻辑的核心处理方法,无非就是将内容保存到session之中。
saveContextInHttpSession(context, request);
return;
}
responseWrapper.saveContext(context);
}
@Override
public boolean containsContext(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return false;
}
return session.getAttribute(this.springSecurityContextKey) != null;
}
}
这里展示了核心方法,并且做了必要的注释。无非就两种行为:一个是保存:登陆成功后,保存当前认证信息。另一个是查询当前会话中的上下文,用于登录之后的后续请求读取当前认证信息。
显然,前者是认证过滤器调用的,也就是UsernamePasswordAuthenticationFilter
。之所以这么明确,是因为其他使用token维护登录态的,通常只能是每个请求都通过token来认证,说到底就是确认token的有效性。如此一来,这个组件也就失去了作用。
SecurityContextPersistenceFilter
那么,我们什么时候需要读取认证信息?欸,我们不是有个SecurityContextHolder吗,我们不是直接通过他拿到当前上下文的吗?欸,是不是在请求到来的时候,从session中读取出来恢复的?不对啊,SecurityContextHolder不是static的吗?假如有多个用户访问应用的话,那不全乱套了?
别急,实际上到这里,我们就需要介绍一下当前这个过滤器了,他就是负责将SecurityContextRepository中的认证信息读取出来,并存入ThreadLocal中。而且时机必须是在所有认证过滤器之前,也必须在请求的业务处理之前,否则就是去意义了。
实际上他在SpringSecurity一系列的过滤之中,执行顺序排行第三:
FilterOrderRegistration() {
Step order = new Step(INITIAL_ORDER, ORDER_STEP);
put(ChannelProcessingFilter.class, order.next());
order.next(); // gh-8105
put(WebAsyncManagerIntegrationFilter.class, order.next());
put(SecurityContextPersistenceFilter.class, order.next());
}
由于他的职责足够单一,所以也非常简单:
public class SecurityContextPersistenceFilter extends GenericFilterBean {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
}
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 确保当前过滤器只执行一次
if (request.getAttribute(FILTER_APPLIED) != null) {
chain.doFilter(request, response);
return;
}
request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
if (this.forceEagerSessionCreation) {
// 配置了强制生成session(确保session一定存在)
HttpSession session = request.getSession();
}
HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
// 通过SecurityContextRepository加载当前安全上下文
SecurityContext contextBeforeChainExecution = this.repo.loadContext(holder);
try {
// 将SecurityContext保存到SecurityContextHolder
SecurityContextHolder.setContext(contextBeforeChainExecution);
chain.doFilter(holder.getRequest(), holder.getResponse());
}
finally {
SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();
// 清理上下文 —— 与threadLocal有关
SecurityContextHolder.clearContext();
this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse());
request.removeAttribute(FILTER_APPLIED);
}
}
}
是不是就串起来了?就这样,我们可以很愉快地通过SecurityContextHolder获取当前用户信息了。实际上,如果咱使用的是Spring MVC+Spring Security,可以使用【@AuthenticationPrincipal UserDetails currentUser】就能获取到当前登录用户了。
SecurityContextHolderStrategy
这里还差一点东西没有清楚,就是SecurityContextHolder是怎么保存安全信息的。由于涉及并发请求访问,因此,ThreadLocal自然而然进入了我们的实现。
他如下几种策略:
策略 | 实现 | 描述 |
SecurityContextHolder#MODE_THREADLOCAL | ThreadLocalSecurityContextHolderStrategy | 使用ThreadLocal,这是默认策略。在子线程中无法访问。 |
SecurityContextHolder#MODE_INHERITABLETHREADLOCAL | InheritableThreadLocalSecurityContextHolderStrategy | 使用InheritableThreadLocal,这可以在子线程中获取当前SecurityContext |
SecurityContextHolder#MODE_GLOBAL | GlobalSecurityContextHolderStrategy | 使用SecurityContextImpl,整个JVM内共享,在特定场景才有用。一般不用他 |
如果真的需要修改策略配置的话,可以直接通过@Bean提供一个实现即可。
public class SecurityContextHolder {
private static void initializeStrategy() {
if (MODE_PRE_INITIALIZED.equals(strategyName)) {
Assert.state(strategy != null, "When using " + MODE_PRE_INITIALIZED
+ ", setContextHolderStrategy must be called with the fully constructed strategy");
return;
}
if (!StringUtils.hasText(strategyName)) {
// Set default
strategyName = MODE_THREADLOCAL;
}
if (strategyName.equals(MODE_THREADLOCAL)) {
strategy = new ThreadLocalSecurityContextHolderStrategy();
return;
}
if (strategyName.equals(MODE_INHERITABLETHREADLOCAL)) {
strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
return;
}
if (strategyName.equals(MODE_GLOBAL)) {
strategy = new GlobalSecurityContextHolderStrategy();
return;
}
// Try to load a custom strategy
try {
Class<?> clazz = Class.forName(strategyName);
Constructor<?> customStrategy = clazz.getConstructor();
strategy = (SecurityContextHolderStrategy) customStrategy.newInstance();
}
catch (Exception ex) {
ReflectionUtils.handleReflectionException(ex);
}
}
}
总结
- 我们通常将当前用户信息保存在session中,由HttpSessionSecurityContextRepository来管理。
- 在请求进入后,先通过SecurityContextPersistenceFilter将HttpSessionSecurityContextRepository中的SecurityContext恢复到SecurityContextHolder,这样后续的处理就能通过它来获取SecurityContext了。业务代码也无需关注HttpSessionSecurityContextRepository了。
- 默认的SecurityContextHolder策略是ThreadLocal,是不能在子线程中获取SecurityContext的。如果子线程需要,那么需要配置成InheritableThreadLocal的。
后记
今天,咱们聊了认证信息的处理。并深入了解了两个组件:SecurityContextRepository、SecurityContextHolderStrategy。这两个组件实际上是共享组件,涉及到了功能的协同。试想一下,如果SecurityContextPersistenceFilter和认证过滤器使用的不是同一个SecurityContextRepository对象,根本就玩不转。
下次,咱们再聊另外一个需要协同的功能:认证异常处理。