乐途乐途
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
  • 学习路径
  • 第1章 Spring Security 基础

    • 本章定位
    • Spring Security 是什么
    • DelegatingFilterProxy
    • 安全过滤器链
    • SecurityFilterChain
    • 过滤器执行顺序
  • 第2章 认证

    • 本章定位
    • Authentication
    • 认证流程
    • AuthenticationManager
    • ProviderManager
    • DaoAuthenticationProvider
    • UserDetails
    • UserDetailsService
    • PasswordEncoder
    • BCryptPasswordEncoder
    • DelegatingPasswordEncoder
    • 表单登录
    • SecurityContext
    • SecurityContextHolder
    • UsernamePasswordAuthenticationToken
  • 第3章 授权

    • 本章定位
    • 授权模型
    • GrantedAuthority
    • AccessDecisionManager
    • AccessDecisionVoter
    • URL 级别授权
    • 方法级别安全
    • @PreAuthorize
    • @PostAuthorize
    • @PreFilter
    • @PostFilter
    • @Secured
    • RoleHierarchy
  • 第4章 过滤器链

    • 本章定位
    • FilterChainProxy
    • SecurityContextHolderFilter
    • LogoutFilter
    • BasicAuthenticationFilter
    • CsrfFilter
    • CorsFilter
    • HeaderWriterFilter
    • AnonymousAuthenticationFilter
    • RequestCacheAwareFilter
    • ExceptionTranslationFilter
    • FilterSecurityInterceptor
  • 第5章 会话管理

    • 本章定位
    • 会话管理
    • SessionFixation
    • 会话并发控制
    • SessionCreationPolicy
    • RememberMe
  • 第6章 JWT

    • 本章定位
    • JWT
    • JwtDecoder
    • JWT 认证
    • JwtAuthenticationConverter
  • 第7章 OAuth2

    • 本章定位
    • OAuth2 基础
    • OAuth2 Client
    • OAuth2 Resource Server
    • 第三方登录配置
  • 第8章 攻击防护

    • 本章定位
    • CSRF 跨站请求伪造防护
    • CORS 跨域防护
    • Clickjacking 点击劫持防护
    • 安全响应头
    • Session Fixation 会话固定防护
  • 第9章 测试

    • 本章定位
    • 安全测试
    • @WithMockUser
    • 最佳实践

SecurityContextPersistenceFilter

定义与作用

SecurityContextPersistenceFilter(Spring Security 5.x 中的标准写法,在 6.x 中被 SecurityContextHolderFilter 替代)是过滤器链中的安全上下文持久化过滤器,通常位于链的最前端(紧跟在 WebAsyncManagerIntegrationFilter 之后)。它的核心职责是:

  1. 请求开始时:从 HttpSession 中加载 SecurityContext,存入 SecurityContextHolder(ThreadLocal),使后续过滤器可以访问当前用户身份。
  2. 请求结束时:将 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 过滤器创建并存入 ThreadLocalSecurityContext 不保存,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 块中确保清理操作一定执行,即使请求过程中发生异常。

上一页
FilterChainProxy
下一页
LogoutFilter