在接手一个老系统的时候,被 Sa-Token 过滤器引发的 CORS 误判问题卡了两天。前端反复报“跨域”、后端死活说“我配了CORS”,最后定位到元凶竟然是 Sa-Token 的过滤器把 OPTIONS 预检请求给拦截了。这个问题在前后端分离的项目里很典型,也很隐蔽,因为很多人会把问题误解为“CORS 没有配置”或者“Nginx 反代问题”,很少往认证体系的过滤器链路上想。这篇文章我就结合我的实际排错过程,把 Sa-Token 过滤器如何干扰 CORS、为什么会出现误判、以及最终的修复方案完整拆解一遍。
适合看这篇文章的,主要是正在用 Sa-Token 做权限控制的 Java 开发者,尤其是前后端分离、存在跨域调用的团队。如果你恰好遇到过“后端明明配了跨域但前端就是调不通”的情况,这篇文章能帮你省掉至少半天排查时间。
1. 问题现场:CORS 报错背后的真实链路
1.1 报错表现与第一轮误判
当时前端同事反馈,所有需要携带登录态请求的接口都调不通,浏览器 Console 里报的是一段很经典的 CORS 错误:
Access to XMLHttpRequest at 'http://api.xxx.com/user/info' from origin 'http://admin.xxx.com' has been blocked by CORS policy.
后端这边我第一反应是查全局跨域配置,确认 Spring MVC 的WebMvcConfigurer里确实加了addCorsMappings,而且allowedOriginPatterns、allowedMethods、allowedHeaders都配了。再查网关或者 Nginx,发现转发也没问题。当时心里浮起一个念头:是不是前端带了自定义 Header,触发了 CORS 的“非简单请求”预检流程,而我们漏了什么配置?
结果一遍遍核对配置,都没有看出问题。甚至我还让前端试过不带Authorization头,结果普通请求能通,一带头就挂。这个现象其实已经在强烈暗示:问题出在认证过滤器对 OPTIONS 预检请求的处理上,而不是 Spring 的 CORS 配置本身。
1.2 请求被拦截的现场还原
我用浏览器的 Network 面板和后端日志两头一起抓,终于看到了完整链路:
- 前端发送真实请求前,浏览器先发送
OPTIONS /user/info。 - 这个 OPTIONS 请求带着
Origin: http://admin.xxx.com、Access-Control-Request-Method: GET、Access-Control-Request-Headers: satoken等头。 - 后端 Sa-Token 过滤器在 Spring 的 CORS 处理之前先执行,发现 OPTIONS 请求中没有有效的
satoken(Sa-Token 默认从请求头或 Cookie 读取 Token),于是直接判定为未登录,返回一个 JSON 格式的 401 响应。 - 浏览器收到的这个 401 响应里没有
Access-Control-Allow-Origin等 CORS 响应头,于是浏览器认定跨域失败。
我把这个链路展开讲一下,因为这是整个问题的核心。浏览器在发起“非简单请求”之前会先做一次预检,预检请求本身并不携带业务数据,它纯粹是为了询问服务器“你允不允许我这个跨域请求”。Sa-Token 的过滤器恰恰把这种询问当成了“必须登录才能访问”的正式业务请求,于是直接按“未登录”处理。这个返回的 401 体里没有半点 CORS 响应头,浏览器不认账,把整次请求判定为跨域失败,而真正的业务请求根本没有发出去。
2. 根因剖析:为什么配置了 CORS 依然跨域失败
2.1 CORS 预检请求的触发条件
很多人会把 CORS 简单理解为“服务器在响应头里加几个字段就完事”,但实际上浏览器遵循的是一套完整的协议。对于“简单请求”,也就是只使用 GET、POST、HEAD,且只携带常见几个 Header 的请求,浏览器会当作简单请求直接发出,服务端只要在响应里带上Access-Control-Allow-Origin就能通过。
一旦请求满足下面任何一个条件,浏览器就会先发送 OPTIONS 预检:
- 使用了 PUT、DELETE、PATCH 等方法;
- 携带了自定义请求头,比如
satoken、Authorization、X-Token; - 请求体不是
application/x-www-form-urlencoded、multipart/form-data、text/plain中的一种。
绝大多数使用 Token 认证的前后端分离项目,都会在请求头里塞一个自定义 Token 字段。这几乎等于必然触发预检。也就是说,只要用了 Sa-Token 又会跨域调用,你的项目 100% 会遇到 OPTIONS 请求。这不是偶然,而是必然。
2.2 Sa-Token 过滤器在链路中的位置
Servlet 规范中的过滤器链和 Spring MVC 的拦截器不是同一个东西。Sa-Token 一般通过SaServletFilter或自定义 Filter 注册进过滤器链,并且会先于 Spring MVC 的 HandlerInterceptor 执行。这意味着 Sa-Token 的过滤器在请求抵达 Controller 之前就已经生效了,也会早于 Spring 对 CORS 配置的最终处理。
正常情况下,你配置的addCorsMappings会在 Spring MVC 处理请求的早期阶段写入 CORS 响应头。但 Sa-Token 的过滤器更靠前,它在 CORS 处理代码还没机会运行的时候就返回 401 了。响应直接终止,后续所有逻辑都不会执行。这就是为什么“CORS 配置看起来完全正确,却依然失效”的根本原因。
打个比方,你把门口的安检机器放到了快递柜前面,任何包裹都必须先过安检,然后快递柜才有机会被打开。一个纯粹是“询问能不能投递”的包裹(OPTIONS 请求),被安检机器当成“无人认领的正式包裹”给拒收了,快递柜自然永远不会被使用。
2.3 为什么凭直觉很难发现
这个问题很难排查,是因为报错信息极具迷惑性。浏览器只报 CORS 错误,不会告诉你“服务器返回的是 401”。而后端日志里也不会出现一个扎眼的异常,因为在 Sa-Token 看来,你就是一个没登录的人想访问需要认证的接口,拒绝得合情合理。要不是我盯着 Response Headers 看了半天,看到返回的根本不是 CORS 响应头,而是一段未登录的 JSON,我可能还要在错误的方向上浪费更多时间。
另一个迷惑点是“普通接口能通,带认证的接口不通”。这也不能怪开发者粗心,因为普通接口如果不需要登录,Sa-Token 过滤器通过excludeLoginPaths放行了,也就不存在拦截问题。需要登录的接口才会被拦截,看起来就像是“权限配置漏了”而不是“过滤器把 OPTIONS 给吃了”。
3. 方案选型:四种解决思路的取舍
3.1 方案一:在 Sa-Token 过滤器里直接放行 OPTIONS
最直白的做法是在 Sa-Token 过滤器配置的addExcludePaths中,把/v1/**之类的路径排除掉,或者对所有 OPTIONS 请求直接放行。
示例代码大概是这样的:
@Configuration public class SaTokenConfigure { @Bean public SaServletFilter saServletFilter() { return new SaServletFilter() .addInclude("/**") .addExclude("/auth/login", "/auth/logout") .setAuth(obj -> { // 判断是否登录 StpUtil.checkLogin(); }) .setError(e -> { // 返回错误信息 }); } }可以调整setAuth里的逻辑,在调用StpUtil.checkLogin()之前先判断一下是不是 OPTIONS 请求,是的话直接 return:
.setAuth(obj -> { if ("OPTIONS".equals(SaHolder.getRequest().getMethod())) { return; } StpUtil.checkLogin(); })这个方案简单直接,能立刻解决预检请求被拦截的问题。它的缺点是会放过所有 OPTIONS 请求,而 OPTIONS 请求本身并不是真正执行业务逻辑的请求,所以安全风险很小,顶多增加一点点无效请求的透传。很多团队最终都会选择这个方案,因为它改动最小,效果立竿见影。
3.2 方案二:调整过滤器顺序,让 CORS 先执行
在 Spring Boot 中,可以通过@Order注解控制 Filter 的执行顺序。理论上,如果把 CORS Filter 的顺序排在 Sa-Token 之前,CORS 响应头就会被先写入,即使 Sa-Token 后续返回 401,浏览器也能看到 CORS 头,跨域问题就不会被误判。
@Bean @Order(Ordered.HIGHEST_PRECEDENCE) public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); }这个方案比方案一更“正统”,因为它解决的是“CORS 处理时序”这个本质问题。但实际使用中要注意一个细节:Sa-Token 的过滤器可能是通过SaServletFilter自动注册的,也可能是在项目里手动 new 出来的,顺序不好统一控制。如果 Sa-Token 的过滤器顺序靠前,光在代码里给CorsFilter加@Order可能还压不住它。这时候得看 Sa-Token 的注册机制,不同版本的 Sa-Token 处理方式略有差异。
我在项目里曾见过同事用@WebFilter+@Order的组合,结果还是没生效。后面查资料才发现 Sa-Token 的SaServletFilter内部有自己的注册逻辑,优先级未必单纯由@Order控制。所以除非你确实理解了 Sa-Token 当前版本的过滤器注册源码,否则我不建议你依赖这个方案。
3.3 方案三:在全局异常/响应处理中补充 CORS 头
这个思路是在 Sa-Token 的setError回调中,或者在一个自定义的 OncePerRequestFilter 中,统一对响应附加 CORS 响应头:
.setError(e -> { SaHolder.getResponse() .setHeader("Access-Control-Allow-Origin", "*") .setHeader("Access-Control-Allow-Methods", "*") .setHeader("Access-Control-Allow-Headers", "*"); return SaResult.error(e.getMessage()); })这样做的好处是不用放行 OPTIONS 请求,也不需要调整过滤器顺序,只要 Sa-Token 返回错误,CORS 头照样带上去。浏览器看到响应里有 CORS 头,跨域校验就通过了,虽然业务请求最终还是会因为未登录而 401,但不会把它误报成 CORS 错误。
这个方案看起来很漂亮,但它有一个容易被忽略的坑:响应头里的 CORS 必须和前端、后端配置保持一致。如果一开始用了*,但业务上又要携带 Cookie 或者需要使用allowedCredentials,那么Access-Control-Allow-Origin就不能写成*,否则浏览器会拒绝。你说一个本来就因为跨域问题被搞到焦头烂额的人,还要再维护一份手工响应头,风险是不是有点大?
3.4 方案四:真正常规的解法组合
我最终采用的是方案一 + 方案二的变体组合。具体做法是这样的:
- 在 Sa-Token 的
setAuth里判断OPTIONS直接放行。 - 同时注册一个全局
CorsFilter,使用 Spring 的CorsConfiguration配置所有跨域许可。 - 在最外层再包一个
OncePerRequestFilter,对所有响应统一补充Vary: Origin等头,预防某些反代/缓存的坑。
组合起来的代码放在后面第 4 章节,这里我想先说清楚选型理由:放行 OPTIONS 是解决“Sa-Token 拦截预检”的唯一直接手段,而全局CorsFilter负责把真正的 CORS 响应头正确写出去。两者各司其职,不会出现“放行了 OPTIONS 但 CORS 头没补上”这种半吊子状态。
4. 实操:完整修复步骤与验证流程
4.1 步骤一:先确认是不是 Sa-Token 过滤器的问题
在动手改代码前,建议先用两个快速验证确认问题定位,避免白干:
- 打开浏览器开发者工具,看预检请求 OPTIONS 的响应状态码。如果返回 401 或 200 但响应内容是一段 JSON(比如
{"code":401,"msg":"未登录"}),基本可以确认是 Sa-Token 拦截了预检请求。 - 临时在 Sa-Token 配置里把所有需要认证的接口路径排除掉,再让前端试一次。如果跨域报错消失,说明就是 Sa-Token 的认证逻辑在作祟。
我在实际排错时还用过一个小技巧:用命令行直接发一个 OPTIONS 请求。
curl -i -X OPTIONS \ -H "Origin: http://admin.xxx.com" \ -H "Access-Control-Request-Method: GET" \ -H "Access-Control-Request-Headers: satoken" \ http://api.xxx.com/user/info返回的响应头里如果根本没有Access-Control-Allow-Origin,同时响应体是一个 401 的 JSON,那就不用再怀疑 Spring 的 CORS 配置了。
4.2 步骤二:修改 Sa-Token 过滤器配置
我以 Sa-Token 1.3x 版本为例,最终落地代码基本是这样:
@Configuration public class SaTokenConfig { @Bean public SaServletFilter saServletFilter() { return new SaServletFilter() .addInclude("/**") // 登录、刷新 token 等接口排除 .addExclude("/auth/login", "/auth/sms") .setAuth(obj -> { // 核心:预检请求直接放行,不参与登录校验 String method = SaHolder.getRequest().getMethod(); if ("OPTIONS".equalsIgnoreCase(method)) { return; } // 校验登录状态 StpUtil.checkLogin(); }) .setError(e -> { // 这里也可以顺手把CORS响应头补上,作为双保险 SaHolder.getResponse() .setHeader("Access-Control-Allow-Origin", "*") .setHeader("Access-Control-Allow-Headers", "*") .setHeader("Access-Control-Allow-Methods", "*"); return SaResult.error(e.getMessage()); }); } }有几个细节值得注意。SaHolder.getRequest().getMethod()在 Sa-Token 的封装里取到的是当前请求的 HTTP 方法字符串,注意转成大写再比较,因为某些客户端可能会发小写的options。还有就是addExclude和setAuth里的放行逻辑是不同层面的,前者是根本不会进入认证逻辑,后者是进入了认证逻辑但主动跳过。对于这个场景,两种都可以,我习惯用setAuth内放行,因为addExclude写太多路径对后续维护不友好。
4.3 步骤三:配置全局 CORS 过滤器
这一步不能让 Spring 自生自灭,最好显式注册一个CorsFilter,并且@Order要足够靠前。
@Configuration public class CorsConfig implements WebMvcConfigurer { @Bean @Order(Ordered.HIGHEST_PRECEDENCE) public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }为什么用addAllowedOriginPattern("*")而不是addAllowedOrigin("*")?因为allowCredentials(true)场景下,Access-Control-Allow-Origin不能是*,必须回显具体的 Origin。Spring 的addAllowedOriginPattern("*")会动态匹配当前请求的 Origin 并写到响应头里,这样既允许跨域又能携带 Cookie。maxAge设置成 3600 秒,是为了让浏览器缓存预检结果,避免每个请求都来一次 OPTIONS,对接口性能有点帮助。
不过要提醒一下,如果你设置了maxAge,在修复期间最好把它调成 0 或很小,否则浏览器会缓存旧的预检失败结果,导致你改了代码前端还是旧的跨域错误。我就在这个细节上吃了亏,改完代码后前端过了五分钟才正常,一度以为自己的修复没有生效。
4.4 步骤四:前端联调验证
服务端修好后,不能光看后端日志就宣布完成。我通常会让前端做三类验证:
- 带自定义 Token 头的跨域 GET 请求;
- 带自定义 Token 头的跨域 POST 请求(JSON 请求体);
- 需要携带 Cookie 的跨域请求。
每一项都要求浏览器控制台不出现 CORS 报错,同时 Network 面板里可以看到预检请求 OPTIONS 的状态码是 200 或 204,并且响应头里包含Access-Control-Allow-Origin和Access-Control-Allow-Headers。
我自己的测试习惯是先用 Chrome 的无痕窗口,避免本地插件或者缓存干扰。然后再用 Firefox 的开发者工具里的“网络”面板复验一遍,因为两个浏览器对 CORS 的提示风格不一样,双验证能降低“当前浏览器过了、用户浏览器挂了”的概率。
5. 常见问题与排查技巧实录
5.1 为什么我明明放了 OPTIONS,前端还是报跨域?
这个情况我碰到过两次。一次是放行逻辑写在了addExclude里,但路径写的是/user/*,而实际请求是/user/info/detail,Spring 的AntPathMatcher并不是按你想的那样匹配多级路径。另一次是浏览器缓存了旧的预检失败结果。处理办法也很简单:把放行路径改成/**或者/user/**,同时把maxAge改小,等联调通过后再调大。
如果有 Nginx 在中间层,还需要检查一下 Nginx 是否对 OPTIONS 请求做了额外处理。有些运维同学会在 Nginx 层直接return 204,这种情况下 CORS 响应头由 Nginx 决定,而不是后端 Java 程序决定,这里的“CORS 误判”就未必是 Sa-Token 的锅了。
5.2 使用自定义 Token 头时要注意 Header 大小写问题
浏览器在发送Access-Control-Request-Headers时,会使用你代码里setRequestHeader时写的大小写形式。Sa-Token 默认读取请求头的名字是satoken,但如果你前端写的是Satoken或者Token,虽然 HTTP 规范说 Header 名称不区分大小写,但在某些浏览器兼容场景下,后端拿到的结果可能不一致。稳妥的做法是前端统一,项目里也统一约定一个 Header 名,别一会儿satoken、一会儿Authorization。
另外,如果使用Authorization头承载 Token,Sa-Token 也支持通过配置token-name切换。但 CORS 预检时Access-Control-Allow-Headers里必须包含这个 Header 名,否则浏览器依然会拦截。用allowedHeader("*")时这个问题不明显,一旦你想收紧策略,就很容易漏。
5.3 多个过滤器叠加时的排查顺序
很多项目里不只是 Sa-Token 一个过滤器,可能还有日志过滤器、XSS 过滤器、自定义鉴权过滤器。如果多个过滤器对 OPTIONS 请求的处理逻辑互相冲突,最糟糕的情况会表现为“CORS 时而通、时而不通”。这时候不妨临时把所有自定义过滤器都关掉,看只剩下 Spring 自带的 CORS 时是否正常,然后再一个个加回来。这种二分法虽然原始,但效率极高,我靠它解决过好几起“过滤器链相互打架”的疑难杂症。
5.4 从“跨域报错”快速定位到“认证拦截”
我想分享一个更好的排查思路:不要先看 CORS 配置,而要先看响应体。在浏览器控制台里点开那条红色的跨域报错,切到 Network,找到对应的预检请求或业务请求,查看响应体内容。如果响应体是项目自己定义的统一返回结构,比如{"code":401,"msg":"未登录"},那问题几乎 100% 出在服务端的认证过滤器上。这个技巧对任何权限框架都适用,不只针对 Sa-Token。
有过一次,前端把请求放在fetch里且设置了credentials: 'include',后端没有开启allowCredentials,结果浏览器直接拒绝。那一次我就没看响应体,因为响应体压根没有;后来加上allowCredentials(true)并配合allowedOriginPattern("*")才通过。所以我想强调一句,CORS 问题排查的第一步永远是“分清楚是浏览器没放行,还是服务端没返回正确响应头”。
6. 写在最后的个人体会
踩过 Sa-Token 过滤器引发 CORS 误判这个坑之后,我最大的改变是:写认证过滤器时,默认就会考虑 OPTIONS 预检请求的兼容问题,而且会把 CORS 配置外置成一个明确的配置类,不再指望 Spring Boot 的自动配置能兜底。这套思路后来用到了其他几个项目上,都稳稳当当。
如果你正被“前端报跨域、后端说已配置”的诡异问题折磨,我的建议是:别急着改代码,先用上面 4.1 里的校验方法确认 Sa-Token 是否拦截了 OPTIONS。确认后,直接在第 4.2 和 4.3 的代码基础上落地,大部分场景都能一次解决。最后记得把浏览器缓存和预检缓存一起清掉,再做完整的跨域联调。这样一通操作下来,你会发现自己不仅解决了问题,还顺带把 CORS 和过滤器链的底层逻辑都理清了,以后再遇到类似问题,基本就是送分题了。