Spring Security 过滤器链定制:认证、CORS 与 CSRF 的先后顺序

Spring Security 过滤器链定制:认证、CORS 与 CSRF 的先后顺序

一、先把问题说清楚:一条请求到底经过谁

1.1 读者要完成的那件事

假设你在维护一个前后端分离的系统:前端跑在 https://app.example.test,后端 API 跑在 https://api.example.test,浏览器会先问一句"我能不能跨域调你",再真正发起带 Cookie 或带令牌的请求。最近团队加了一个自定义过滤器,用来给每个请求打上租户标识;加完之后发现三件事同时坏了:跨域预检请求返回 403、写操作报"CSRF 令牌缺失"、部分认证失败又返回 HTML 而不是 JSON。

本文的目标就是让这件事重新跑通:在 Spring Boot 4.1.x / Spring Framework 7.0.x 上,用一个明确的 SecurityFilterChain,把认证过滤器、CORS 处理、CSRF 校验按正确顺序组织起来,并讲清楚"为什么这样排"。

这里要先划定一个讨论范围。很多文章在讲过滤器顺序时,会把这些概念混着说:Servlet 容器自己的 Filter 顺序、Spring Security 内部 FilterChainProxy 里的过滤器顺序、HttpSecurity DSL 里各配置项的写法顺序、以及方法级安全注解的执行位置。这四者并不是同一个东西。本文讨论的核心是第二种:FilterChainProxy 内部那条安全过滤器链的顺序。Servlet 容器的外部 Filter 只作为"为什么自建过滤器绕过安全匹配会出事"的对照出现;HttpSecurity 的 DSL 写法顺序本身不决定运行时顺序,这一点会在第三节单独解释;方法级安全放到反例里说明其边界。

