有段时间我接手一个老项目,登录用的是 Spring Security 默认表单,大家的状态是“能登录就行”。后来需求变成前后端分离、接口要返回 JSON、某些接口要按角色过滤,问题一下子全冒出来:有人明明登录了,异步线程里却拿不到用户信息;自定义过滤器明明写了,请求却始终没走校验逻辑;还有人把 anonymousUser 当普通字符串去做 equals 判断,结果线上炸了。这些问题的根子都指向同一个地方——Spring Security 的身份验证过程,以及验证结果所在的“安全上下文”。
这篇文章不是教你怎么把 Spring Security 配置跑起来,而是从认证链路和安全上下文这两条线,把框架内部拆开:为什么有这么多 Filter、Provider、Manager,验证结果到底存在哪,又是怎么被下一环取出来的。适合刚接触 Spring Security 但觉得文档太散的开发者,也适合被上下文丢失、自定义认证搞痛过、想系统搞明白的人。我尽量少说官方文档里已经重复过多次的术语解释,多说实现里真正决定行为的设计点。
1. 认证链路的三层抽象:从一次表单登录看完整执行路径
Spring Security 最容易被误解的地方,是它不只有一个“认证组件”,而是一条完整的责任链。一次默认的表单登录,请求会经过这样一条路径:
HttpServletRequest -> FilterChainProxy -> SecurityContextHolderFilter -> UsernamePasswordAuthenticationFilter -> AuthenticationManager -> ProviderManager -> DaoAuthenticationProvider -> UserDetailsService.loadUserByUsername() -> PasswordEncoder.matches() -> 认证成功,Authentication 回到 Filter -> SecurityContextHolder.getContext().setAuthentication(auth) -> 授权过滤器读取上下文你可以在任意一个环节打断点,看到认证状态从“没认证”变成“已认证”的过程。这条链路里真正干活的抽象只有三个:Authentication、AuthenticationManager、AuthenticationProvider。理解了这三个东西各自的职责,自定义任何认证方式都不会再心虚。
1.1 三个核心接口各管一段
Authentication是凭证的载体。未认证时,它里面通常是用户名和原始密码;认证完成后,它里面的 principal 会换成完整的UserDetails,密码会被擦除,同时带上用户角色。它像一张“从候选到正式”的票,状态由isAuthenticated()区分。
AuthenticationManager是认证总入口,只有一个authenticate()方法。它不关心你是表单登录、手机验证码还是 OAuth2,只负责把认证这件事派发下去。它的默认实现是ProviderManager。
ProviderManager内部维护了一组AuthenticationProvider。它拿到请求后,逐个问每个 Provider:“你支持这种登录方式吗?”支持就交给它执行,不支持就换下一个。这种委托设计最大的好处是:新增一种登录方式,不需要改已有入口,只需要加一个 Provider。
AuthenticationProvider才是真正干活的人。表单登录场景下,DaoAuthenticationProvider会调用UserDetailsService加载用户,再用PasswordEncoder校验密码。这也是绝大多数自定义登录都要重写的一层。
这三个抽象解决了同一个问题:把“认证入口”和“认证实现”解耦。你完全可以在不改UsernamePasswordAuthenticationFilter的情况下,用同一个入口接上短信验证码、扫码登录、LDAP 等多种认证方式。
1.2 认证成功前后的 Authentication 状态变化
开发新手最常犯的错,是以为DaoAuthenticationProvider返回的 Authentication 和登录请求里构造的那个是同一个东西。其实不是。
表单登录时,UsernamePasswordAuthenticationFilter构造的是一个未认证的UsernamePasswordAuthenticationToken:
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);这个 token 的isAuthenticated()是 false,principal 是字符串形式的用户名,credentials 是明文密码。然后它被传给AuthenticationManager,一层层往下走,直到某个 Provider 校验完成。
校验成功之后,Provider 会重新构造一个已认证的 token 返回:
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());注意第二个参数传的是 null,因为 Spring Security 默认会在认证完成后eraseCredentialsAfterAuthentication,把密码等敏感信息从内存中擦掉。第三个参数是权限列表,决定这个用户后续能访问哪些接口。这里有个很隐蔽的坑:如果你的自定义 Provider 忘记传 authorities,登录会成功,但后面任何hasRole()、hasAuthority()判断都会失败。
1.3 自定义 Provider 的 supports 方法是生死线
如果你想接入手机验证码登录,通常做法是实现一个AuthenticationProvider。很多教程会给你一个类似这样的代码:
@Component public class SmsCodeAuthenticationProvider implements AuthenticationProvider { @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String phone = authentication.getPrincipal().toString(); // 调用自己的用户服务加载用户 UserDetails user = userService.loadUserByPhone(phone); if (user == null) { throw new BadCredentialsException("用户不存在"); } return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); } @Override public boolean supports(Class<?> authentication) { return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication); } }supports()方法决定这个 Provider 会不会被ProviderManager选中。如果你漏了这个方法,或者返回的 Class 和实际传入的 token 类型对不上,Provider 永远不会有执行机会。更麻烦的是 ProviderManager 不会报错,只会一路找下去,最后抛一个没人接得住的ProviderNotFoundException。
在排查自定义认证问题时,第一件事就是确认supports()判断的 token 类型和过滤器构造的 token 类一致。我见过太多人盯着authenticate()看老半天,最后发现是 supports 写错了。
2. SecurityContext 的存放机制:ThreadLocal 是基座,策略选择决定边界
认证完成后,结果要放到一个当前请求后续代码能访问的地方。Spring Security 的做法是存进SecurityContextHolder,而它的底层是一个ThreadLocal<SecurityContext>。
你可以把 ThreadLocal 理解成每个线程自带的储物柜。每个线程往里放东西,只能自己取到,其他线程摸不着。这样设计的好处很直接:同一个线程里的后续过滤器、Controller、Service 都能通过SecurityContextHolder.getContext()拿到当前用户;不同线程之间天然隔离,不会互相干扰。
但隔离也带来一个副作用:子线程拿不到父线程的上下文。这个问题在异步场景里会频繁引爆,我放到下一节专门讲。这里先搞清楚框架默认怎么处理。
2.1 三种存储策略和它们的适用场景
SecurityContextHolder提供了三种策略,通过系统属性spring.security.strategy或代码设置:
| 策略 | 行为 | 典型场景 |
|---|---|---|
| MODE_THREADLOCAL | 默认,每个线程只能访问自己的上下文 | 普通同步请求,绝大多数项目 |
| MODE_INHERITABLETHREADLOCAL | 子线程创建时继承父线程的上下文 | 需要异步获取用户信息,且线程池可预期回收 |
| MODE_GLOBAL | 整个 JVM 共享一个上下文 | 桌面程序、消息监听等非 Web 场景,基本不建议 Web 项目用 |
设置方式很简单:
java -Dspring.security.strategy=MODE_INHERITABLETHREADLOCAL -jar app.jar或者在启动时调用:
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);关键在于:INHERITABLETHREADLOCAL 的继承发生在子线程创建的那一刻。如果子线程来自线程池,而线程池复用了旧线程,那拿到的上下文可能是上一次任务的,不是当前请求的。这个模式在 Tomcat 这类本身就会复用线程的环境里,反而可能造成更大的混乱。所以我的建议是:默认不要动这个策略,真有异步需求时显式传递上下文,后文会给方案。
2.2 上下文的“挂上”和“取下”是安全问题
安全上下文是有生命周期的。一个请求进来,框架会从存储里加载当前上下文并挂到 ThreadLocal 上;请求处理完成,必须把它从 ThreadLocal 上清掉。
为什么必须清?因为 Servlet 容器不保证一个线程只服务一个请求。Tomcat 的工作线程池会把线程反复复用,如果上一个请求的用户上下文没清,下一个请求的前半段可能看到上一个用户的信息,这是比 404 严重得多的越权漏洞。
这个“挂上/取下”的动作,在旧版本里由SecurityContextPersistenceFilter完成,它默认结合HttpSessionSecurityContextRepository,会在请求结束时把上下文写回 Session。从 Spring Security 6 开始,默认换成SecurityContextHolderFilter,结合RequestAttributeSecurityContextRepository,只负责加载和清理,不再主动写 Session。
旧版:SecurityContextPersistenceFilter + HttpSessionSecurityContextRepository(写回 Session) 新版:SecurityContextHolderFilter + RequestAttributeSecurityContextRepository(仅请求内)这个变化对前后端分离项目很友好。无状态环境下,上下文本来就应该是一次请求内的临时变量,每次请求都从 Token 重建,用完即焚。新版默认行为更贴近这种场景。如果项目确实还需要 Session 保存上下文,可以在配置里显式指定:
http.securityContext(ctx -> ctx.securityContextRepository(new HttpSessionSecurityContextRepository()));改配置前先问自己一句:你的接口到底需不需要跨请求保留登录状态?如果每次请求都会带 Token,那就不需要写 Session,保持新版默认反而更安全。
3. 我踩过的三个坑位:上下文丢失、重复认证、匿名身份误判
接下来这部分是实战里最容易出问题的三个点,我基本都在真实项目里踩过,每一次排查过程都很痛苦,但搞清楚之后又觉得当初应该早点看源码。
3.1 @Async 与 CompletableFuture 中的上下文丢失
先描述现象:Controller 里调用一个@Async方法,方法内部取SecurityContextHolder.getContext().getAuthentication(),结果居然是 null。
原因很直接:异步方法跑在另一个线程,ThreadLocal 不跨线程。你在这个线程里存的东西,别的线程自然看不到。
网上很多帖子会告诉你把策略改成MODE_INHERITABLETHREADLOCAL,我在 2.1 里也提到过,这个方案在线程池场景下并不可靠。真正稳妥的做法是显式传递上下文。Spring Security 官方其实给了一套并发包装类,包括DelegatingSecurityContextRunnable、DelegatingSecurityContextCallable、DelegatingSecurityContextExecutor,作用是在新线程执行前把当前上下文放进去,执行完再清理。
如果你用的是ThreadPoolTaskExecutor,更优雅的做法是自定义TaskDecorator,让每个任务提交时自动带上调用线程的上下文:
@Configuration public class AsyncSecurityContextConfig { @Bean public AsyncTaskExecutor applicationTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setTaskDecorator(task -> { SecurityContext context = SecurityContextHolder.getContext(); return () -> { try { SecurityContextHolder.setContext(context); task.run(); } finally { SecurityContextHolder.clearContext(); } }; }); return executor; } }这里有个细节容易被忽略:包装时取上下文,要在提交任务的线程里取,也就是在执行器调用decorate()时取。如果你放进Runnable内部再取,执行的线程和提交线程已经不一样了。
CompletableFuture 也有类似问题。supplyAsync(supplier, executor)里如果用了线程池,执行线程拿到的是空的 SecurityContextHolder,这时候同样建议在任务开头手动setContext,或者干脆把上下文作为参数传到任务里,避免隐式依赖。
3.2 自定义认证过滤器被重复注册
很多人自定义 JWT 过滤器时,会顺手在类上加一个@Component,然后在 SecurityFilterChain 里又手动加一遍:
http.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);结果就是同样的过滤器被注册到两条链路里:一条是 Servlet 容器自动发现并注册的,另一条是你手动加进FilterChainProxy的。如果这个过滤器继承的是普通Filter,一个请求会执行两遍;如果继承的是OncePerRequestFilter,因为类名相同,第二次会被它的alreadyFilteredAttribute机制跳过,表现成“不执行”或“执行顺序不对”,非常迷惑。
解决方案很简单:二选一。既然要在 Security 链路里控制顺序,就应该只在这里注册,不要再让它被容器自动扫描。最干净的做法是在 SecurityConfig 里用@Bean构建过滤器,而不在过滤器类上放@Component:
@Bean JwtAuthFilter jwtAuthFilter() { return new JwtAuthFilter(jwtUtil); }如果因为某种原因已经用了@Component,可以通过FilterRegistrationBean把它从容器注册中排除掉。排查这种问题最快的办法是发起一次请求,看调用栈里同一个过滤器类出现了几次,能立即确认注册路径。
3.3 匿名身份判断不能只靠字符串比较
Spring Security 在没有认证信息的请求里,会由AnonymousAuthenticationFilter自动塞一个AnonymousAuthenticationToken到上下文里。这个 token 的isAuthenticated()返回 true,principal 默认是字符串 "anonymousUser"。
这就让很多判断“当前用户是否登录”的代码翻车。像这样写:
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null && authentication.isAuthenticated()) { // 以为这里一定是登录用户 }实际效果是匿名用户也能进来,因为他持有的 AnonymousAuthenticationToken 恰好就是 authenticated 状态。还有人写"anonymousUser".equals(authentication.getPrincipal()),这在默认配置下能用,但一旦有人自定义了匿名主身份,或者换了认证方式,字符串就对不上了。
更可靠的写法是判断类型:
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null && authentication.isAuthenticated() && !(authentication instanceof AnonymousAuthenticationToken)) { // 真正登录的用户 }也可以借助专业的工具类AuthenticationTrustResolver:
AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl(); boolean anonymous = trustResolver.isAnonymous(authentication);这类判断通常出现在“当前用户可选填”或“未登录也能浏览部分内容”的场景里,写错了不是炸在登录,而是炸在权限放行上。
4. 无状态 JWT 认证的完整落地:从 Token 到 SecurityContext 再到接口鉴权
讲完基础机制,我放一个完整的前后端分离 JWT 认证落地例子。这一段不是纸上谈兵,是我在实际项目里验证过的一套最小实现,覆盖了配置、过滤器、异常处理三个关键部分。
4.1 SecurityFilterChain 的现代写法
Spring Security 6 以后,推荐用 Lambda DSL 配置,配置逻辑读起来是线性的:
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthFilter jwtAuthFilter) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login", "/api/auth/refresh", "/error").permitAll() .anyRequest().authenticated()) .exceptionHandling(eh -> eh .authenticationEntryPoint((request, response, e) -> writeJson(response, 401, "未登录或 token 无效")) .accessDeniedHandler((request, response, e) -> writeJson(response, 403, "没有权限"))) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }几个容易忽略的点:
csrf在无状态场景必须关闭。JWT 在请求头里,不依赖 Cookie,CSRF 防御没意义,反而会在测试时给你一堆 403。SessionCreationPolicy.STATELESS告诉框架不要创建 HttpSession,这是无状态的关键。- 登录接口要
permitAll,否则你连换取 token 的地方都进不去。 addFilterBefore把自定义 JWT 过滤器放在UsernamePasswordAuthenticationFilter前面,避免表单登录过滤器先插一手。
4.2 JWT 过滤器的核心逻辑
过滤器的任务很简单:从请求头拿 Token,校验通过后把用户信息构建成一个已认证的Authentication,塞进SecurityContextHolder。之后整个请求链路都能通过getAuthentication()拿到用户。
@Component public class JwtAuthFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final UserDetailsService userDetailsService; public JwtAuthFilter(JwtUtil jwtUtil, UserDetailsService userDetailsService) { this.jwtUtil = jwtUtil; this.userDetailsService = userDetailsService; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7); try { String username = jwtUtil.getUsername(token); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = userDetailsService.loadUserByUsername(username); if (jwtUtil.validateToken(token, userDetails)) { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails( new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } catch (JwtException | IllegalArgumentException e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"invalid token\"}"); return; } } chain.doFilter(request, response); } }这段代码有几个细节值得解释:
- 每次请求都从 Token 重建
Authentication,没有跨请求缓存依赖。 userDetails.getAuthorities()传给了已认证的 token,后续方法级安全@PreAuthorize判断权限全靠它。setDetails()会把当前请求 IP、SessionId 等信息挂到 Authentication 上,方便审计。- Token 解析失败时直接返回 401,而不是放行让后面的逻辑兜底,避免异常被吞掉后继续访问业务接口。
4.3 无状态场景下,安全上下文为什么不需要跨请求保存
很多人看到SecurityContextHolder.getContext().setAuthentication()后,会习惯性地想:要不要把上下文存到类似 Session 的地方,不然下一个请求丢了怎么办?
这是个关键认知转变。无状态认证下,上下文压根不需要跨请求保留。每个请求进来时,SecurityContextHolderFilter会创建一个空的或从 Repository 加载的上下文;你的 JWT 过滤器拿到 Token 后重建用户身份;请求结束,SecurityContextHolderFilter在 finally 里调用clearContext(),把 ThreadLocal 清干净。
下次请求再进来,重复这个过程。身份信息的持久化靠的是 Token 本身,而不是任何服务端会话。Spring Security 6 默认的RequestAttributeSecurityContextRepository就是这个思路,它的作用域只到单次请求,不写 Session。
如果你在无状态下还去配置HttpSessionSecurityContextRepository,反而会让事情变复杂:Session 需要考虑过期、并发会话控制,还会让前后端分离架构重新背上状态负担。
4.4 从 SecurityContext 到接口鉴权
过滤器设置完上下文后,后续的授权逻辑统一从SecurityContextHolder里取数据。Spring Security 的授权过滤器会遍历FilterSecurityInterceptor(6.x 里是AuthorizationFilter)拿到当前请求匹配的授权规则,再根据上下文里的 Authentication 做权限比对。
这意味着接口鉴权不需要你在 Controller 里写一堆 if else。只要在 SecurityFilterChain 里配置好规则,或者在方法上加上@PreAuthorize,框架就会自己从上下文取身份做判断。你前面所有的认证工作,最后都是为了让这个上下文里的 Authentication 足够“干净、可信”。
这也是为什么我会建议把认证过滤器和鉴权配置分开写。认证只负责“你是谁”,鉴权只负责“你能干什么”,两者通过 SecurityContext 关联,职责切分清楚,项目复杂起来才好维护。
5. 再往前一步:多 FilterChain 与方法级安全中上下文如何参与决策
项目做到中后期,往往不会只有一个 SecurityFilterChain。前后端 API 要用无状态 JWT,管理后台要走表单登录,第三方接口回调可能还要 permitAll。Spring Security 支持定义多个SecurityFilterChainBean,按@Order顺序匹配。
5.1 多 FilterChain 的匹配顺序和配置
看一个常见配置:
@Bean @Order(1) SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception { http .securityMatcher("/api/**") .csrf(AbstractHttpConfigurer::disable) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean @Order(2) SecurityFilterChain adminSecurityFilterChain(HttpSecurity http) throws Exception { http .securityMatcher("/admin/**") .formLogin(form -> form.loginPage("/admin/login").permitAll()) .authorizeHttpRequests(auth -> auth.anyRequest().hasRole("ADMIN")); return http.build(); }注意两个关键点:
securityMatcher("/api/**")决定了这条链只处理 API 路径。如果第一条链写了anyRequest().authenticated()却没有限定路径,它会把所有请求吞掉,后面的 chain 形同虚设。- 每条链是独立的过滤序列,每条链里可以有不同的认证过滤器、异常处理器。JWT 过滤器只加到 API 链里,管理后台链就不用。
Spring Security 匹配链时按@Order从小到大找,命中就停止。这个机制会让上下文策略也按链分开:API 链无状态、管理链走表单 Session,互不干扰。
5.2 @EnableMethodSecurity 与上下文的关系
如果你的权限不只到 URL 级别,还要精确到某个按钮、某个方法,可以在配置类上加@EnableMethodSecurity:
@Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { }然后在 Service 方法上直接声明权限:
@Service public class OrderService { @PreAuthorize("hasRole('ADMIN')") public void deleteOrder(Long orderId) { // 只有 ADMIN 能进来 } @PreAuthorize("hasPermission(#orderId, 'Order', 'update')") public void updateOrder(Long orderId) { // 结合 PermissionEvaluator 做数据级权限 } }方法级安全不重新认证,它在方法执行前通过 AOP 拦截器从SecurityContextHolder取出当前 Authentication,再交给AuthorizationManager判断。换句话说,如果前面认证流程没把正确的 roles/authorities 塞进 Authentication,这里不管你注解写得多严密,照样放行或拒绝。
这也是我在 1.2 里强调必须传 authorities 的原因。很多时候用户能登录,但访问任何@PreAuthorize方法都被拒绝,问题就出在认证成功时构造的 Authentication 少了权限列表。
5.3 自定义权限决策的入口
如果你做过审批流、数据隔离这类需求,会发现hasRole、hasAuthority对于“只能操作自己创建的订单”这种场景无能为力。Spring Security 预留了PermissionEvaluator接口,配合方法级安全里的hasPermission表达式使用。
实现接口后注册到上下文里,然后在配置类中启动方法安全:
@EnableMethodSecurity public class MethodSecurityConfig { @Bean AuthorizationManager<MethodInvocation> authorizationManager() { // 使用 PermissionEvaluator 相关的实现 } }这个阶段你其实已经越过“身份验证”的范畴,进入了授权领域。但它的前提没有变:所有决策都围绕 SecurityContext 里的 Authentication 展开。我在实际项目里见过很多自行用 ThreadLocal 存储“当前用户 ID”的做法,结果一到网关、异步、RPC 就出各种幺蛾子。与其再造一套轮子,不如先把 Spring Security 这条上下文机制彻底吃透。
如果你也在接类似改造,建议从最小的接口链路开始试,先把登录返回 token 调试通,再逐步把方法级权限加上。每次只动一个 SecurityFilterChain,出问题也好回退。上下文这块,宁可多写几行显式传递的代码,也不要赌框架默认策略能满足所有异步场景。