SecurityContextPersistenceFilter
定义与作用
SecurityContextPersistenceFilter(Spring Security 5.x 中的标准写法,在 6.x 中被 SecurityContextHolderFilter 替代)是过滤器链中的安全上下文持久化过滤器,通常位于链的最前端(紧跟在 WebAsyncManagerIntegrationFilter 之后)。它的核心职责是:
- 请求开始时:从
HttpSession中加载SecurityContext,存入SecurityContextHolder(ThreadLocal),使后续过滤器可以访问当前用户身份。 - 请求结束时:将
SecurityContextHolder中的SecurityContext写回HttpSession,并在当前线程中清理SecurityContextHolder,防止内存泄漏。
核心原理
1. SecurityContextRepository
SecurityContextPersistenceFilter 通过 SecurityContextRepository 接口与 HttpSession 交互:
public interface SecurityContextRepository {
// 从请求中加载 SecurityContext(如从 Session)
SecurityContext loadContext(HttpRequestResponseHolder holder);
// 将 SecurityContext 保存到请求(如写入 Session)
void saveContext(SecurityContext context, HttpServletRequest request,
HttpServletResponse response);
// 判断当前请求是否包含可用的 SecurityContext(如 Session 是否存在)
boolean containsContext(HttpServletRequest request);
}
默认实现是 HttpSessionSecurityContextRepository,将 SecurityContext 以 SPRING_SECURITY_CONTEXT 为属性名存入 HttpSession。
2. 请求生命周期中的完整流程
3. 为什么请求结束必须清理 ThreadLocal
SecurityContextHolder 默认使用 ThreadLocal 存储。如果请求结束时未清理,线程归还到线程池后,下一次被分配给另一个请求时,新请求会复用旧请求的 SecurityContext,导致严重的身份串用安全漏洞。
// SecurityContextPersistenceFilter 核心逻辑(简化)
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
// 1. 加载上下文
HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
SecurityContext context = repo.loadContext(holder);
SecurityContextHolder.setContext(context);
try {
chain.doFilter(holder.getRequest(), holder.getResponse());
} finally {
// 2. 保存并清理(无论是否发生异常,都在 finally 中执行)
SecurityContext finalContext = SecurityContextHolder.getContext();
repo.saveContext(finalContext, holder.getRequest(), holder.getResponse());
SecurityContextHolder.clearContext();
}
}
完整示例一:自定义 SecurityContextRepository(Session 与 Redis 混合)
场景说明
应用需要支持集群部署,Session 需要共享。虽然 Spring Session 可以自动解决,但某些场景下需要自定义 SecurityContextRepository,将 SecurityContext 同时存入本地 HttpSession(快速读取)和 Redis(集群共享)。
操作前配置(默认 Session 存储)
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults());
return http.build();
}
// 默认 HttpSessionSecurityContextRepository,单机没问题,集群下 Session 不共享
问题分析:集群部署时,用户请求可能被负载均衡到不同节点。如果节点 A 的 Session 未同步到节点 B,用户登录后访问节点 B 时,SecurityContextPersistenceFilter 从 Session 中读取不到 SecurityContext,导致用户被强制要求重新登录。
操作后配置(自定义 Repository)
@Configuration
@EnableWebSecurity
public class ClusterSecurityContextConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults())
// 关键:替换默认的 SecurityContextRepository
.securityContext(context -> context
.securityContextRepository(redisSecurityContextRepository())
);
return http.build();
}
@Bean
public SecurityContextRepository redisSecurityContextRepository() {
return new SecurityContextRepository() {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String REDIS_KEY_PREFIX = "security:context:";
@Override
public SecurityContext loadContext(HttpRequestResponseHolder holder) {
HttpServletRequest request = holder.getRequest();
HttpSession session = request.getSession(false);
// 1. 先尝试从本地 Session 加载(快速)
if (session != null) {
SecurityContext ctx = (SecurityContext) session
.getAttribute("SPRING_SECURITY_CONTEXT");
if (ctx != null) {
return ctx;
}
}
// 2. Session 中没有,从 Redis 加载(跨节点)
String sessionId = request.getSession().getId();
String redisKey = REDIS_KEY_PREFIX + sessionId;
String contextJson = redisTemplate.opsForValue().get(redisKey);
if (contextJson != null) {
// 反序列化 SecurityContext(简化示意)
return deserializeContext(contextJson);
}
return SecurityContextHolder.createEmptyContext();
}
@Override
public void saveContext(SecurityContext context,
HttpServletRequest request,
HttpServletResponse response) {
HttpSession session = request.getSession();
String sessionId = session.getId();
// 1. 写入本地 Session
session.setAttribute("SPRING_SECURITY_CONTEXT", context);
// 2. 写入 Redis(集群共享,设置过期时间)
String redisKey = REDIS_KEY_PREFIX + sessionId;
String contextJson = serializeContext(context);
redisTemplate.opsForValue().set(redisKey, contextJson,
Duration.ofMinutes(30));
}
@Override
public boolean containsContext(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null && session.getAttribute("SPRING_SECURITY_CONTEXT") != null) {
return true;
}
String sessionId = request.getSession().getId();
return Boolean.TRUE.equals(
redisTemplate.hasKey(REDIS_KEY_PREFIX + sessionId)
);
}
// 序列化/反序列化辅助方法(省略具体实现)
private String serializeContext(SecurityContext context) { return ""; }
private SecurityContext deserializeContext(String json) {
return SecurityContextHolder.createEmptyContext();
}
};
}
}
结果分析
| 场景 | 节点 A | 节点 B | 结果 |
|---|---|---|---|
| 用户在节点 A 登录 | Session 写入 SecurityContext,Redis 同步写入 | — | 登录成功 |
| 用户访问节点 B | — | 从 Redis 读取 SecurityContext,加载到 ThreadLocal | 无需重新登录,保持认证状态 |
| 请求结束 | 保存到 Session + Redis | 保存到 Session + Redis | 双写保障一致性 |
| Session 过期 | Redis Key 同时过期 | — | 自动清理,无残留 |
完整示例二:禁用 Session 存储(无状态 API)
场景说明
纯 REST API 服务使用 JWT 认证,每个请求携带 Token 进行状态校验,不需要 Session 存储 SecurityContext。如果仍然使用默认的 HttpSessionSecurityContextRepository,每个请求都会创建 HttpSession 并写入 SecurityContext,浪费内存且违背无状态设计。
操作前配置(默认,带 Session)
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults()) // 或 JWT 过滤器
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
// 即使设置了 STATELESS,SecurityContextPersistenceFilter 仍会尝试读写 Session
// 虽然 STATELESS 不创建新 Session,但 SecurityContextRepository 仍然可能操作已有 Session
问题分析:SessionCreationPolicy.STATELESS 确实阻止了 Session 创建,但 SecurityContextPersistenceFilter 仍会在请求结束时尝试调用 saveContext()。如果请求中已有 Session(如通过 JSESSIONID Cookie),它会写入 SecurityContext 到 Session 中。这在无状态 API 中是不必要的开销。
操作后配置(使用 NullSecurityContextRepository)
@Configuration
@EnableWebSecurity
public class StatelessApiConfig {
@Bean
@Order(1)
public SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
// 关键:使用 NullSecurityContextRepository 完全禁用 Session 存储
.securityContext(context -> context
.securityContextRepository(new NullSecurityContextRepository())
)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain webSecurity(HttpSecurity http) throws Exception {
http
.securityMatcher("/web/**")
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
// Web 端保持默认 HttpSessionSecurityContextRepository
return http.build();
}
}
NullSecurityContextRepository 的 loadContext() 返回空上下文,saveContext() 为空操作,containsContext() 返回 false。这样 SecurityContextPersistenceFilter 对 Session 无任何操作,但仍会在 finally 中清理 SecurityContextHolder,这是安全必需的。
结果分析
| 请求 | Session 行为 | SecurityContext 来源 | 请求结束后 Session 状态 |
|---|---|---|---|
/api/data | 不读写 Session | 由 BasicAuthenticationFilter 或 JWT 过滤器创建并存入 ThreadLocal | SecurityContext 不保存,ThreadLocal 被清理 |
/web/page | 正常读写 Session | 从 Session 加载,表单登录后写入 | SecurityContext 持久化到 Session |
/api/data(带 JSESSIONID) | 忽略已有 Session,不读取 | 由认证过滤器重建 | 不写入 Session,ThreadLocal 被清理 |
易错场景:异步请求中 SecurityContext 丢失
问题现象
@Service
public class ReportService {
@Async // 使用 Spring 异步线程池
public CompletableFuture<Report> generateReport() {
// 在新线程中执行
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// auth 为 null!因为 ThreadLocal 不跨线程继承
String username = auth.getName(); // 抛出 NullPointerException
// ...
}
}
根因:SecurityContextHolder 默认使用 MODE_THREADLOCAL,SecurityContext 绑定到 Servlet 容器的请求线程。@Async 方法在新线程池中执行,新线程的 ThreadLocal 是空的,因此 SecurityContext 丢失。
正确解决方案
在 Spring Security 5.x 中,可通过 SecurityContextHolder 策略或 DelegatingSecurityContextRunnable 传递上下文:
@Configuration
@EnableWebSecurity
public class AsyncSecurityConfig implements AsyncConfigurer {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.securityContext(context -> context
.securityContextRepository(new HttpSessionSecurityContextRepository())
);
return http.build();
}
// 方法1:配置 ThreadPoolTaskExecutor,包装任务以继承 SecurityContext
@Override
@Bean
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setThreadNamePrefix("async-");
// 关键:使用 DelegatingSecurityContextExecutor 包装
executor.initialize();
return new DelegatingSecurityContextExecutorService(executor.getThreadPoolExecutor());
}
// 方法2:在异步方法中手动从 SecurityContextHolder 获取并传递(不推荐)
@Async
public CompletableFuture<Report> generateReportWithManualPass() {
// 不行,auth 已经在异步线程中丢失了
// 正确做法是在调用方提取并作为参数传入
}
}
// 调用方正确做法:在同步线程中提前获取
@RestController
public class ReportController {
@Autowired
private ReportService reportService;
@GetMapping("/report")
public ResponseEntity<?> generate() {
// 在请求线程中获取当前用户
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// 将用户名作为参数传入,而不是依赖异步线程读取 SecurityContext
return ResponseEntity.ok(reportService.generateReport(auth.getName()));
}
}
面试考点:
SecurityContextPersistenceFilter在请求结束时为什么必须调用SecurityContextHolder.clearContext()? 答:SecurityContextHolder默认使用ThreadLocal存储。Servlet 容器使用线程池处理请求,如果请求结束时不清理,线程归还后会被分配给下一个请求。新请求会复用旧请求的SecurityContext,导致用户 A 的请求能看到用户 B 的身份信息,造成严重的身份串用安全漏洞。SecurityContextPersistenceFilter在finally块中确保清理操作一定执行,即使请求过程中发生异常。