SecurityContext 如何保存?异步线程中为什么可能丢失认证信息?
简化版
Spring Security 默认用 ThreadLocal 保存当前请求的 SecurityContext(里面装着 Authentication)——绑定在处理请求的那个线程上,同一线程的调用链里都能读到。问题:当代码切换到异步线程(@Async、线程池、CompletableFuture)时,ThreadLocal 不会自动跨线程传递,新线程里 SecurityContextHolder.getContext() 拿到的是空/匿名认证——认证信息「丢了」。解决:用 Spring Security 的委托包装器(DelegatingSecurityContext Runnable/Callable/Executor)或 TaskDecorator 在提交任务时捕获并传播上下文、执行后清理;或只显式传递业务需要的 userId/tenantId 更简单。
详细版
为什么会丢:
请求线程(有 SecurityContext in ThreadLocal)
│ 提交异步任务到线程池
↓
异步线程(新线程,自己的 ThreadLocal 是空的)
→ SecurityContextHolder.getContext() → 空/匿名认证 ❌
传播方案:
// 方案一:Security 委托包装器(捕获当前上下文,执行时设置、结束清理)
Executor delegating = new DelegatingSecurityContextExecutor(realExecutor);
// 或包装单个任务
Callable<T> wrapped = new DelegatingSecurityContextCallable<>(task);
// 方案二:Spring TaskDecorator(给 @Async 线程池装饰)
executor.setTaskDecorator(runnable -> {
SecurityContext ctx = SecurityContextHolder.getContext(); // 提交时捕获
return () -> {
SecurityContextHolder.setContext(ctx); // 执行前设置
try { runnable.run(); }
finally { SecurityContextHolder.clearContext(); } // 执行后清理
};
});
// 方案三(更稳):只传业务需要的标识
Long userId = currentUser.getId();
executor.submit(() -> process(userId, tenantId)); // 显式传参
完整版教学
一、SecurityContext 保存什么
SecurityContext 里最重要的是 Authentication——它代表当前主体(用户)和权限。Controller、服务层、@PreAuthorize 方法安全表达式,都依赖它判断「当前用户是谁、有什么权限」。
所以 SecurityContext 不只是「登录态容器」,更是「授权判断的数据来源」——一旦它在异步线程里丢了,不仅拿不到用户,方法级安全也会因为「拿不到认证」而拒绝访问或误判为匿名。
| 保存内容 | 典型用途 | 丢失后的表现 |
|---|---|---|
principal | 当前用户标识、审计字段 | 创建人为空或 anonymous |
authorities | 方法权限、URL 授权 | @PreAuthorize 拒绝 |
| 认证状态 | 判断是否匿名/已登录 | 异步任务被当成未登录 |
SecurityContext 的坑不是“拿不到用户名”这么轻,而是授权、审计、租户隔离都可能一起失去依据。
二、默认为什么用 ThreadLocal
一次 HTTP 请求通常由一个 Worker 线程从头处理到尾。把 SecurityContext 放在 ThreadLocal(线程本地变量)里的好处:整个调用链(Controller → Service → Repository)不用层层传参,任何地方都能通过 SecurityContextHolder.getContext() 读到当前认证——非常方便。
Spring Security 的生命周期:请求开始时加载/创建 SecurityContext(放进 ThreadLocal)→ 认证成功写入 → 请求结束时清理(SecurityContextHolder.clearContext())。
请求结束必须清理——因为 Worker 线程是线程池复用的,如果不清理,上一个请求的用户信息会残留在 ThreadLocal 里,被下一个复用该线程的请求读到,造成用户身份串号(严重安全问题)。Spring Security 的过滤器会在 finally 里清理,但自己操作 ThreadLocal 时要注意。
三、异步线程为什么拿不到(核心)
@Async、线程池、CompletableFuture 都会切换执行线程——任务不再在原来的请求线程上跑,而是在线程池的另一个线程上跑。
ThreadLocal 是绑定在「线程」上的,不会自动跨线程复制。所以异步任务运行在新线程里时,这个新线程的 ThreadLocal 是空的(或者是它上次复用时残留的),调用 SecurityContextHolder.getContext() 拿到的是空认证或匿名用户。
表现:异步任务里 @PreAuthorize 拒绝访问、审计字段(创建人)为空、异步日志里用户显示为 anonymous——这些都是「异步丢失认证」的典型症状。
请求线程 T1: ThreadLocal = userA
submit task
线程池线程 T9: ThreadLocal = empty 或旧值
SecurityContextHolder.getContext() -> anonymous/错误用户
四、不能随便改成 InheritableThreadLocal
有人想「把 ThreadLocal 换成 InheritableThreadLocal 不就能传给子线程了吗」——这是个坑:
InheritableThreadLocal只在「创建新子线程」时继承父线程的值。但现代应用大量使用线程池——线程池的线程是复用的、早就创建好的,不是每次任务都新建线程。所以对线程池不可靠(任务提交时线程早已存在,不会触发继承)。- 更糟的是,线程池复用 + InheritableThreadLocal 可能导致旧上下文残留——一个用户的上下文被继承后没清理,被后续任务读到,引发更隐蔽的安全问题(串号)。
所以别全局改成 InheritableThreadLocal,要用下面的正确传播方式。
五、推荐的传播方式
Spring Security 提供了委托包装器,在提交任务时捕获上下文、执行前设置、执行后清理,边界清晰:
DelegatingSecurityContextRunnable/DelegatingSecurityContextCallable:包装单个任务。DelegatingSecurityContextExecutor:包装整个 Executor,之后提交的任务都自动传播。
或者用 Spring 的 TaskDecorator 给异步线程池做装饰(见详细版代码)——在任务提交时抓当前 SecurityContext,任务执行前 setContext、执行完 clearContext。
关键是三步:捕获(提交时)→ 设置(执行前)→ 清理(执行后 finally)。清理尤其重要——异步线程也是复用的,不清理同样会污染下一个任务。
六、业务上更稳的做法:只传需要的标识
很多时候,异步任务其实不需要完整的 Authentication 对象,只需要 userId、tenantId、traceId 等几个业务标识。
这时把这几个必要标识作为「任务参数」显式传进去,往往比「传播整个安全上下文」更清晰、更安全:
Long userId = SecurityContextHolder.getContext().getAuthentication()... ; // 主线程取
asyncExecutor.submit(() -> doWork(userId, tenantId)); // 显式传参
好处:不用担心上下文传播/清理的边界问题、不会有权限对象过期或泄露风险、代码意图更明确(一看就知道异步任务用了哪些用户信息)。能显式传标识就别传整个上下文——这是更稳的工程实践。
七、排查与测试
- 排查:异步丢失认证的典型症状——审计字段(创建人/更新人)为空、
@PreAuthorize在异步方法里拒绝访问、异步日志用户为 anonymous。看到这些先怀疑上下文没传播。 - 测试:要覆盖异步任务中的用户标识是否正确,并检查线程池任务结束后上下文不会污染下一次执行(防串号)——提交两个不同用户的任务,验证第二个不会读到第一个的上下文残留。
八、常见误区与追问
- 误区:ThreadLocal 会随着任务提交自动传到线程池。 ThreadLocal 绑定线程,不绑定任务,线程池线程不会自动拿到请求线程的上下文。
- 误区:改成 InheritableThreadLocal 就能彻底解决异步丢上下文。 它只在线程创建时继承,对已存在的线程池线程不可靠,还可能造成上下文残留。
- 误区:异步任务结束不清理也没关系。 线程池会复用线程,不清理可能让下一个任务读到上一个用户的认证信息。
- 追问:DelegatingSecurityContextExecutor 做了哪三步? 提交时捕获上下文,执行前设置上下文,执行后在 finally 中清理上下文。
- 追问:什么时候更适合显式传
userId? 异步任务只需要审计或租户标识时,显式传参比传播整个Authentication更清晰、更安全。 - 追问:怎么测试是否发生上下文污染? 连续提交两个不同用户的异步任务,验证第二个任务不会读到第一个用户的上下文。
九、加强记忆
Spring Security 默认用 ThreadLocal 存 SecurityContext(含 Authentication,是登录态 + 授权判断的数据源),绑定在请求线程,调用链免传参,请求结束必须清理(线程池复用不清理会串号)。异步线程(@Async/线程池/CompletableFuture)会切换线程,ThreadLocal 不自动跨线程传播 → 拿到空/匿名认证(症状:审计字段空、@PreAuthorize 拒绝、日志 anonymous)。别用 InheritableThreadLocal(对线程池不可靠、还可能残留串号)。正确传播:DelegatingSecurityContext Runnable/Callable/Executor 或 TaskDecorator——捕获(提交时)→ 设置(执行前)→ 清理(finally)。更稳做法:只显式传 userId/tenantId 等业务标识(避免传播整个上下文的边界问题)。测试要验异步用户正确 + 无上下文污染。