接手过几个 Spring Security 项目之后,我最大的感受是:大部分开发不是被 API 难住的,而是被“链路”和“默认行为”绕晕的。你只是加了一个spring-boot-starter-security依赖,就发现所有请求都变了脸色,静态资源访问不了,POST 请求莫名其妙 403,想写一个认证过滤器又不知道往哪里塞。这篇东西我尽量把 Spring Security 的骨架讲透,从当前 Spring Boot 3 + Spring Security 6 的实际配置方式出发,把认证、过滤器链、授权表达式、OAuth2 资源服务器这些容易踩坑的地方一次捋清。适合刚接触安全框架、或者从 5.x 迁移到 6.x 后遇到各种异常的同学参考。
1. 先别急着写配置:安全框架到底挂在请求链的哪个位置
很多初学者上来就搜“Spring Security 登录配置”,抄一段authorizeHttpRequests就以为完事了。结果遇到的需求一变——比如要支持 App 的 token 认证、要对接外部 OAuth2、要给某个内部接口做自定义权限校验——配置立刻炸锅。原因就在于没有先建立“请求是怎么穿过层层过滤器”的图景。
Spring Security 在 Servlet 技术栈里的核心模型,是一组过滤器组成的链。它并不是像 AOP 那样在一个方法边界做拦截,而是以 Servlet Filter 的方式,位于整个 HTTP 请求生命周期中,从请求进入容器开始,一直覆盖到 Controller 执行前。所以一个请求如果连不上过滤器链,Spring Security 对它是完全不生效的;反过来,只要这条链上的任何一个过滤器判定请求不安全,后面的业务代码根本不会执行。
这里有一个非常关键的理解:Spring Security 不是靠一个注解或者一个拦截器完成的,而是靠一条精心排序的过滤器链协作完成的。比如常见的UsernamePasswordAuthenticationFilter负责处理表单登录请求,BasicAuthenticationFilter处理 HTTP Basic 认证,AuthorizationFilter负责在请求进入 Controller 前做授权判断,ExceptionTranslationFilter负责把认证失败和授权失败翻译成对应的 401 或 403 响应。不同的过滤器各管一段,像流水线上的工人。
也因为这个原因,你在配置里看到的authorizeHttpRequests、formLogin、oauth2ResourceServer这些 DSL 方法,本质上不是在写某个独立功能,而是在告诉 Spring Security:“我这个应用需要把哪些过滤器放进链里,以及这些过滤器应该表现出什么行为。”
我见过不少团队,在网关层或者多个服务里同时引入 Spring Security,然后每个服务都写一套自己的过滤器,结果链路串起来之后请求被重复认证、Response Header 被覆盖、CORS 配置互相打架。搞清楚过滤器链的参与者和顺序,就等于拿到了排查这些问题的地图。
2. 从被淘汰的 WebSecurityConfigurerAdapter 说起:新配置模型到底新在哪
如果你看过比较早的教程,大概率见过类似这种写法:
@Configuration @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/public/**").permitAll() .anyRequest().authenticated() .and() .formLogin(); } }Spring Security 5.7 之后,官方明确标记WebSecurityConfigurerAdapter为过时,并在 6.x 中彻底移除了这个基类。直接原因很现实:继承式的配置让一个应用只能有一份全局配置,想要多个过滤器链、多套规则,就必须用技巧去 hack;而且大量and()串联的写法,代码一长就根本没法读,谁改谁知道。
新的配置模型是“组件装配”式的。你把真正的可配置对象,直接声明为 Spring 容器里的 Bean,核心就是SecurityFilterChain:
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize .requestMatchers("/login", "/css/**", "/js/**", "/images/**").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .formLogin(Customizer.withDefaults()) .httpBasic(Customizer.withDefaults()); return http.build(); } }注意几个容易出错的地方:
antMatchers已经进入历史,新版本要使用requestMatchers。authorizeRequests换成了authorizeHttpRequests。这不仅仅是一次改名字,内部参与授权的过滤器也从基于FilterSecurityInterceptor的模型,换成了更直观的AuthorizationFilter模型。如果你用老的 API 写配置,Spring Boot 3 启动阶段就可能直接报方法找不到,或者提示你去看迁移文档。
@EnableWebSecurity这个注解在这个模型里还重要吗?重要,但它的作用不再是让你继承某个基类,而是启用 Spring Security 的 Web 安全默认配置。如果你的项目里已经有了一个SecurityFilterChainBean,其实大部分时候可以不写这个注解,Spring Boot 自动配置会兜底;但为了明确和稳妥,建议保留。
另一个新手会踩的坑是“多个 SecurityFilterChain”的配置方式。在实际项目中,同一套应用里常常有管理端、用户端、内部 API 三种完全不同的规则。比如管理端需要表单登录,用户端走 Session,内部 API 完全无状态用 JWT。这种情况下,你就可以注册多个SecurityFilterChainBean,并且用@Order控制顺序:
@Bean @Order(1) SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception { http .securityMatcher("/api/**") .authorizeHttpRequests(authorize -> authorize .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); return http.build(); } @Bean @Order(2) SecurityFilterChain webSecurityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize .requestMatchers("/login").permitAll() .anyRequest().authenticated() ) .formLogin(Customizer.withDefaults()); return http.build(); }关键点是第一个链声明了securityMatcher("/api/**"),只有匹配这个前缀的请求才会进入这条链;匹配不到的请求继续往后面的链上找。Spring Security 会从Order最小的 SecurityFilterChain 开始尝试,直到找到第一个能处理当前请求的链。这个排序逻辑非常像 Router 的路由匹配——规则越具体、优先级越高的链,越要往前放。
配置模型变了之后,最大的好处是规则变成了“一个方法返回一个完整链路”,每个链路彼此独立。排查问题的时候,你可以直接盯住对应的SecurityFilterChain,不用在整个继承体系里翻来翻去。
3. 过滤器链是如何注册到容器里的:FilterChainProxy 与 DelegatingFilterProxy 的分工
文章开头我提到“请求要穿过过滤器链”,那 Spring Security 的过滤器链到底是怎么进到 Servlet 容器的?这是很多开发者知识盲区,也是被热搜词里“Spring Security filter 是如何完成注册的”反复提到的问题。
先看一个底层关系:Servlet 容器只知道普通Filter,它并不知道 Spring 容器里的 Bean 是什么。而 Spring Security 的整个过滤器链,实际上是被一个名为FilterChainProxy的 Servlet Filter 包装起来的。FilterChainProxy是 Spring Security 所有过滤器的总入口,它本身是一个Filter,内部又持有若干SecurityFilterChain。每个SecurityFilterChain内部又包含若干具体的Filter。
Spring Boot 自动配置做了一件很关键的事:它注册了一个DelegatingFilterProxyRegistrationBean,名字叫springSecurityFilterChain。DelegatingFilterProxy是 Spring 提供的一个桥接过滤器,它在 Servlet 容器启动时会去 Spring 容器里找名为springSecurityFilterChain的 Bean,然后把自己接收到的请求委托给那个 Bean。而这个 Bean 的真实类型,就是我们上面说的FilterChainProxy。
如果你做的不是 Spring Boot 项目,而是传统 Spring MVC 应用,就需要自己在 web.xml 或AbstractSecurityWebApplicationInitializer里注册这个 DelegatingFilterProxy,而且 filter-name 必须是springSecurityFilterChain,否则它找不到代理的 Bean。这也是 Spring Boot 项目很少关注注册过程的原因——自动配置已经把这件事做完了。
再来说FilterChainProxy内部。当请求到达时,它会遍历持有的所有 SecurityFilterChain,找到第一个匹配当前请求的链,然后把请求交给这条链上的过滤器逐个执行。这些过滤器的顺序是固定的、经过设计的,不能随意调整。Spring Security 内置了一套默认过滤器,按照顺序大致是:
SecurityContextHolderFilter或SecurityContextPersistenceFilter:负责从 SecurityContextRepository 中加载 SecurityContext 到当前线程HeaderWriterFilter:写入安全相关的响应头CorsFilter:处理跨域配置CsrfFilter:处理 CSRF Token 校验LogoutFilter:处理注销请求UsernamePasswordAuthenticationFilter:处理表单登录请求BasicAuthenticationFilter:处理 HTTP Basic 认证RequestCacheAwareFilter:保存和恢复因为登录被打断的请求AnonymousAuthenticationFilter:为没有认证信息的请求创建一个匿名 AuthenticationSessionManagementFilter:处理会话固定攻击防护和会话并发控制ExceptionTranslationFilter:捕获 Filter 链上的认证/授权异常并翻译成 HTTP 响应AuthorizationFilter:最终执行 URL 授权规则判断
正因为执行顺序如此明确,你自定义的 Filter 必须被插入到正确的位置。很多人的做法是直接把自定义 Filter 注册成普通的@Component,让它成为 Servlet 容器里的一个过滤器。这样做看似能用,实际上它的执行位置不受 Spring Security 控制,可能在FilterChainProxy之前执行,也可能在它之后,根本无法和你配置的登录认证放在同一个上下文里。
正确的做法是通过 HttpSecurity 把自定义过滤器添加到 SecurityFilterChain 中:
http .addFilterBefore(customAuthFilter, UsernamePasswordAuthenticationFilter.class) .addFilterAfter(customHeaderWriter, HeaderWriterFilter.class);插入位置的参照物,通常选择官方过滤器 Class 对象,比如:
- 想在表单登录之前做前置 token 校验,插在
UsernamePasswordAuthenticationFilter之前 - 想在授权判断前做请求包装、改写或审计,插在
AuthorizationFilter之前 - 想在异常处理前记录日志,插在
ExceptionTranslationFilter之前
还有一个很隐蔽的问题:如果自定义 Filter 本身是@Component,又同时通过addFilterBefore加进了 SecurityFilterChain,它就会被注册两次。一次是 Servlet 容器直接管理的普通过滤器,一次是FilterChainProxy内部过滤器。后者的执行没有问题,但前者的执行很容易打乱你的预期。所以我的习惯是自定义安全过滤器不标注@Component,只在配置类中通过new创建并注册,让它只存在于安全链中,避免重复注册。
4. 认证链路拆解:从表单登录到 SecurityContext 里存了什么
过滤器链是整个安全框架的骨架,而认证就是安全框架的“心脏”。我建议每个使用 Spring Security 的开发者都至少完整追踪一次登录请求。这样遇到任何自定义认证需求,你才知道该在哪一环动手。
我们先以最常见的表单登录为例。假设前端提交了一个 POST 请求到/login,请求体里带上了username和password。这个请求首先会被UsernamePasswordAuthenticationFilter捕获。过滤器把用户名和密码提取出来,构造一个尚未认证的UsernamePasswordAuthenticationToken对象。这个 Token 对象里此时只有 principal(通常就是用户名)和 credentials(密码),并没有权限信息。
接着,过滤器调用AuthenticationManager.authenticate(token)。AuthenticationManager是认证的总协调者,它的默认实现是ProviderManager。ProviderManager内部维护了一组AuthenticationProvider,它会逐个尝试这些 Provider,看谁有能力处理当前类型的 Token。DaoAuthenticationProvider就是专门处理UsernamePasswordAuthenticationToken的 Provider。
DaoAuthenticationProvider做的事可以拆成几步。它会调用你在容器里配置的UserDetailsService,根据用户名加载用户信息。如果你配置的UserDetailsService返回一个UserDetails对象,它就拿着这个对象里的密码哈希,和你传入的明文密码做比对。比对逻辑不是简单的equals,而是交给PasswordEncoder的matches方法。这里有个重要细节:哪怕用户不存在,DaoAuthenticationProvider也会故意执行一次密码比对来消耗时间,防止攻击者通过响应时间差判断用户名是否存在。
如果密码比对失败,DaoAuthenticationProvider会抛出BadCredentialsException。如果成功,它会构造一个新的、已经认证的UsernamePasswordAuthenticationToken,里面包含完整的用户信息(principal)、凭证(通常被清空)和权限列表。这个 Token 返回给AuthenticationManager,再返回给UsernamePasswordAuthenticationFilter。
接下来的动作很关键:UsernamePasswordAuthenticationFilter会把认证成功的 Authentication 放到SecurityContext中,而SecurityContext会被设置到SecurityContextHolder。在默认的线程模型下,SecurityContextHolder使用ThreadLocal存储,也就是说,当前请求的处理线程在后续任意位置都能通过SecurityContextHolder.getContext().getAuthentication()拿到当前登录用户。
这里顺便解释一个高频疑问:为什么SecurityContextHolder.getContext().getAuthentication()有时是AnonymousAuthenticationToken?因为在过滤器链中,如果前面的认证过滤器都没有设置认证信息,AnonymousAuthenticationFilter会创建一个匿名的 Authentication 放入 SecurityContext。所以“没有登录”不等于 SecurityContext 为空,它里面往往是一个 anonymous 认证对象。业务代码如果忘了判断isAuthenticated(),只判断“authentication 不为 null”,就会把匿名用户当成有效用户放行,这是坑。
更底层的保存逻辑在 Servlet 环境中是这样的:Spring Security 6 默认使用SecurityContextHolderFilter,它会在请求开始时从配置的SecurityContextRepository中读取 SecurityContext 并放入SecurityContextHolder,在请求结束时清理ThreadLocal。默认的SecurityContextRepository是HttpSessionSecurityContextRepository,所以登录成功后,你会看到 Session 里多了一个SPRING_SECURITY_CONTEXT属性。
如果你写的不是基于 Session 的应用,不想让认证状态落 Session,可以在配置里声明SessionCreationPolicy.STATELESS,同时提供自己的 SecurityContextRepository,或者让每个请求都携带 token 并在自定义过滤器中重建认证信息。这也是 JWT 接口服务最常见的做法。
我在项目里强烈建议一个调试习惯:第一次接入 Spring Security 时,不要急着调自定义认证,先用官方默认表单登录跑通一次,然后在任意 Controller 里打印:
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); System.out.println(authentication.getClass()); System.out.println(authentication.getAuthorities()); System.out.println(authentication.isAuthenticated());你会立刻理解 principal、credentials、authorities 这三个概念在运行时的真实形态。理解了这一步,后面看UserDetailsService、AuthenticationProvider、JWT 解析等代码时,基本不会再迷糊。
5. 新版 OAuth2 资源服务器里没有 hasScope 了?给你讲清楚 Scope 映射模型
这些年 OAuth2 相关的问题,几乎成了 Spring Security 讨论区里最热闹的话题。尤其是从 Spring Boot 2.x 升级到 Spring Boot 3 的团队,经常被同样一个问题卡住:原来代码里的hasScope('read'),升级之后编译不过去了,是不是新版本把hasScope方法删了?
要回答这个问题,得先理清历史。很多老项目用的是早期 Spring Security OAuth2 项目,那时配置资源服务器时,会在http.authorizeRequests()里写类似这样的 SpEL 表达式:
.authorizeRequests() .antMatchers("/api/**") .access("#oauth2.hasScope('read')")这里的#oauth2.hasScope(...)并不是 Spring Security 核心框架提供的内置表达式,而是当时独立的 Spring Security OAuth2 库通过自定义 ExpressionHandler 注入的一个扩展方法。所以它依赖的是旧库中注册的OAuth2WebSecurityExpressionHandler。后来 OAuth2 相关支持逐步收编进 Spring Security 主仓库,资源服务器的授权模型发生了根本性变化,这套基于@EnableResourceServer和#oauth2的旧机制被移除了。
现在的 OAuth2 资源服务器,默认把 JWT 里的scope或scp声明转换为GrantedAuthority,并且加上了SCOPE_前缀。也就是说,token 里的scope=read会被映射成名为SCOPE_read的权限。因此,你不再需要一个专门叫hasScope的方法,直接用标准的权限判断方法即可:
http .authorizeHttpRequests(authorize -> authorize .requestMatchers("/api/messages/**").hasAuthority("SCOPE_message:read") .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));在方法级安全里也一样,用@PreAuthorize("hasAuthority('SCOPE_read')")取代旧写法。如果你原来写的是hasAnyScope('read', 'write'),对应迁移成hasAnyAuthority('SCOPE_read', 'SCOPE_write')。
那么,如果 JWT 里的字段不是标准的scope或scp,而是自定义的roles、permissions之类的 claim,该怎么办?我举一个最常见的需求:资源服务器既要判断 JWT 的 scope,又要判断用户角色。默认的JwtAuthenticationConverter只处理scope相关 claim,所以你需要自己扩展。
实现方式是这样的:提供一个JwtAuthenticationConverterBean,给它配置两个JwtGrantedAuthoritiesConverter,一个提取 scope,一个提取自定义角色 claim:
@Bean JwtAuthenticationConverter jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter scopeConverter = new JwtGrantedAuthoritiesConverter(); scopeConverter.setAuthorityPrefix("SCOPE_"); JwtGrantedAuthoritiesConverter roleConverter = new JwtGrantedAuthoritiesConverter(); roleConverter.setClaimName("roles"); roleConverter.setAuthorityPrefix("ROLE_"); JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); converter.setJwtGrantedAuthoritiesConverter( jwt -> { Collection<GrantedAuthority> authorities = new ArrayList<>(); authorities.addAll(scopeConverter.convert(jwt)); authorities.addAll(roleConverter.convert(jwt)); return authorities; } ); return converter; }然后在HttpSecurity配置里挂上这个 Bean:
.oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())) );如果你在网上一搜发现“Spring Boot 3 整合 Spring Security OAuth2”的代码片段千奇百怪,我建议你认准一个主线:spring-boot-starter-oauth2-resource-server负责把你当作资源服务器来用,spring-boot-starter-oauth2-client负责让你去对接外部授权服务器完成登录授权。两套 starter 解决的问题不同,别混。如果你需要的是“自己作为授权服务器”,那还得再引入对应的遗留支持或使用其他身份认证产品,那不是同一个配置路径。
还有一个常见疑问是:为什么我配置了资源服务器,请求 /api/还是能被匿名访问?**
大概率是你配置的顺序不对。注意,在authorizeHttpRequests内,授权规则是先匹配先生效。如果第一条规则是anyRequest().permitAll(),那么后面的oauth2ResourceServer只负责解析 token,不会拦截任何请求,因为授权层的规则已经把请求全部放行了。授权和认证在这里是两回事:oauth2ResourceServer的作用是“如果请求头里有 token,我就尝试解析并建立认证”,但它不负责决定一个请求是否必须要有认证。只有authorizeHttpRequests里的规则才决定“哪些请求必须登录、必须有什么权限”。所以务必让受限接口的规则出现在前面,而anyRequest().authenticated()这类兜底要放在最后。
6. CSRF、安全响应头、Session 策略:那些“默认就在生效”的隐形规则
Spring Security 默认提供的安全能力远比你想象的要多。它不只是拦住未认证请求,还会默默在响应头里注入一堆安全属性。这些默认值对生产环境是友好的,但在开发调试时常常制造困扰。我遇到过最典型的一种情况:本地连 H2 数据库的控制台,页面能打开,但点任何 SQL 操作都返回 403,控制台也看不到任何报错。排查到最后,发现是 H2 Console 的 iframe 被X-Frame-Options挡住了,POST 请求又被 CSRF 拦截了。
先讲 CSRF。从 Spring Security 4 开始,基于 Cookie 的浏览器请求默认启用 CSRF 防护。这是为了防止跨站请求伪造:攻击者诱导已登录用户访问一个恶意页面,该页面自动向目标站点发起 POST 请求,由于浏览器会自动携带 Cookie,服务端无法分辨这个请求是不是用户本意。Spring Security 的CsrfFilter会要求每个修改状态的请求(POST、PUT、DELETE、PATCH)携带一个合法的 CSRF Token。对于传统的服务端渲染表单应用,Token 会被嵌入页面或 Cookie 中;对于前后端分离应用,前端需要在请求头里带上从后端获取的 Token。
但现在的很多接口服务,认证方式已经是 JWT,前端把 token 放在 Authorization Header,而不是依赖 Cookie。这种情况下 CSRF 攻击的风险大大降低——因为第三方站点没法在请求头里伪造你的 JWT。所以很多开发团队会关闭 CSRF。需要明确的是,关闭 CSRF 的前提是你确认当前应用不使用基于 Cookie 的自动携带机制。如果你仍然在用 Session 登录,又关闭 CSRF,那就要自己承担风险。
一个相对合适的关闭方式是限定范围或在文档中留痕:
http.csrf(csrf -> csrf.disable());但我不建议每个项目都无脑抄这一行。尤其是管理后台这类产品,基于 Session 登录,请保留默认的 CSRF 防护,否则一个管理员的账号很容易被外部页面“代为操作”。
响应头方面,Spring Security 在默认情况下会通过HeaderWriterFilter给响应添加不少安全头:
X-Content-Type-Options: nosniff:禁止浏览器猜测 MIME 类型X-Frame-Options: DENY:禁止页面被放到 iframe 里Cache-Control等与缓存相关:确保包含敏感信息的响应不被浏览器缓存Strict-Transport-Security(HTTPS 环境下):强制浏览器使用 HTTPS
这些默认值大多合理。但X-Frame-Options: DENY会阻止任何页面嵌入自己的应用,如果你要嵌入一个可视化报表、H2 Console,或者被别的系统 iframe 嵌入,就得放开 frame 选项:
http.headers(headers -> headers .frameOptions(frame -> frame.sameOrigin()) );sameOrigin表示只允许同源页面把当前页面嵌入 iframe。比直接全部 allow 安全很多。
Session 策略也是一个容易忽略的地方。表单登录应用默认会创建 Session 并把 SecurityContext 存进 Session。但如果你做的是无状态 API,并托管在多个实例后面,Session 会带来扩容复杂和粘滞会话问题。接口服务通常建议设置:
http.sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) );这个配置会让 Spring Security 不再创建 Session,也不会从 Session 中读取 SecurityContext。对于纯 JWT 接口来说,这几乎是标配。但要注意:如果某个过滤器或业务代码仍然依赖 Session,比如使用HttpSessionRequestCache、验证码存 Session,那么STATELESS会导致这些功能失效。
这里有一个我自己总结的判断流程:先问自己三个问题——我的登录凭证放在哪里(Cookie 还是 Header)?我需要服务端保存会话状态吗(有状态还是无状态)?我的客户端是不是只有自家控制的 SPA/App(能不能安全关闭 CSRF)?把这三个问题回答清楚,再写 CSRF 和 Session 配置,比照着网上的配置抄一遍靠谱得多。
7. 高发“配置失效”场景排查:permitAll 和认证过滤器之间到底什么关系
Spring Security 的配置是出了名的“看起来设置对了,实际没效果”。下面几个场景,是我在真实项目和内部分享中反复遇到的,值得单独说一遍。
第一个高频问题是:我把登录页和静态资源都设成了permitAll,为什么访问静态资源还是跳到登录页?
先确认一点:permitAll不是“绕过安全处理”,它只是授权规则中的“所有人都允许访问”。请求仍然会经过过滤器链上的认证过滤器。如果资源存在于 Spring Security 的默认保护路径范围内、但你又没有配置任何规则能匹配到它,那最终会落到anyRequest()上。如果你在规则里写了anyRequest().authenticated(),那么一切没被前面规则匹配到的 URL 都需要登录;而静态资源如果因为路径前缀写错没能被更前面的规则捕获,自然会被弹到登录页。所以排查静态资源问题时,优先检查requestMatchers的路径是否与实际请求完全一致,比如是否有项目 context path。
第二个经典坑是 controller 已经正确登录了,但调用内部接口时传入的Authentication为 null。原因往往是过滤器链的上下文传递和线程切换出了问题。如果接口里启用了异步处理,默认情况下SecurityContextHolder的 ThreadLocal 不会自动传递到子线程。需要配置DelegatingSecurityContextRunnable/DelegatingSecurityContextExecutor显式把 SecurityContext 传递给异步任务,或者使用SecurityContextHolder设置MODE_INHERITABLETHREADLOCAL。在 WebFlux 场景下则没有 ThreadLocal 的概念,SecurityContext 是放在响应式上下文里的,两者不能套用同一种代码习惯。
第三个坑和 CSRF 高度相关:响应的状态码看起来是 403,日志里却没有 AccessDeniedException。
实际原因是CsrfFilter直接拦截了请求,根本轮不到后面的授权过滤器,异常也就没有抛到ExceptionTranslationFilter的处理范围。这时候你会看到一个干净的 403 响应,没有任何你自定义的异常处理器参与。排查方法是打开org.springframework.security的 DEBUG 或 TRACE 日志,查看CsrfFilter是否输出了Invalid CSRF token found for http://...这行关键信息。日志一开,立刻定位。
第四个坑是自定义 token 校验过滤器已经执行了,认证信息也确实写进了 SecurityContext,但最终 Controller 里还是拿到匿名用户。这个时候我会先去确认自定义过滤器和SecurityContextHolderFilter的执行顺序。如果自定义过滤器在SecurityContextHolderFilter之前执行,而 SecurityContextRepository 在请求结束时又会把当前 SecurityContext 写入 Session,某些情况下会互相覆盖;如果过滤器被插在了ExceptionTranslationFilter之后,可能连授权判断都已经过了,过滤器做的事情根本没进入授权决策流程。这类问题通常无法靠盯代码一眼发现,最好结合日志中 FilterChain 的调试输出,观察过滤器到底执行到了哪一步。
Spring Security 在TRACE级别会打印请求经过的过滤器链详情。在 application.yml 里加一段配置,排查效果立竿见影:
logging: level: org.springframework.security: TRACE打开日志后,你会看到类似这样的输出:请求被哪个 SecurityFilterChain 匹配、哪些过滤器参与执行、每个过滤器执行前后 SecurityContext 的变化。这套日志是我排查 Spring Security 问题时的第一工具,比断点调试更高效,因为你能看到全链路。
8. 把这套流程跑起来:本地测试、MockMvc 与最实用的验证手段
理论讲完,最后分享一套我常用的本地验证方法。它能帮你把配置和代码的“因果链”在几分钟内验证清楚,不用反复改动代码重启。
第一步是使用 Spring Security 自带的 MockMvc 集成测试。你不需要启动真实服务器,通过@SpringBootTest加上@AutoConfigureMockMvc就能模拟完整的过滤器链执行:
@SpringBootTest @AutoConfigureMockMvc class SecurityConfigTest { @Autowired MockMvc mockMvc; @Test void givenNoAuthentication_whenAccessProtected_thenRedirectToLogin() throws Exception { mockMvc.perform(get("/admin")) .andExpect(status().is3xxRedirection()); } @Test void givenAuthenticated_whenAccessProtected_thenOk() throws Exception { mockMvc.perform(get("/admin") .with(SecurityMockMvcRequestPostProcessors.user("admin").roles("ADMIN"))) .andExpect(status().isOk()); } }注意 POST 请求测试时,如果 CSRF 是开启状态,需要加.with(csrf()),否则会被 CSRF 过滤器挡住,导致测试结果不是你想要的授权结果。我用SecurityMockMvcRequestPostProcessors.csrf()这个方法比手写请求头要省事太多。
@WithMockUser注解也非常实用。它可以模拟已经登录的用户,不用真的走一遍认证:
@Test @WithMockUser(username = "tester", roles = "USER") void givenUser_whenAccessUserEndpoint_thenOk() throws Exception { mockMvc.perform(get("/me")) .andExpect(status().isOk()); }但是要记住,@WithMockUser只是在 SecurityContext 里塞了一个伪造用户,它不会真正触发UserDetailsService和PasswordEncoder。如果你要测试密码正确性、账号锁定一类逻辑,还是要通过@WithUserDetails或者发送真实登录请求来做。
第二步是最直接的手工验证。启动项目后,用 curl 检查响应头和安全行为:
curl -i http://localhost:8080/你应该能从响应头里看到X-Content-Type-Options: nosniff之类的安全头。如果看到WWW-Authenticate头,说明默认安全机制触发,是因为你访问了一个受保护资源且未发现认证信息。再配合浏览器开发者工具的 Network 面板,看一次登录请求的跳转链路,大致能还原表单登录全流程。
关于调试日志,我建议按需开启,不要长期在生产环境把 Spring Security 调到 TRACE,因为它会打印大量请求上下文信息,对性能有一定影响,而且日志量很惊人。平时用 DEBUG 级别观察认证失败异常即可。
在真实项目里我还有一个习惯:每个关键安全规则都搭配一个集成测试用例。比如“匿名用户访问 /api/private 应该返回 401”“普通用户访问 /admin 应该返回 403”“具备 SCOPE_admin 的 token 能访问管理接口”。这批测试不只是在改代码的时候保护你,它在排查环境问题时,能非常清楚地告诉你“是配置没生效,还是测试方案本身不对”。安全规则这种最容易出隐蔽问题的地方,越早把行为固化成测试越好。