ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring跨域CORS Filter实战:从注解到全局过滤器,彻底解决前后端跨域

Spring跨域CORS Filter实战:从注解到全局过滤器,彻底解决前后端跨域 去年做前后端分离改造Vue管理后台跑在 3000 端口Spring Boot 接口在 8080浏览器控制台“如愿以偿”地蹦出了那句经典的Access to fetch at http://localhost:8080/api/user from origin http://localhost:3000 has been blocked by CORS policy: No Access-Control-Allow-Origin header is present第一次遇到这类问题大部分人都会给 Controller 加个CrossOrigin发现能用就完事了。但真正接手过几个接口满布注解、Security 权限缠身、甚至还套了网关的老项目之后你会明白跨域根本不是加一个注解那么简单。这篇我就专门聊聊 Spring 里跟“跨域 CORS Filter”相关的整套逻辑为什么推荐用 Filter 而不是注解、CorsFilter 在源码层面到底做了什么、和 Spring Security 一起用的时候排位问题有多要命以及实际排错该怎么一步步复现。1. 跨域问题的本质不是后端拒绝了你是浏览器替你挡下了1.1 同源策略前后端为什么不能随便互相访问浏览器的同源策略是这套机制的地基。所谓“同源”要求协议、域名、端口三个要素全部一致。http://localhost:3000和http://localhost:8080端口不同哪怕域名都是 localhost依然算跨域https和http不一样也算跨域localhost和127.0.0.1表面看是同一台机器在浏览器眼里也同样是两个源。同源策略默认禁止页面跨域读取资源但它不拦资源的“发送”。这意味着你的前端可以正常把请求发到后端后端也正常完成了业务处理并返回数据偏偏浏览器在把响应交给前端脚本之前会先做一轮“资质审查”检查响应头里有没有 Access-Control-Allow-Origin值跟当前页面源是否匹配。如果后端压根没处理过跨域响应头里没有这个字段浏览器就直接把响应扣下控制台报 CORS 错误。接口逻辑、业务代码可能全都没问题数据也正常算出来了只是因为“响应头不合格”前端拿不到。1.2 浏览器怎么判定“能不能跨”简单请求与预检请求CORS 协议把请求大致分成两类。第一类是简单请求通常是 GET、HEAD以及 Content-Type 限定为application/x-www-form-urlencoded、multipart/form-data、text/plain的 POST。这类请求浏览器不会提前试探直接把真实请求发过去然后检查响应头。第二类是预检请求。只要请求方法不是上面的几种比如 PUT、DELETE或者带上了自定义请求头比如Authorization、X-Requested-With甚至 Content-Type 用了application/json浏览器都会先发一个 OPTIONS 请求称为 preflight探探路OPTIONS /api/user HTTP/1.1 Host: localhost:8080 Origin: http://localhost:3000 Access-Control-Request-Method: PUT Access-Control-Request-Headers: Content-Type,Authorization后端看到这个 OPTIONS 请求后需要返回一系列 Access-Control-Allow-* 响应头告诉浏览器“这个源我认这些方法我认这些请求头我也认”。预检通过后浏览器才会把真正的 PUT 请求发出去。所以跨域场景里OPTIONS 请求占的比重很高。很多接口排错时第一眼看到 403、404 的往往是这个预检请求出了问题。1.3 关键认知CORS 校验是浏览器做的一道“放行测试”这句话很多人没转过弯来服务端其实不做拦截。只要后端没有额外的权限过滤器一个跨域请求会照常命中 Controller、照常执行业务、照常返回数据。真正把响应挡下来的是浏览器它在拿到响应后检查 CORS 头不合格就直接不让前端脚本读取。这也是为什么大家用 curl、Postman 测接口时一切正常压根不带 Origin 头后端根本不会去校验跨域配置一换到浏览器跨域调用就报错。理解了这一点排错思路就很清晰了跨域配置的目标不是“让后端拒绝或允许”而是“让后端在响应里按 CORS 协议把正确的头带回去”。而 Servlet 规范里的 Filter恰好能在请求进入 Controller 之前、在响应返回给浏览器之前统一把这件事干完。Spring 里专门做这件事的就是 CorsFilter。2. Spring 里的 CORS 配置方式四种写法选错就要返工2.1 CrossOrigin 注解快速但不全局Spring 最懒人的跨域配置是直接往 Controller 类或方法上挂注解RestController RequestMapping(/api/user) public class UserController { CrossOrigin(origins http://localhost:3000) GetMapping public User get() { return new User(张三); } }开发环境联调用这个确实快后端同事一看就懂。但它的局限很明显第一注解挂在 HandlerMapping 这个层级。请求要先找到对应的 Handler 方法才有机会解析注解里的跨域配置。如果前端请求的路径本身没有对应的 Controller 方法比如 404 页面或者预检请求根本没走到 HandlerMappingCORS 头就不会被处理。第二配置散落。一个系统几十个 Controller每个人各写各的源、各写各的允许方法总有一天出现“这个接口能跨、那个接口不能跨”的诡异局面。排查时你根本不知道哪个接口漏了注解。所以我现在的看法是CrossOrigin适合单点快速验证或者临时给某个接口开个口子不适合作为项目的全局跨域方案。2.2 WebMvcConfigurer.addCorsMappings全局配置但仍是 MVC 层比注解稍微体面一点的是集中注册全局跨域映射Configuration public class WebConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(https://admin.example.com) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }这套配置会把匹配/api/**的路径统一处理源码层面最终也是靠AbstractHandlerMapping里的CorsInterceptor生效。它依然属于 MVC 内部的处理机制遇到请求路径根本没有对应 Handler 的情况效果同样有限。但在纯 Spring MVC 项目里这种配置方式简洁、集中作为全局方案完全够用。真正复杂的情况出在引入 Spring Security、网关、OAuth 之后。2.3 注册 CorsFilter真正意义上的“Servlet 过滤器级全局方案”标题里写的 CorsFilter是 Spring 提供的一个标准OncePerRequestFilter。它活在 Servlet 容器过滤器链里位置上排在 DispatcherServlet 之前天然比 HandlerMapping 那一层抢先一步。它最大的优势有两个。一是只要过滤器路径匹配不管请求有没有对应 Controller都会执行跨域校验和响应头填充二是预检请求可以在 Filter 这一层直接被截断处理并返回压根不用继续往业务链路里传。对无状态的纯 API 项目来说这是最干净、最可控的一种做法。一个典型的注册方式是这样Configuration public class CorsConfig { Bean public FilterRegistrationBeanCorsFilter corsFilter() { UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); CorsConfiguration config new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOriginPattern(*); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setMaxAge(3600L); source.registerCorsConfiguration(/**, config); CorsFilter filter new CorsFilter(source); FilterRegistrationBeanCorsFilter bean new FilterRegistrationBean(filter); bean.setOrder(Ordered.HIGHEST_PRECEDENCE); return bean; } }后面第 3 章会拆解这套配置的细节这里先记住结论CorsFilter是 Spring 里跨域方案最接近“全局统一收口”的形态也最贴合题目里讲的“Filter”概念。2.4 Spring Security 的 cors()好多人“配了没效果”是栽在这如果项目里集成了spring-boot-starter-security事情会突然变得复杂。前面配好的 CorsFilter 不一定能畅通无阻因为 Spring Security 自己有一条庞大的过滤器链它会先把请求拦截住。你很可能遇到这样的场景CorsFilter 明明注册了跨域配置写得没错浏览器还是报 CORSNetwork 面板里却显示 OPTIONS 请求返回 403。这是因为 Spring Security 默认会拦截所有请求OPTIONS 预检请求也一样被当作普通请求拿去走认证授权逻辑了认证不过直接拒绝压根轮不到真正执行 CORS 响应头写入。解决办法是在 Security 配置里显式开启http.cors(withDefaults())这一行会把 CorsFilter 纳入 Security 的过滤链放在认证授权过滤器之前让预检请求先被跨域逻辑处理掉。详细原理和踩坑场景我在第 4 章展开。先放一张对比表把几种方式的关键差异理顺配置方式生效层级能否覆盖无 Handler 的路径与 Security 配合适合场景CrossOrigin 注解HandlerMapping否不够用开发期快速放行addCorsMappingsMVC HandlerMapping否需 Security 放行 OPTIONS纯 MVC 全局方案CorsFilterServlet 过滤器链是需设置在 Security 链之前或开启 http.cors()全局统一收口首选Security http.cors()Security 过滤链是天然集成极推荐有 Security 必用3. CorsFilter 源码拆解一个过滤器如何控制全局跨域3.1 CorsFilter 的“骨架”OncePerRequestFilter 与两个核心组件先去看源码。CorsFilter继承自OncePerRequestFilter后者保证同一个请求在过滤器链中只执行一次避免因为 forward、include 等内部转发把过滤动作重复执行。CorsFilter 内部握着两个核心组件CorsConfigurationSource和CorsProcessor。前者负责根据当前请求定位“该用哪套跨域配置”后者负责真正做校验、写响应头。核心逻辑浓缩在doFilterInternal里protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException { CorsConfiguration corsConfiguration this.configSource.getCorsConfiguration(request); if (!this.processor.processRequest(corsConfiguration, request, response)) { filterChain.doFilter(request, response); } }关键点在这里processRequest返回false表示“跨域处理没有拦截请继续走后续过滤器链”返回true则表示“我已经把预检请求处理完了业务链路不用再走”。3.2 UrlBasedCorsConfigurationSource按路径匹配跨域规则配置源最常用的实现是UrlBasedCorsConfigurationSource它用类似 Ant 的路径匹配规则来划分不同的跨域策略。UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); CorsConfiguration config new CorsConfiguration(); source.registerCorsConfiguration(/api/**, config); source.registerCorsConfiguration(/admin/**, anotherConfig);请求进来后它会按请求路径找到最匹配的CorsConfiguration。如果你配了/api/**再配了一个/**做兜底就能实现“默认全部放行个别敏感接口单独收紧”的精细控制。这种路径级策略是注解方案很难做到的。3.3 DefaultCorsProcessor预检、实际请求与拒绝策略真正干活的DefaultCorsProcessor处理逻辑大概是这样的几步第一步判断请求是不是 CORS 请求。请求头里必须有Origin否则直接返回false让请求继续往后走。这解释了为什么 curl 不带 Origin 头时跨域配置完全不参与执行。第二步根据CorsConfigurationSource拿配置。如果当前路径没有匹配到任何跨域配置同样直接放行不设置任何 CORS 头业务照常处理。第三步对于实际请求非预检校验Origin是否在允许名单里。如果允许就把Access-Control-Allow-Origin、Access-Control-Allow-Credentials等头写进响应然后返回false原样放行到 Controller如果不允许则什么都不写响应当中不会有 CORS 头。浏览器拿到这种响应自然判定跨域失败。第四步对于预检请求OPTIONS且带Access-Control-Request-Method头需要额外校验请求方法是否在允许列表、请求头是否在允许列表。校验不通过会直接返回 403校验通过则把Access-Control-Allow-Methods、Access-Control-Allow-Headers、Access-Control-Max-Age等头写满然后返回true表示预检到此结束后续认证授权和 Controller 都不用再执行了。这里有一个非常反直觉的细节对于普通跨域请求CorsFilter 并不会主动返回错误状态码它只是“不给你写 CORS 头”让浏览器去拦。真正明确返回 403 的是预检请求校验失败的情况。3.4 一份能直接抄的 CorsFilter 配置直接给一份我在实际项目里常用的完整配置把注释写清楚Configuration public class CorsConfig { Bean public FilterRegistrationBeanCorsFilter corsFilter() { UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); CorsConfiguration config new CorsConfiguration(); // 允许携带 Cookie注意和 allowedOrigins(*) 不能同时用 config.setAllowCredentials(true); // 用 allowedOriginPatterns 代替 allowedOrigins(*)可以配合 allowCredentialstrue config.addAllowedOriginPattern(*); // 允许所有请求头 config.addAllowedHeader(*); // 允许所有方法GET/POST/PUT/DELETE/OPTIONS 都包含 config.addAllowedMethod(*); // 预检结果缓存 1 小时单位是秒 config.setMaxAge(3600L); source.registerCorsConfiguration(/**, config); CorsFilter filter new CorsFilter(source); FilterRegistrationBeanCorsFilter bean new FilterRegistrationBean(filter); // 数字越小越靠前确保 CorsFilter 在安全过滤器链之前执行 bean.setOrder(Ordered.HIGHEST_PRECEDENCE); return bean; } }这里建议无脑把setOrder设为Ordered.HIGHEST_PRECEDENCE。如果项目里同时也接了 Spring Security后面章节会说明更推荐的做法但手动注册 Filter 时让它的优先级尽量靠前永远不会错。4. Spring Security 联手 CorsFilter过滤器顺序才是命门4.1 典型现象CorsFilter 配好了浏览器还是报 CORS日志里却挂着 403真实项目中很多人折腾跨域失败问题不出在 CORS 配置本身而出在过滤器顺序。Spring Security 本质也是一条 Servlet Filter 链而且 Spring Boot 自动配置时给它的默认 order 非常靠前默认是-100。如果你的 CorsFilter 在它后面或者根本没有被 Security 意识到OPTIONS 预检请求就会被 Security 的认证过滤器拦下返回 403。从浏览器视角看预检请求得到 403自然得不到任何 Access-Control-Allow-* 头于是报 CORS policy 错误而后端体系里Security 的过滤器日志里却记录的是“认证失败 403”。两边信息看起来完全对不上其实是同一个请求的两面。4.2 http.cors() 到底做了什么解决这个问题最标准的姿势是在 Security 配置里调用http.cors()。Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .cors(withDefaults()) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth - auth .requestMatchers(/api/public/**).permitAll() .anyRequest().authenticated()) ; return http.build(); } }CorsConfigurer在开启后会优先从 Spring 容器里找CorsConfigurationSource或CorsFilter类型的 Bean。找到了就直接用找不到就自己 new 一个默认配置的 CorsFilter放到 Security 过滤链的最前面。这样一来预检请求会被优先处理不用走后面的认证授权。所以最干净的做法其实不是手动注册 Filter而是只提供一个CorsConfigurationSourceBean然后让 Security 自己组装Configuration public class CorsConfig { Bean public CorsConfigurationSource corsConfigurationSource() { UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); CorsConfiguration config new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOriginPattern(*); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setMaxAge(3600L); source.registerCorsConfiguration(/**, config); return source; } }这样http.cors()会直接复用这个配置源不会重复生成多个 CORS 过滤器。4.3 手动注册 CorsFilter 时的顺序问题如果你的项目因为历史原因必须手动注册 CorsFilter那就要注意和 Security 的先后关系。如果两个 Filter 都加到链上建议给 CorsFilter 设置一个小于 Security 默认 order 的值比如用Ordered.HIGHEST_PRECEDENCE让它在最前面先处理 OPTIONS。不过手动注册以后要非常小心响应头重复的问题。Filter 返回响应时写了一遍Access-Control-Allow-OriginSecurity 的 CorsFilter 又写一遍最终响应头里会出现两个一模一样的 CORS 头。浏览器虽然多数情况能容忍但排错时容易绕晕。我现在更倾向的方案是项目里有 Security 就只走http.cors()加CorsConfigurationSourceBean彻底不手动注册 Filter项目里没有 Security 才直接注册 CorsFilter。两条路各管各的互不叠加。4.4 403 还是 CORS 报错两种报错怎么区分实际排错时很多人被“CORS 报错还是权限报错”搞晕。给一个简易区分表Network 里的现象大概率原因检查方向OPTIONS 返回 403Security 拦截预检或预检校验没通过Security 里有没有开 http.cors()OPTIONS 返回 404路径没有对应 Handler配置层在 MVC 之后改用 CorsFilter 这类 Servlet 层方案返回 200但没有 Access-Control-Allow-Origin 头跨域配置没生效或没命中检查路径匹配、是否多个配置源有 CORS 头但浏览器仍报错值匹配不上或 allowCredentials 与通配符冲突检查 Origin 精确匹配、allowedOriginPatterns请求返回 401认证失败和 CORS 无关看 Security 的认证链5. 预检请求的隐藏坑与跨域细节5.1 为什么 OPTIONS 请求经常“404”注解和addCorsMappings方案都依赖 HandlerMapping 找到目标 Handler 后才能返回 CORS 配置。如果前端请求的路径本身就不存在比如/api/nonexist预检请求到达 DispatcherServlet 后发现没有对应 Handler直接返回 404404 响应里又没有跨域头浏览器报 CORS 错误。这种问题用 CorsFilter 就不会出现。它不管有没有 Handler只要路径匹配/api/**就在过滤器层把预检请求处理掉跟着返回 200 和正确的 CORS 头。这个差异在实际项目中影响很大特别是前端联调初期路径写错是家常便饭错误提示既像 404 又像 CORS容易让人绕进去。5.2 allowCredentials 与 allowedOrigins* 的兼容性红线浏览器规范有一条硬性要求当Access-Control-Allow-Credentials: true时Access-Control-Allow-Origin不允许是*必须返回具体的源地址。否则 Cookie 会跟着跨域请求到处跑安全上没法接受。Spring 对此的处理也很刚性你如果直接写allowedOrigins(*)又同时setAllowCredentials(true)项目启动时就会抛IllegalArgumentException。Spring 5.3 之后给出了更灵活的方案allowedOriginPatterns它既支持通配符又允许与allowCredentialstrue共存config.setAllowCredentials(true); config.addAllowedOriginPattern(https://*.example.com); config.addAllowedOriginPattern(http://localhost:*);只要协议或子域名确定用allowedOriginPatterns比allowedOrigins(*)安全得多语义也更清晰。5.3 maxAge给预检请求加缓存别让浏览器反复“举手提问”每个跨域实际请求之前如果都要先发一个 OPTIONS 预检等于请求数直接翻倍。Access-Control-Max-Age就是告诉浏览器这次预检结果可以缓存多长时间在缓存有效期内同类请求直接发真实请求不再探测。config.setMaxAge(3600L);单位是秒上面配置表示缓存 1 小时。开发阶段可以设小一点避免改了配置浏览器还在用老缓存线上建议至少 600 秒以上能明显减少 OPTIONS 请求量。改完配置想立即验证记得先清一下浏览器缓存或者干脆用无痕窗口。5.4 自定义请求头和 Access-Control-Allow-Headers经常漏配前端要在跨域请求里带自定义头比如Authorization、X-Trace-Id浏览器会把它作为请求头的一部分放进预检请求要求后端明确允许。如果allowedHeaders里没配预检直接失败。最省心的做法是config.addAllowedHeader(*)允许全部请求头。但如果你在意兼容性或安全收紧就按实际用到的头显式配置config.setAllowedHeaders(Arrays.asList(Content-Type, Authorization, X-Requested-With));5.5 响应头重复与多配置叠加跨域配置最怕多个来源同时生效。我在一个项目里见过addCorsMappings配了一份、Security 的http.cors()又配了一份、网关层还补了一份结果响应头里三个Access-Control-Allow-Origin。浏览器一般不炸但排查时你根本不知道哪一份真正在起作用。建议全局只保留一个 CORS 策略出口。检查办法很简单curl 看响应头数数出现了几个Access-Control-Allow-Origin。6. 实测排错从浏览器报错到 curl 复现到确认生效6.1 用 curl 模拟浏览器预检请求浏览器只是一个 CORS 协议的客户端它做的一切都可以用 curl 手动模拟。模拟预检请求curl -i -X OPTIONS http://localhost:8080/api/user \ -H Origin: http://localhost:3000 \ -H Access-Control-Request-Method: GET \ -H Access-Control-Request-Headers: Content-Type,Authorization正常返回应该能看到类似内容HTTP/1.1 200 Access-Control-Allow-Origin: http://localhost:3000 Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS Access-Control-Allow-Headers: Content-Type,Authorization Access-Control-Max-Age: 3600再模拟一个带 Origin 的实际请求curl -i http://localhost:8080/api/user \ -H Origin: http://localhost:3000这次关注响应里有没有Access-Control-Allow-Origin值是不是跟前端页面源精确一致。6.2 一份“CORS 排错标准动作”清单按这个顺序查基本能定位 80% 的跨域问题确认前端的真实源协议、域名、端口一个字符都不能差。localhost和127.0.0.1是两个不同的源。打开浏览器 Network看这个请求有没有触发 OPTIONS 预检预检状态码是多少403、404、还是 200。用 curl 手动发 OPTIONS看响应头里有没有 Access-Control-Allow-* 系列字段如果没有说明跨域处理逻辑压根没执行到。检查项目里有没有 Spring Security如果有确认http.cors()是否开启以及 Security 过滤链顺序。检查全局跨域配置里的 origin 是否和页面源精确匹配用了allowedOrigins(*)时确认没有同时开allowCredentialstrue。检查是不是存在多份跨域配置Filter、Security、MVC、网关至少保留一个出口。如果请求经过了 Nginx 或其他反向代理确认代理层没有额外改写或覆盖 CORS 头。最后用无痕窗口重新验证一次排除浏览器缓存和插件干扰。6.3 我踩过的几个坑逐个给你排掉第一个坑是localhost和127.0.0.1之争。前端页面地址栏输入http://localhost:3000接口却填http://127.0.0.1:8080这在浏览器看来就是两个源。解决方法是前端代码里统一用http://localhost:8080或在运行时读取环境变量。第二个坑是 GET 请求没有预检所以没有 OPTIONS直接就是一发真实请求报 CORS 错。很多人一看 Network 里没有 OPTIONS就认为跨域配置根本没生效其实是 GET 本来就不走预检需要检查的是真实响应头。第三个坑是网关和下游服务同时配了 CORS。统一网关是一个理想方案但下游服务如果不改每个服务又在给自己加白名单两边规则一旦不一致产生的响应头会让浏览器无所适从。改成约定“只由出入口统一加 CORS 头内部服务一律不配”协调成本反而最低。第四个坑是调试时改了配置但浏览器还在用旧缓存。这时候最快的办法是开一个无痕窗口。如果你用 Chrome开发阶段可以先不跨域而是通过配置代理、同源部署来降低联调成本等预发了再切回真实跨域环境这样能少看很多没营养的报错。我自己现在处理跨域的固定套路是只要项目用了 Spring Boot跨域配置就统一走 CorsFilter如果带了 Security就只提供CorsConfigurationSourceBean 并开启http.cors()绝不手动注册第二个 Filter。这样无论请求打到哪个路径、是否有对应的 Handler预检都能在过滤链最前方被处理排错时只需要查一个配置位置省心太多。
返回列表