把范围收窄之后,问题就变成一个可以被验证的命题:在一条 securityMatcher 匹配到 /api/** 的链里,CORS 处理、CSRF 校验、认证、授权这四段谁先谁后,以及各自失败时输出什么。

1.2 什么是过滤器链

用大白话讲:Servlet 容器收到一个 HTTP 请求后,不会直接把请求交给你的 Controller,而是先让一串"关卡"依次检查或修改请求,这串关卡就是过滤器链(Filter Chain)。每个过滤器可以在请求进入时做点事,也可以在响应返回时做点事;它可以放行,也可以直接拒绝并把响应写完。

这件事在 Spring Security 中尤其重要:Spring Security 并不是一个"拦截器",而是把自己实现成了一条由很多过滤器组成的链,挂在 Servlet 容器的过滤器链上。你写的 HttpSecurity 配置,本质上是在描述"这条安全过滤器链里放哪些过滤器、按什么顺序放"。正式名称是 SecurityFilterChain (安全过滤器链),注册它的组件在 Spring Boot 里通常是 SecurityFilterChain 类型的 Bean 集合,由 FilterChainProxy 统一调度。

再具体一点,一个过滤器在 doFilter(request, response, chain) 里的典型结构是:

text 复制代码
进入过滤器
  |
  +-- 自己判断:可以放行吗?
  |      |
  |      +-- 不能:直接写响应(状态码 + 响应体),不再调用 chain
  |      |
  |      +-- 能:调用 chain.doFilter(request, response)
  |                 |
  |                 v
  |            后续过滤器 / Controller
  |                 |
  |                 v
  |            响应回到这里,可再做后处理
  |
  结束

这个结构决定了两个后果:第一,过滤器可以"提前结束"请求,而且结束后面的过滤器完全看不到请求;第二,放行之后,响应会沿调用栈返回,所以过滤器也可以在响应阶段追加头信息或改写内容。CORS 处理正是利用"提前结束 + 写响应头"这两个能力:预检请求它直接应答并结束;普通跨源请求它放行但补上响应头。

1.3 为什么顺序是本文的核心

因为过滤器链是串行的:前一个过滤器不放行,后面的根本看不到请求。所以"顺序"不是风格问题,而是功能问题:

  • CORS 检查如果排在认证之后,浏览器发来的、本就不带凭据的预检请求(OPTIONS)会先被认证拦住,直接失败。
  • CSRF 校验如果排在认证之前,错误信息会变成"未认证",可实际上问题出在令牌;如果排在登录处理之后,登录接口本身就不好处理。
  • 你自己加的过滤器如果放在 FilterChainProxy 之外(注册为普通 Servlet Filter),它就绕过了 Spring Security 的匹配逻辑,可能对所有请求生效,包括那些你打算放行的路径。

理解这三者的先后,是本文主线。

还有一层容易被忽略的问题:顺序错了之后,现象往往指向错误的原因。比如预检返回 401,日志里的异常看起来像"没有认证",于是有人去改认证配置;真实原因却是 CORS 没有在认证之前应答。再比如 CSRF 失败被全局异常处理统一包装成 400,前端看到的是"参数错误",排查方向完全跑偏。顺序的价值不只在"能不能跑通",更在于"失败时能不能看清是哪种失败"。

二、环境、版本与兼容边界

2.1 版本基线

项目 本文使用 说明
Java 21 默认示例基线
Spring Boot 4.1.x 新项目示例
Spring Framework 7.0.x 由 Boot BOM 管理具体补丁
Spring Security 由 Boot BOM 管理的兼容版本 不在示例中手工指定版本
存量系统 Boot 3.5.x / Framework 6.2.x 仅做差异说明,不作为主示例

需要说清楚的边界:

  1. 官方保证:Spring Boot 通过依赖管理(BOM)统一各组件的兼容版本,Boot 官方文档说明其依赖管理覆盖 Spring Security 等模块;具体版本号以你所用 Boot 版本的依赖管理为准,本文不臆造补丁号。
  2. 特定条件下的行为 :过滤器在链中的相对顺序,由各过滤器注册时的 order 与 HttpSecurity 的配置共同决定;具体顺序值属于实现细节,不同大版本可能调整,因此应通过"观察实际行为 + 官方文档描述"来确认,而不是死记数字。
  3. 工程建议:自定义认证逻辑优先写成 Spring Security 过滤器并插入到 SecurityFilterChain 内部,而不是注册成外部 Servlet Filter,以便复用其匹配与异常处理机制。
  4. 待确认假设:本文代码没有在本次回答中实际运行,预期结果属于"按机制推导应观察到"的结果,读者需在自己的环境验证。

第 2 条需要再展开一下,因为它是最容易产生误解的地方。Spring Security 内部确实有一组相对稳定的"标准过滤器",比如处理安全上下文的、处理异常的、处理认证的、处理授权的;它们之间的相对位置在同一个大版本内是设计好的协作关系,而不是任由调用者随意调换的。但"标准过滤器们各自的绝对 order 数值"并不是公开契约的一部分,跨大版本可能调整,甚至同名概念对应的实现类也可能被替换。因此工程上可靠的做法是:用 addFilterBefore / addFilterAfter 这类以类为锚点的方法表达相对位置,让框架负责算出实际数值;而不是手写一个 order 数值去"猜位置"。锚点类如果在新版本里不存在了,编译期就会报错,这种失败是显式且安全的;而硬编码数值在版本升级后可能静默错位,问题留到运行时才暴露。

2.2 依赖

xml 复制代码
<!-- pom.xml 片段:版本由 Spring Boot 父 POM 管理 -->
<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.1.0</version>
</parent>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

spring-boot-starter-web 提供 Servlet MVC 与 Tomcat;spring-boot-starter-security 提供过滤器链、认证授权、CSRF、CORS 集成;spring-security-test 提供测试支持。注意:示例中的 4.1.0 是写作时的代表示例版本,实际以你项目锁定的补丁为准,不需要与本文完全一致才能复现机制。

这里刻意没有给 spring-security-core、spring-security-web 单独写版本,也没有单独引入 spring-web。原因是这些组件的版本由 Boot 的依赖管理统一给出,手工指定容易出现"父 POM 给一个、你写一个"的混搭,最后编译能过、运行期行为却与文档不符。如果你确实需要用 Gradle,等价写法是引入对应的 starter,然后通过 Boot 插件或平台管理版本,不要逐个组件写死版本号。

2.3 Servlet 与 WebFlux 的分界

本文讨论的是 Servlet 栈(Spring MVC)。如果你用的是 WebFlux,Spring Security 的过滤器链模型换成 WebFilter 链,配置方法名与内部结构都不同,本文结论不能直接套用。这一点必须先确认,否则后面所有"过滤器顺序"的讨论都会跑偏。

两者差异不只是名字。Servlet 栈里一个请求由一个线程从头走到尾,过滤器可以在请求对象上挂属性、可以在响应对象上写头,线程局部的东西(比如安全上下文默认的存储方式)在同一线程内可见。WebFlux 里请求处理是事件循环驱动的,不允许随意阻塞,安全上下文默认也不是简单线程局部。因此"把安全上下文写入后,后面的过滤器能读到"这个前提,在两种栈里的成立条件不一样。本文所有关于"认证过滤器先写上下文、授权再读"的推理,都建立在 Servlet 栈的线程模型上。

判断自己项目属于哪种栈的方法很直接:看依赖里是 spring-boot-starter-web(Servlet)还是 spring-boot-starter-webflux(响应式),或者两者都有时看实际生效的 WebApplicationType。两者同时存在时,Boot 默认偏向 Servlet,这个默认也可能被配置改变,所以要看启动日志里的应用类型声明,而不是靠记忆。

三、核心机制:三者各自管什么、为何要这个顺序

3.1 SecurityFilterChain 与匹配

SecurityFilterChain 是一组过滤器加一个请求匹配器。Spring Security 允许注册多个这样的 Bean;请求进来时,FilterChainProxy 会按注册顺序(可用 @Order 影响)依次尝试匹配,第一个匹配上的链被完整执行,匹配不到就继续下一条。

这个"第一匹配生效"的语义,是理解一切的起点:

  • 如果你写了两条链,一条匹配 /api/**,一条匹配 /**,那么 /api/x 只走第一条,第二条完全不参与。
  • 如果你的自定义链匹配范围过宽,就会把本该走默认链的登录页、静态资源也吞进去。

匹配用的工具是请求匹配器(RequestMatcher),常见形式是路径、方法、AntPathRequestMatcher 风格或 MVC 的路径匹配器。Spring Security 官方文档明确建议:使用 MVC 时优先用基于 MVC 的匹配器,以与 Controller 的路径解析规则保持一致。这条属于官方文档给出的建议,具体类名在不同版本可能有变化,用前请对照你所使用版本的参考文档。

"第一匹配生效"还有一个容易被忽略的后果:链与链之间不是叠加关系,而是互斥关系。有人以为"我先写一条只做 CORS 的链,再写一条做认证的链,两条会串起来",这是错的。请求只会进入第一条匹配的链,后面的链即使也匹配,也不会执行。如果你希望某条路径同时拥有 CORS 和认证,那这两件事必须写在同一条链 里,或者干脆用全局的 CorsConfigurationSource 让各条链共享同一份策略。

还有一个细节值得写进排查清单:当没有任何链匹配时,FilterChainProxy 会直接放行请求到后续 Servlet 处理,也就是说这条请求不受 Spring Security 保护。这在无意中排除了某条路径时是危险的,因为现象不是"被拒绝",而是"看起来一切正常但没被保护"。所以自定义 securityMatcher 之后,最好用一条测试或一次手工请求确认目标路径确实进入了预期的那条链。

3.2 CORS 在安全链中的位置

CORS(跨源资源共享)是浏览器的一种安全机制:页面脚本默认不能读取其他源的响应,除非对方源显式允许。浏览器在发出"非简单请求"(比如带 PUT、自定义头、JSON 内容类型)之前,会先发一个 预检请求 (preflight),方法是 OPTIONS,并携带 Origin 与 Access-Control-Request-Method 等头。

关键点:预检请求不携带 Cookie,也不携带业务令牌 。如果安全链在这之前就要求认证或校验 CSRF 令牌,预检必然失败。因此 CORS 处理必须在认证与 CSRF 之前拿到并回应预检。Spring Security 提供了与 HttpSecurity.cors() 集成的入口,它会使用 CorsConfigurationSource 中定义的策略来生成响应头,并对预检请求直接返回。

把预检拆开看会更清楚。浏览器发预检,本质是问三件事:这个源能不能访问、能不能用这个方法、能不能带这些头。服务器要回答的就是 Access-Control-Allow-Origin、Access-Control-Allow-Methods、Access-Control-Allow-Headers,如果是带凭据的请求还要加 Access-Control-Allow-Credentials。这些答案完全来自配置,与"当前请求是谁"无关。既然与身份无关,就没有理由让认证先跑一遍;让认证先跑,等于要求预检先证明自己是合法用户,而预检在设计上恰恰不带用户凭据。

需要强调的因果关系:CORS 是浏览器端的访问控制,服务器端设置响应头并不会"阻止"非浏览器客户端发请求。它解决的是"浏览器是否允许脚本读取响应",不是"这台机器是否有权限"。把 CORS 当授权用是常见误区。这个误区会带来两种错误做法:一是拿允许源列表当访问控制清单,以为不在列表里的客户端就进不来;二是发现服务端"没拦住"某个请求,就去加 CORS 配置,结果只是改了浏览器的行为,服务端该做的认证授权一点没变。

另一条常见边界:Access-Control-Allow-Origin 在带凭据的请求中不允许是通配符 *,必须是具体源;同时 Access-Control-Allow-Credentials: true 与具体源要成对出现。若配置里既写了通配符源又要求凭据,浏览器会拒绝这次响应,而服务端日志看起来一切正常,因为服务器确实返回了它以为正确的头。这类问题只能从前端控制台和服务端响应头两头对照才能定位。

3.3 CSRF 校验管什么

CSRF(跨站请求伪造)是另一种问题:用户已登录的浏览器,被第三方页面诱导,向你的站点发起了一个它并不知情的写请求。Cookie 会自动带上,所以服务器无法靠"带 Cookie"判断这是用户本意。防御思路是要求写请求额外携带一个攻击者猜不到、也无法跨源读取的值,即 CSRF 令牌。

Spring Security 默认对会改变状态的请求方法(大体上是非 GET、HEAD、TRACE、OPTIONS)启用 CSRF 校验(默认行为随版本可能有细节差异,以你的版本文档为准)。它需要能读到令牌,也需要能写回令牌(比如通过 Cookie 或请求属性)。这就是为什么它对顺序敏感:

  • 它排在认证之后,才能知道"这批令牌对应哪个会话/用户";
  • 它必须对预检请求放行,因此 CORS 得更靠前。

这里补充一个经常被混淆的点:CSRF 校验和认证解决的不是同一件事。认证回答"你是谁",CSRF 回答"这个写请求是不是你本人在你的页面上发起的"。一个已经通过认证的请求,仍然可能是被伪造的,所以两者不能互相替代。反过来说,一个没有通过认证的请求,CSRF 校验也帮不上忙,因为它没有会话可对应。这就是为什么"先认证还是先 CSRF"会直接影响错误码的可读性:先认证,未登录的写请求得到 401,已登录但令牌错的写请求得到 403,两类问题分得开;先 CSRF,两类都可能是 CSRF 相关的拒绝,排查时就分不清。

还有一点关于"无状态令牌"的边界。如果客户端完全不依赖浏览器自动携带的 Cookie,而是把令牌放在请求头里,那么第三方页面无法让浏览器自动附上这个头,CSRF 的威胁模型就不完全适用,可以考虑关闭 CSRF 校验。但这是有前提的:前提是"确实没有依赖 Cookie 的凭据路径"。只要系统里还有任何一处用 Cookie 做认证,就不能笼统地关掉。关闭与否要写清依据,不能因为"我们是 API"就默认关掉。

3.4 认证过滤器

认证解决的是"你是谁"。在典型配置里,httpBasic()、formLogin() 或你自己写的"从请求头解析令牌"的过滤器都属于认证环节。认证成功会把身份写入安全上下文(SecurityContext),后续的授权判断才有依据。

为什么认证要排在 CSRF 之后或之前,需要区分场景:

  • 对于需要登录的写请求,先认证再校验 CSRF,可以让"未登录"和"令牌错误"两种错误区分清楚;
  • 但认证过滤器不能拦截预检,所以它必须排在 CORS 之后。

本文主线采用:CORS → CSRF → 认证 → 授权 → 业务。这是一个常见且自洽的顺序,而不是唯一正解;不同系统若使用无状态令牌、把令牌放在请求头里,可能会关掉 CSRF,此时顺序讨论的重点会转移到认证与异常处理。

需要说明清楚的是,这里的"认证"指的是建立身份的环节,而不是"要求身份"的环节。"要求身份"通常由授权规则隐式完成:授权规则发现当前上下文没有身份,就抛出未认证类异常,再由异常处理转换成 401 或重定向。因此自定义认证过滤器本身的职责应当是"有凭据就尝试解析并写入上下文,没凭据就安静放行",而不是"没凭据就地拒绝"。如果它在过滤器里直接写 401,会绕过框架的异常统一处理路径,导致同一个"未认证"出现两种响应格式,这恰恰是 1.1 里"认证失败返回 HTML"现象的来源之一。

3.5 用一张图固定顺序

text 复制代码
浏览器请求
   |
   v
[ SecurityFilterChain 匹配 ]  <- 第一匹配生效
   |
   v
[ CORS 处理 ] --预检请求--> 直接响应 200,结束
   |
   v
[ CSRF 校验 ] --缺少/错误令牌--> 403,结束
   |
   v
[ 认证过滤器 ] --无凭据/无效令牌--> 不结束,放行,稍后由授权判 401
   |
   v
[ 授权判断 ] --无身份--> 401;有身份但权限不足--> 403,结束
   |
   v
[ Controller / 业务代码 ]

这张图想说明一件事:每一层都能"提前结束",但并不是每一层都应该提前结束。CORS 与 CSRF 在失败时确实要立刻结束并给出明确响应;认证过滤器在"没有凭据"时不该结束,而应放行让授权环节统一判定。所以某层排错位置,就会用错误的错误码结束请求,掩盖真正的失败原因。

四、主线示例:一个可复现的最小工程

4.1 场景设定

  • 前端源:https://app.example.test
  • 后端源:https://api.example.test
  • 接口:GET /api/me(读取当前用户)、POST /api/orders(创建订单,写操作)
  • 认证:请求头 X-Auth-Token 携带一个示意用的固定令牌
  • 安全需求:写操作必须登录且带 CSRF 令牌;读接口只要求登录;预检请求必须成功

这是合成场景,令牌值是示意,不对应任何真实系统。选这个场景的原因不是它复杂,而是它同时包含了三个会互相干扰的失败点:预检不带凭据、写请求需要令牌、读请求只需要身份。只要其中一个顺序错位,三类请求里至少有一类会给出误导性的错误。

为了让后面的命令能直接对上预期结果,这里把接口行为明确成一张表:

接口 方法 是否需要身份 是否需要 CSRF 令牌 成功状态
/api/me GET 是 否(读方法默认不校验) 200
/api/orders POST 是 是 201
/api/orders OPTIONS(预检) 否 否 2xx
/api/public/ping GET 否 否 200

4.2 配置类

java 复制代码
// 片段:示意实现,未在本次回答中运行
package demo.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain apiChain(HttpSecurity http,
                                        TenantHeaderFilter tenantHeaderFilter,
                                        TokenAuthFilter tokenAuthFilter) throws Exception {
        http
            .securityMatcher("/api/**")
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .ignoringRequestMatchers("/api/public/**"))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated())
            .addFilterAfter(tenantHeaderFilter,
                org.springframework.security.web.context.SecurityContextHolderFilter.class)
            .addFilterBefore(tokenAuthFilter,
                org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("https://app.example.test"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("X-Auth-Token", "X-CSRF-TOKEN", "X-XSRF-TOKEN", "Content-Type"));
        config.setExposedHeaders(List.of("X-XSRF-TOKEN"));
        config.setAllowCredentials(true);
        config.setMaxAge(3600L);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
}

输入与状态变化说明:

  • securityMatcher("/api/**") 让这条链只守护 /api 下的请求,其他路径不被它接管。
  • cors(...) 接入 Spring Security 的 CORS 过滤逻辑,并使用上面的策略源。只要这一步存在,预检请求就会在安全链的前段被应答,不会走到后面的认证与授权。
  • csrf(...) 指定令牌存放在 Cookie 中,且允许脚本读取(withHttpOnlyFalse),因为前端需要把它放进请求头回传。
  • authorizeHttpRequests 声明 /api/public/** 放行,其余需要认证。
  • addFilterAfter 把自定义租户过滤器插在安全上下文之后。插入位置是工程选择,取决于该过滤器是否需要读取已建立的认证信息;本例只需要租户头,不影响认证,因此靠前插入无碍。
  • addFilterBefore 把令牌认证过滤器插在表单登录认证过滤器之前,让它在认证阶段较早尝试建立身份。
  • setExposedHeaders 在跨源场景里很重要:默认情况下,前端脚本只能读到少数"安全"响应头,如果 CSRF 令牌是放在响应头里下发的,不显式暴露,前端就读不到。本例用 Cookie 下发,暴露这个头是为了让前端在需要时能读到最新令牌。
  • setMaxAge 让浏览器缓存预检结果,减少预检次数。这属于优化,不影响顺序语义,但会影响你观察预检请求的频率,排查时要知道它存在。

注意:这里只用了一条链。如果系统还需要登录页等 HTML 流程,通常会再加一条优先级更高的链来匹配那些路径,避免被 apiChain 的认证规则影响。

4.3 自定义认证过滤器

java 复制代码
// 片段:示意实现,未在本次回答中运行
package demo.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class TokenAuthFilter extends OncePerRequestFilter {

    private static final String HEADER = "X-Auth-Token";
    private static final String DEMO_TOKEN = "demo-token"; // 示意值

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse res,
                                    FilterChain chain)
            throws IOException, ServletException {
        String token = req.getHeader(HEADER);
        if (token != null && DEMO_TOKEN.equals(token)
                && SecurityContextHolder.getContext().getAuthentication() == null) {
            var auth = new UsernamePasswordAuthenticationToken(
                "demo-user", null,
                AuthorityUtils.createAuthorityList("ROLE_USER"));
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        chain.doFilter(req, res);
    }
}

这段代码做了两件事:从请求头读取令牌;在匹配时把身份写入安全上下文。注意它没有在令牌缺失时直接写响应,因为缺少凭据应该交给后续授权环节判断,统一由异常处理产出 401,避免同一类错误出现两种格式。它也没有在令牌错误时抛异常,同样是出于"让异常处理统一"的考虑;如果你希望区分"令牌格式错误"和"没有令牌",可以在写入上下文之外再挂一个请求属性,由异常入口点读取并决定文案,而不是在过滤器里直接写响应体。

代码里有一个刻意的判断:getAuthentication() == null。它保证已经存在的身份不会被覆盖。在一条链里可能有多个认证机制,谁先成功建立身份,后续机制就不该再改。忽略这个判断,会让"某个后执行的机制用低权限身份覆盖了高权限身份"成为安全隐患。

还要注意 OncePerRequestFilter 的语义:它保证同一个请求在一次调度内只执行一次。这在有错误转发(forward)的场景下很重要,否则过滤器可能在转发路径上二次执行。它不保证跨异步线程的可见性,如果后面引入异步处理,需要重新评估上下文传播。

4.4 把它插进安全链

在 apiChain 中补一句:

java 复制代码
// 片段:接在 4.2 的链配置中
http.addFilterBefore(tokenAuthFilter, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class);

UsernamePasswordAuthenticationFilter 在这里主要用作"认证阶段的定位锚点"。把你的令牌过滤器插在它之前,意味着它先执行。插入锚点的具体语义与可用类以你所使用版本的参考文档为准;如果锚点类在某版本被替换,编译会直接失败,这是好事,比运行时静默错位安全。

为什么这里不选"排在 CORS 之后、CSRF 之前"这样的描述,而一定要落到某个具体类上?因为 HttpSecurity 的链构建最终是按过滤器类来排序的,addFilterBefore / addFilterAfter 的第二个参数就是锚点类。你说"放在 CSRF 之前"并不能直接表达成一次方法调用,除非你知道 CSRF 过滤器对应的实现类。用认证阶段的锚点表达"我的认证过滤器先跑",语义最小、耦合最少。

这也解释了 1.1 里提到的一个混淆:在 http DSL 里写的先后顺序,和运行时顺序不是一回事。你完全可以先写 authorizeHttpRequests(...) 再写 cors(...),运行顺序不会因此颠倒;真正决定顺序的是每个组件被装配成过滤器时携带的 order,以及 addFilterXxx 指定的相对位置。所以排查顺序问题时,不要盯着 DSL 的书写顺序看,要看实际装配出的过滤器链。

4.5 CSRF 令牌怎么来

使用 CookieCsrfTokenRepository 时,令牌会通过 Cookie 下发。为了让前端拿到它,通常需要一个接口或页面触发令牌加载;在某些版本中,令牌加载是延迟的,只有访问受保护资源或显式请求时才会写入 Cookie。实践做法是让前端在应用启动后先请求一个安全的读接口,再读取 XSRF-TOKEN Cookie,回填到请求头 X-XSRF-TOKEN(Spring Security 默认识别的头名因配置而异,需按你所用版本的文档确认)。

这里有一个经常导致"本地能跑、部署后不行"的细节:前端读 Cookie 的前提是 Cookie 对当前页面所在源可见。如果后端设置 Cookie 的路径、域或者安全属性不合适,前端脚本可能读不到;如果前端跨源请求又没有带凭据,Cookie 根本不会被发送。这都属于 Cookie 与 CORS 的协作边界,不是过滤器顺序问题,但现象很像顺序问题------写请求报令牌缺失时,先别急着改链顺序,先确认令牌到底有没有被下发、有没有被读到、有没有被带上。

一个需要小心的点:如果你在 CSRF 配置中设置了 ignoringRequestMatchers,那么被忽略的路径完全不做校验。忽略范围要窄,只覆盖确实不需要幂等保护的回调或公开接口。上面的示例里忽略了 /api/public/**,是配合"公开接口允许匿名访问"这个设定;如果你的公开接口里也有写操作,要重新评估是否真的应该忽略,而不是顺手把整个前缀加进去。

排查令牌问题可以按这个顺序走:

text 复制代码
写请求报令牌缺失 / 令牌无效
  |
  +-- 1. 响应里有没有下发令牌?(看 Set-Cookie 或响应头)
  |        |
  |        +-- 没有:是否走的是被忽略的路径?令牌下发是否被延迟到某次访问才发生?
  |
  +-- 2. 前端读到了吗?(跨源、Cookie 属性、脚本可读性)
  |
  +-- 3. 请求里带了吗?(头名是否与服务端识别的一致)
  |
  +-- 4. 到达服务端后,被哪一层拒绝了?(看是 CSRF 拒绝还是认证拒绝)

第 4 步是本文的重点:如果 CSRF 的顺序不对,第 4 步看到的"拒绝原因"会是认证而不是令牌,于是前面三步白查。

4.6 有界步骤

  1. 创建 Boot 4.1 工程,加入 2.2 的依赖。
  2. 放入 SecurityConfig、TokenAuthFilter、TenantHeaderFilter(后者可先写成只读取头并直接放行的空实现)。
  3. 写一个返回当前用户名的 GET /api/me,和一个返回 201 的 POST /api/orders,再补一个 GET /api/public/ping。
  4. 启动应用。
  5. 用 curl 依次验证:预检、未认证读、已认证读、登录后无令牌写、带令牌写。
  6. 结束后关闭应用,删除本地 Cookie 文件。

TenantHeaderFilter 的最小实现如下,它只读头并写入请求属性,不改变顺序语义:

java 复制代码
// 片段:示意实现,未在本次回答中运行
package demo.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class TenantHeaderFilter extends OncePerRequestFilter {

    public static final String TENANT_ATTR = "demo.tenant";

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse res,
                                    FilterChain chain)
            throws IOException, ServletException {
        String tenant = req.getHeader("X-Tenant-Id");
        if (tenant != null && !tenant.isBlank()) {
            req.setAttribute(TENANT_ATTR, tenant);
        }
        chain.doFilter(req, res);
    }
}

控制器的示意实现,重点是观察身份与请求属性,而不是业务逻辑:

java 复制代码
// 片段:示意实现,未在本次回答中运行
package demo.web;

import demo.security.TenantHeaderFilter;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
@RequestMapping("/api")
public class DemoController {

    @GetMapping("/me")
    public Map<String, Object> me(Authentication authentication, HttpServletRequest request) {
        return Map.of(
            "user", authentication.getName(),
            "tenant", String.valueOf(request.getAttribute(TenantHeaderFilter.TENANT_ATTR))
        );
    }

    @PostMapping("/orders")
    public ResponseEntity<Map<String, String>> createOrder() {
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(Map.of("orderId", "order-demo-1"));
    }

    @GetMapping("/public/ping")
    public Map<String, String> ping() {
        return Map.of("status", "ok");
    }
}

配置里如果只写了 @Configuration 和 Bean,而没有把 TokenAuthFilter 交给链,认证不会发生;但 TokenAuthFilter 是 @Component,它同时也会被 Servlet 容器当作普通过滤器注册。这是一个真实存在的易错点:组件扫描把它当成通用过滤器,它就在 FilterChainProxy 之外也生效一次。本文的示例依赖"它内部判断幂等"而不至于出错,但正确做法通常是不要在它上面同时使用 @Component 并让它依赖容器自动注册,而是作为普通 Bean 交给安全链管理,必要时用 FilterRegistrationBean 显式禁用它的容器级注册。这一点在 5.1 的反例里会展开。

4.7 命令与预期观测

bash 复制代码
# 1) 预检:应返回 2xx 且带 Access-Control-Allow-Origin
curl -i -X OPTIONS http://localhost:8080/api/orders \
  -H 'Origin: https://app.example.test' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: X-Auth-Token, X-XSRF-TOKEN, Content-Type'

# 2) 未认证读取:预期 401
curl -i http://localhost:8080/api/me

# 3) 已认证读取:预期 200
curl -i http://localhost:8080/api/me \
  -H 'X-Auth-Token: demo-token' \
  -H 'X-Tenant-Id: tenant-a'

# 4) 无 CSRF 令牌的写:预期 403
curl -i -X POST http://localhost:8080/api/orders \
  -H 'X-Auth-Token: demo-token' -H 'Content-Type: application/json' -d '{}'

# 5) 先取令牌 Cookie,再带令牌写:预期 201
curl -i -c cookie.txt http://localhost:8080/api/me -H 'X-Auth-Token: demo-token'
curl -i -b cookie.txt -X POST http://localhost:8080/api/orders \
  -H 'X-Auth-Token: demo-token' \
  -H 'Content-Type: application/json' \
  -H 'X-XSRF-TOKEN: <从上一步 Cookie 中取出的值>' \
  -d '{}'

# 6) 公开接口:预期 200,不需要令牌
curl -i http://localhost:8080/api/public/ping

预期结果与原因:

请求 预期状态 原因
预检 OPTIONS 2xx CORS 在认证与 CSRF 之前,直接回应预检
无令牌读 401 认证过滤器未建立身份,授权规则拒绝
有令牌读 200 身份建立,规则放行
无 CSRF 的写 403 认证已通过,CSRF 校验失败
带 CSRF 的写 201 身份与令牌都满足
公开读 200 匹配到放行规则,且不要求 CSRF

以上是"按机制推导的预期",不是本次运行结果。真实环境中状态码还会受 exceptionHandling 配置、是否启用 formLogin(可能触发重定向而非 401)等影响,需要实测确认。

观察响应时,建议不要只看状态码,也看头。预检请求要能看到允许的源、方法、头;普通跨源请求要能看到允许的源,且与请求的 Origin 完全一致;如果响应里没有这些头,说明 CORS 环节没有生效,而不是"认证挡住了"。把"没生效"和"生效了但被拒"分开,是排查的第一步。

五、失败与反例:顺序错了会看到什么

5.1 反例一:把 CORS 关掉,改用外部 CorsFilter

text 复制代码
错误结构:
Servlet 容器 -> 外部 CorsFilter -> FilterChainProxy -> 认证 -> CSRF

这种写法并非一定错,但容易出问题:外部过滤器对所有请求生效,它和 Security 链的匹配范围不再一致;一旦 Security 链在别的路径上做了不同的 CORS 策略,就会出现两套规则。更隐蔽的是,外部过滤器绕过 Security 的匹配后,某些被 securityMatcher 排除的路径也会被加上 CORS 头,形成你以为没有暴露、实际却允许的源。

诊断方法:发一个针对非 /api 路径的跨源请求,看是否返回了 Access-Control-Allow-Origin。如果有,说明有过滤器在安全链之外生效。

更细的诊断可以看"响应头来自谁"。如果 Access-Control-Allow-Origin 出现在一个本不该由安全链处理的路径上,而你的 CorsConfigurationSource 只注册了 /api/**,这就是外部过滤器在补头。另一个信号是重复头:两个组件都给同一个响应追加头,可能出现同名头重复,浏览器对重复的允许源头处理并不总是宽容。

同样的道理也适用于把认证过滤器直接注册成容器级 Filter。它会先于 FilterChainProxy 执行,于是在一条 securityMatcher 不匹配的路径上也可能建立身份;而这条路径上的安全上下文可能并不被安全链使用,造成"看起来登录了但规则没生效"或"被不该保护的东西保护了"。判断方法很简单:给非目标路径发请求,看安全上下文或响应是否表现出"被认证过"的痕迹。

5.2 反例二:预检返回 401

现象:浏览器控制台报跨域错误,服务端日志显示 OPTIONS 请求未通过认证。

原因链条:CORS 没有在认证之前处理,或者根本没有启用 cors(),导致预检请求进入认证过滤器;预检不带令牌,认证失败,直接返回 401。

修复:确认 http.cors(...) 存在且 CorsConfigurationSource 匹配了目标路径;确认自定义认证过滤器不拦截 OPTIONS。可以在过滤器中显式判断:

java 复制代码
// 片段:跳过预检
if ("OPTIONS".equalsIgnoreCase(req.getMethod())) {
    chain.doFilter(req, res);
    return;
}

但更推荐把这件事交给 CORS 环节,让过滤器本身不需要知道预检。原因有两个:第一,预检是否应该被放行,取决于 CORS 策略,而不是认证逻辑;第二,把这个判断散落在多个过滤器里,等于把"预检"这个概念重复实现,任何一处漏掉都会重现问题。

还有一种看起来像 401 的现象值得单独说:浏览器在预检失败时,控制台显示的往往不是 401,而是一句笼统的跨域错误,甚至不展示具体状态码。所以不能只看浏览器控制台,要同时看服务端访问日志里那条 OPTIONS 的实际状态。否则容易把"预检被拒"误判为"前端代码问题"。

5.3 反例三:CSRF 错误码与认证错误码混在一起

如果 CSRF 排在认证之前,那么一个"已登录但令牌过期"的写请求,可能先被 CSRF 拒绝;如果认证配置又把未认证请求重定向到登录页,前端拿到的就是 HTML,解析失败,日志里却只看到"JSON 解析异常"。

排查顺序应该是:先看请求方法和路径是否属于需要 CSRF 的范围,再看令牌来源(Cookie 还是请求属性),最后看异常处理是否把 403 转成了统一 JSON。把这个顺序固定下来,能避免在错误的地方改配置。

这里还有一个隐性的错误做法:为了让"所有错误都是一个格式",在异常处理里把 CSRF 拒绝也映射成 401 或 400。结果是前端无法区分"你该重新登录"和"你的令牌不对",于是统一弹一次重新登录,用户体验和安全性都受损。正确做法是保留语义:CSRF 失败就是 403,未认证就是 401,权限不足也是 403,但 403 的响应体里给出可区分的错误码。

5.4 反例四:方法级安全误以为能替代链顺序

在 Controller 方法上加 @PreAuthorize 属于方法级安全。它运行在方法调用附近,而不是过滤器链里。像 CORS 预检这种根本不进入 Controller 的请求,方法级注解完全管不到。因此它不能替代 CORS 与 CSRF 的链内位置。

一个常见组合是:链上做粗粒度路径控制,方法上做细粒度业务授权。两者是互补关系,不是替代关系。

补充一个边界:方法级安全要生效,需要启用相应的方法安全配置;它也不会自动改写异常响应格式。方法级授权失败抛出的异常,同样要经过你配置的异常处理才能变成预期的 JSON。所以"在方法上加注解"并不能替代第六节要讲的异常转换。

5.5 反例五:关掉 CSRF 却不说明前提

有的团队在遇到令牌问题后,直接在配置里禁用 CSRF,问题"消失"了,但没有人写清为什么可以关。这在纯请求头令牌、且完全不使用 Cookie 凭据的 API 里是合理的;如果系统里还有任何一处依赖浏览器自动携带的 Cookie,禁用 CSRF 就等于把这个入口暴露给跨站伪造。判断标准不是"是不是 API",而是"有没有依赖浏览器自动携带的凭据"。这条判断要写进配置注释或架构决策记录里,而不是留在某个人的记忆里。

六、异常转换与响应格式

即使顺序对了,错误信息的形态仍然会影响前端。Servlet 栈中,安全异常默认由 ExceptionTranslationFilter 处理,但如果你启用了表单登录,未认证请求可能被重定向;如果你想要统一 JSON,需要自定义入口点与拒绝处理器。

java 复制代码
// 片段:示意,统一 JSON 错误
http.exceptionHandling(ex -> ex
    .authenticationEntryPoint((req, res, e) -> {
        res.setStatus(401);
        res.setContentType("application/json;charset=UTF-8");
        res.getWriter().write("{\"code\":\"UNAUTHORIZED\"}");
    })
    .accessDeniedHandler((req, res, e) -> {
        res.setStatus(403);
        res.setContentType("application/json;charset=UTF-8");
        res.getWriter().write("{\"code\":\"FORBIDDEN\"}");
    }));

要点:认证失败和授权失败在语义上是两件事,错误码不同,前端处理也不同。不要为了"统一"把所有失败都返回 400。

这里还要区分两个入口:authenticationEntryPoint 处理"没有身份"的情况,accessDeniedHandler 处理"有身份但被拒绝"的情况。在匿名请求与拒绝处理器之间,框架有时会把前者转交给入口点,具体路径取决于配置;这也是为什么"明明配了拒绝处理器,预览却是 401"的现象会出现。排查时先确认异常类型,再看由哪个处理器接住,而不是直接改状态码。

另一个常见问题是预检请求走到了异常处理。正常情况下预检应该在 CORS 环节就结束,不会触发安全异常;如果看到预检的响应体是 JSON 错误,说明 CORS 没有在它之前生效。这时改异常处理没有用,改顺序或补 CORS 配置才对。

七、适用条件与结论

7.1 什么时候这套顺序成立

  • 使用 Servlet 栈(Spring MVC),不是 WebFlux。
  • 前后端分离、跨源部署,浏览器会发预检。
  • 使用 Cookie 或基于 Cookie 之外的令牌,但写操作仍需防 CSRF(若完全用请求头令牌且不依赖 Cookie,可评估关闭 CSRF,但要说明依据)。
  • 安全链按路径拆分时,securityMatcher 范围清晰,不会互相吞并。

7.2 什么时候需要改

  • 纯 API 且客户端不携带浏览器凭据(如服务间调用),CSRF 可能需要关闭,但必须写清风险评估。
  • 使用 WebFlux 时,需要重写为 WebFilter 体系。
  • 多租户系统可能需要把租户过滤器放在认证之前,但如果它依赖认证身份,就要调整。
  • 如果前端需要读取额外的响应头,要在 CorsConfiguration 里显式暴露,而不是指望默认行为。

7.3 结论

把过滤器链看成一串"会提前结束请求的关卡",顺序问题就变成"哪种失败应该先被看到"。CORS 必须早于认证与 CSRF,因为预检不带凭据;CSRF 应早于认证之后的业务逻辑,以便把"令牌问题"和"身份问题"分开;认证过滤器本身在无凭据时应放行,由授权环节统一判定;自定义过滤器要么插进安全链并明确锚点,要么清楚说明它为何必须在外层。最后,所有结论都以你所使用版本的官方文档和实际观测为准,本文示例未执行,需在你的工程中验证。

7.4 需要复核的问题

  1. 你所用 Boot/Security 版本中,CSRF 令牌头的确切名称与默认忽略范围。
  2. 多链场景下,你的匹配器是否存在重叠。
  3. 异常入口点是否与前端约定一致。
  4. 自定义认证过滤器是否被容器重复注册,从而在安全链之外也执行一次。
  5. CORS 配置里的源、凭据、暴露头是否与前端实际读写的头一致。

把这五点核完,再上线,比在生产上追着 CORS 报错猜顺序更省时间。

相关推荐
yuniko-n1 小时前
【JUC】读写锁 StampedLock
java·1024程序员节
文丑颜不良啊1 小时前
LeetCode701~725题目解析
java·leetcode
ch.ju2 小时前
Java——idea知识:Collection父接口
java·开发语言·intellij-idea
木井巳3 小时前
【动态规划: 线性dp】解码方法
java·算法·leetcode·动态规划·推荐算法
ly76894 小时前
Kafka 消费者重试与死信:Spring for Apache Kafka 的边界设计
spring·kafka·apache·并发消费·消费者重试·死信主题·位移提交
缓慢更新10 小时前
审批的加签与会签:多人意见怎么合
android·java·数据库·低代码·rxjava
yuniko-n12 小时前
【JUC】volatile 非原子性深入解析
java
sun_weitao12 小时前
极简版 MCP 工具实现方案
java·linux·服务器
金銀銅鐵13 小时前
[Java] 展示增删元素时 LinkedList 的内部变化
java·后端·ai编程