恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Spring Security 6过滤器链与认证授权实战:从迁移到配置避坑指南
首页
资讯中心
/
Spring Security 6过滤器链与认证授权实战:从迁移到配置避坑指南
Spring Security 6过滤器链与认证授权实战:从迁移到配置避坑指南
发布时间:2026/9/8 16:02:06
接手过几个 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核心就是SecurityFilterChainConfiguration 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 里的scoperead会被映射成名为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一个提取自定义角色 claimBean 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 - { CollectionGrantedAuthority 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-SecurityHTTPS 环境下强制浏览器使用 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 能访问管理接口”。这批测试不只是在改代码的时候保护你它在排查环境问题时能非常清楚地告诉你“是配置没生效还是测试方案本身不对”。安全规则这种最容易出隐蔽问题的地方越早把行为固化成测试越好。