前言

在《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);
		}
	}
}

总结

  1. 我们通常将当前用户信息保存在session中,由HttpSessionSecurityContextRepository来管理。
  2. 在请求进入后,先通过SecurityContextPersistenceFilter将HttpSessionSecurityContextRepository中的SecurityContext恢复到SecurityContextHolder,这样后续的处理就能通过它来获取SecurityContext了。业务代码也无需关注HttpSessionSecurityContextRepository了。
  3. 默认的SecurityContextHolder策略是ThreadLocal,是不能在子线程中获取SecurityContext的。如果子线程需要,那么需要配置成InheritableThreadLocal的。

后记

今天,咱们聊了认证信息的处理。并深入了解了两个组件:SecurityContextRepository、SecurityContextHolderStrategy。这两个组件实际上是共享组件,涉及到了功能的协同。试想一下,如果SecurityContextPersistenceFilter和认证过滤器使用的不是同一个SecurityContextRepository对象,根本就玩不转。
下次,咱们再聊另外一个需要协同的功能:认证异常处理。