有段时间我接手一个老项目登录用的是 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构造的是一个未认证的UsernamePasswordAuthenticationTokenUsernamePasswordAuthenticationToken token new UsernamePasswordAuthenticationToken(username, password);这个 token 的isAuthenticated()是 falseprincipal 是字符串形式的用户名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而它的底层是一个ThreadLocalSecurityContext。你可以把 ThreadLocal 理解成每个线程自带的储物柜。每个线程往里放东西只能自己取到其他线程摸不着。这样设计的好处很直接同一个线程里的后续过滤器、Controller、Service 都能通过SecurityContextHolder.getContext()拿到当前用户不同线程之间天然隔离不会互相干扰。但隔离也带来一个副作用子线程拿不到父线程的上下文。这个问题在异步场景里会频繁引爆我放到下一节专门讲。这里先搞清楚框架默认怎么处理。2.1 三种存储策略和它们的适用场景SecurityContextHolder提供了三种策略通过系统属性spring.security.strategy或代码设置策略行为典型场景MODE_THREADLOCAL默认每个线程只能访问自己的上下文普通同步请求绝大多数项目MODE_INHERITABLETHREADLOCAL子线程创建时继承父线程的上下文需要异步获取用户信息且线程池可预期回收MODE_GLOBAL整个 JVM 共享一个上下文桌面程序、消息监听等非 Web 场景基本不建议 Web 项目用设置方式很简单java -Dspring.security.strategyMODE_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构建过滤器而不在过滤器类上放ComponentBean JwtAuthFilter jwtAuthFilter() { return new JwtAuthFilter(jwtUtil); }如果因为某种原因已经用了Component可以通过FilterRegistrationBean把它从容器注册中排除掉。排查这种问题最快的办法是发起一次请求看调用栈里同一个过滤器类出现了几次能立即确认注册路径。3.3 匿名身份判断不能只靠字符串比较Spring Security 在没有认证信息的请求里会由AnonymousAuthenticationFilter自动塞一个AnonymousAuthenticationToken到上下文里。这个 token 的isAuthenticated()返回 trueprincipal 默认是字符串 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)) { // 真正登录的用户 }也可以借助专业的工具类AuthenticationTrustResolverAuthenticationTrustResolver 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 在请求头里不依赖 CookieCSRF 防御没意义反而会在测试时给你一堆 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;charsetUTF-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 的授权过滤器会遍历FilterSecurityInterceptor6.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 级别还要精确到某个按钮、某个方法可以在配置类上加EnableMethodSecurityConfiguration 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 AuthorizationManagerMethodInvocation authorizationManager() { // 使用 PermissionEvaluator 相关的实现 } }这个阶段你其实已经越过“身份验证”的范畴进入了授权领域。但它的前提没有变所有决策都围绕 SecurityContext 里的 Authentication 展开。我在实际项目里见过很多自行用 ThreadLocal 存储“当前用户 ID”的做法结果一到网关、异步、RPC 就出各种幺蛾子。与其再造一套轮子不如先把 Spring Security 这条上下文机制彻底吃透。如果你也在接类似改造建议从最小的接口链路开始试先把登录返回 token 调试通再逐步把方法级权限加上。每次只动一个 SecurityFilterChain出问题也好回退。上下文这块宁可多写几行显式传递的代码也不要赌框架默认策略能满足所有异步场景。