首页 / 资讯中心 / 文章详情

Spring Security跨域配置实战:预检请求401与CORS响应头缺失的彻底解法

Spring Security跨域配置实战:预检请求401与CORS响应头缺失的彻底解法 ★ FEATURED ARTICLE
1. 明明是Spring MVC的活为什么跑到Spring Security头上1.1 一次真实的报错Security和CORS在一条链路里打架你们有没有遇到过这种场景前端项目跑在http://localhost:8080后端接口在http://localhost:8081联调的时候前端同事指着浏览器控制台说“接口挂了CORS error”。我过去一看红色的报错写得很清楚Access-Control-Allow-Origin is not present on the requested resource。单独拎出来看这个问题应该由Spring MVC来解决Controller上加个CrossOrigin或者整个项目配一个CorsFilter响应头就带上了。但在Spring Security项目里事情突然变得很奇怪——你在Controller上加了CrossOrigin前端依然报跨域你在SecurityConfig里把OPTIONS请求放行了前端依然报跨域最后你甚至把csrf().disable()都写了还是不行。原因很简单Spring Security的过滤器链跑在DispatcherServlet之前。也就是说请求进入Tomcat之后先被FilterChainProxy这一层拦住认证、授权、会话管理全在这一层做完最后才轮到Spring MVC的Controller。CORS响应头如果只在Controller层生成可请求连Controller都没到就被Security拦在了门外前端自然收不到Access-Control-Allow-Origin。这也是为什么说“SpringSecurity之跨域”是个值得单独写一篇文章的话题。它不是让你学会调一个注解而是要你理解在两条过滤器链叠在一起的时候跨域响应头到底该在哪一层生成以及预检请求为什么总是稀里糊涂地被401。1.2 过滤器链的角度CORS应该站在哪一位先看一个最简化的请求链路客户端请求 → Tomcat 的 FilterChain → Spring Security 的 FilterChainProxy → SecurityFilterChain认证/授权过滤器 → DispatcherServlet → ControllerSpring Security的核心是FilterChainProxy它内部又维护了一条或多条SecurityFilterChain。你配置的http.authorizeHttpRequests()、http.formLogin()、http.oauth2ResourceServer()最终都会变成这条链上的各种Filter。CorsFilter在Spring Security里也是作为一个Filter挂在SecurityFilterChain上的。问题就出在这里CorsFilter应该挂在认证过滤器之前还是之后如果CORS过滤器挂在认证过滤器之后预检请求带着目标请求方法打进来但为了让预检被通过我们需要先给响应加上Access-Control-Allow-Origin。如果认证过滤器先一步执行预检请求没有携带任何用户凭证直接被判定为未认证返回401响应里没有CORS头浏览器就会把它解读成“跨域配置失败”。这不是CORS没配而是CORS头生成得太晚了。正确的姿势是让CorsFilter尽量靠前在认证过滤器判断用户身份之前就把CORS响应头写好。这样即使后续请求认证失败浏览器也能正确接住401而不是被跨域策略挡在门外报一个让人摸不着头脑的CORS错误。Spring Security官方早就意识到了这个问题所以在HttpSecurity上提供了cors()方法。这个方法的作用就是把一个CorsFilter挂到安全过滤器链的合适位置让它尽早处理跨域相关逻辑。1.3 先说清楚三件事同源、预检和凭据在继续往下之前我把三个容易混淆的概念快速捋一遍。**同源same-origin**指协议、域名、端口完全一致。http://localhost:8080和http://localhost:8081端口不同所以是跨域。https://a.example.com和http://a.example.com协议不同也是跨域。**预检preflight**是浏览器自动发起的一个OPTIONS请求。当你的跨域请求不是“简单请求”时比如使用application/json或者携带了自定义Header比如Authorization浏览器会先发一个OPTIONS请求去试探服务器你允许这个跨域请求吗服务器返回的OPTIONS响应里如果带了Access-Control-Allow-Origin、Access-Control-Allow-Headers等头浏览器才会继续发真正的业务请求。**凭据credentials**是CORS里的又一个维度。浏览器请求可以设置为credentials: include表示跨域请求要携带Cookie、TLS证书等凭证。一旦设置了凭据模式服务器的响应头就多了一条Access-Control-Allow-Credentials: true而且Access-Control-Allow-Origin不能再写通配符*。行了基本概念点到为止。接下来我们进入真正的坑为什么Spring Security里的预检请求这么容易401以及那个看起来万能的permitAll()为什么救不了你。2. 预检请求与401的爱恨纠葛permitAll为什么治标不治本2.1 预检请求的空手而来先记住一件事浏览器发出的预检请求除了带一个Origin和Access-Control-Request-Method之外通常不会携带业务凭证。它不会带Cookie不会带Authorization也不会带你的自定义Header比如X-Requested-With。浏览器这样设计是有意为之——预检只是想确认服务器允许哪些跨域行为并不是真的要把数据发给后端。在Spring Security项目里认证过滤器链上通常有类似这样的逻辑http.authorizeHttpRequests(auth - auth .requestMatchers(/login, /public/**).permitAll() .anyRequest().authenticated() );你可能已经猜到结果了。前端往/api/user发一个application/json的POST请求浏览器先发OPTIONS预检。这个预检请求的路径是/api/user方法OPTIONS没有任何登录态。如果Security没有为OPTIONS单独开绿灯它会落到authenticated()的逻辑里然后被判定为未认证返回401。浏览器收到401但这个401的响应体里没有Access-Control-Allow-Origin头于是浏览器不会把这个401暴露给前端而是直接在控制台抛出一个CORS错误。前端看到的是“跨域失败”后端日志里却是“OPTIONS请求未认证”。两个团队互相对着屏幕研究半天谁也说服不了谁。2.2 常见的半截配置antMatchers(HttpMethod.OPTIONS, /**).permitAll()应对这个问题的第一反应通常是“把OPTIONS放行”。很多人的SecurityConfig会改成这样http.authorizeHttpRequests(auth - auth .requestMatchers(HttpMethod.OPTIONS, /**).permitAll() .requestMatchers(/login, /public/**).permitAll() .anyRequest().authenticated() );我不能说这段代码是错的但它确实是治标不治本。它只是让OPTIONS请求不被认证过滤器拦截不再返回401。可是如果你的项目里根本没有配置CORS过滤器这个OPTIONS请求进入Spring MVC之后只是拿到一个普通的200响应仍然不会带Access-Control-Allow-Origin头。浏览器看到预检响应里没有允许跨域的头照样报CORS错误。还有一个更容易踩的变体用CrossOrigin处理Controller层跨域同时在Security里放行OPTIONS。结果预检请求确实到了Controller层CrossOrigin也生效了但等你把真正的POST请求发过去又发现CORS错误——因为POST请求需要认证而认证失败的401响应里没有CORS头。这时候你才会彻底明白CrossOrigin和Security的认证过滤器是两套体系CORS头覆盖不了被Security拒绝的请求。2.3 一次完整的排查链路从401到CORS响应头缺失我这里复盘一个真实排查过程步骤可以复现方便你把思路沉淀下来。**第一步稳定复现。**前端从http://localhost:8080调用后端http://localhost:8081/api/order请求方法POSTContent-Type: application/json前端配置了credentials: include。控制台报CORS错误。**第二步打开Network面板过滤一下请求。**你会在真正的POST请求之前看到一条名为api/order的OPTIONS请求。如果它返回401说明预检被Security拦截了。这是我处理过的绝大多数案例的起点。**第三步回到后端看日志。**后端日志明确记录了一条类似“Authentication failed for OPTIONS /api/order”的记录。直接确认Security的过滤器链接管了预检请求。**第四步先放行OPTIONS做隔离验证。**在SecurityConfig里临时加一行requestMatchers(HttpMethod.OPTIONS, /**).permitAll()重新部署。你会发现报错变了浏览器的提示从“No Access-Control-Allow-Origin header is present”变成了别的或者OPTIONS变成了200。**第五步检查CORS配置。**如果OPTIONS已经200但响应头里没有Access-Control-Allow-Origin说明项目里压根没有CORS过滤器或者CORS过滤器挂在了一个不被Security执行的位置。此时你要做的不是继续调Security的放行规则而是把CORS配置补上。正确做法是声明一个CorsConfigurationSource并在http.cors()里接管它。**第六步验证。**用curl手动模拟预检请求确认OPTIONS响应带上了Access-Control-Allow-Origin。具体命令我放在后面的调试章节。这条链路走完你会发现自己最初犯的错是“把一个CORS配置问题当成了授权放行问题”。两者长得很像但解决方案完全不同。3. Spring Security里配置CORS的三种姿势以及我推荐哪一种3.1 姿势一CrossOrigin注解为什么短期能用长期会炸CrossOrigin可能是很多Java开发者认识CORS的第一站。在Controller的方法或类上加上它Spring MVC会自动处理同源策略的响应头。如果你在一个没有Spring Security的Spring Boot项目里这招确实好用简单直接每个Controller自己管自己的跨域。但放在Spring Security项目里这个方案有几个先天缺陷。第一它只覆盖到Controller层。前面反复说过预检请求可能被Security过滤器链拦下根本活不到Controller层注解自然也不会生效。第二它没法统一管理。Controller一多你可能在几十个方法上重复写CrossOrigin某个新同事漏写一个前端又得半夜把你叫起来排查。第三它处理不了被Security驳回的401/403响应。即便预检过了真正业务请求如果因为未认证而返回401这个401响应不一定带CORS头浏览器一样报错。所以我的结论是CrossOrigin适合纯Spring MVC的开发环境接口、内部调试接口或者临时给某个接口开口子在Spring Security项目中真正服务于前端应用的API不建议靠它保命。3.2 姿势二CorsConfigurationSource http.cors(Customizer.withDefaults())最稳这是我现在最常用的方案没有之一。配置分两个地方一个Bean一个Security配置。先定义一个CorsConfigurationSourceBean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config new CorsConfiguration(); // 明确列出允许的前端来源而不是使用 * config.setAllowedOriginPatterns(List.of( https://admin.example.com, http://localhost:5173 )); config.setAllowedMethods(List.of(GET, POST, PUT, DELETE, OPTIONS)); config.setAllowedHeaders(List.of(*)); config.setAllowCredentials(true); // 预检结果缓存1小时减少OPTIONS请求频率 config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/api/**, config); return source; }然后在SecurityConfig里http .cors(Customizer.withDefaults()) // 其他安全配置 .authorizeHttpRequests(auth - auth .requestMatchers(HttpMethod.OPTIONS, /**).permitAll() .anyRequest().authenticated() );这段配置的关键点在于http.cors(Customizer.withDefaults())。它会让Spring Security从容器中查找名为corsConfigurationSource的Bean默认就是这个方法名然后用它构造一个CorsFilter挂到安全过滤器链的前段。这样一来预检请求在进入认证流程之前响应头就已经准备好了。我还留着requestMatchers(HttpMethod.OPTIONS, /**).permitAll()这是双保险。因为即便CORS过滤器配置正确如果没有放行OPTIONS到授权层预检请求还是可能被后续的认证逻辑拦下。现在两件事分得很清楚cors()负责加CORS响应头permitAll()负责让预检请求通过授权判断。3.3 姿势三自定义CorsFilter注册到过滤器链最前第三招适合一种场景项目里的Spring Security版本比较老或者你想把CORS逻辑独立出来不依赖Spring Security的装配机制。你可以手动注册一个CorsFilter到Servlet容器过滤器中并且把顺序调得比Spring Security还靠前。Bean public FilterRegistrationBeanCorsFilter corsFilterRegistration( CorsConfigurationSource source) { FilterRegistrationBeanCorsFilter registration new FilterRegistrationBean(new CorsFilter(source)); registration.setOrder(Ordered.HIGHEST_PRECEDENCE); return registration; }因为它的优先级最高所以会在FilterChainProxy之前执行CORS响应头会先加到响应上。这样即使后面Security判定401响应也带了CORS头浏览器能把真实状态暴露给前端。这个方案能用但我个人不太推荐把它作为Spring Security项目的主方案。原因有两点。第一绕开了Spring Security的配置体系。团队里其他同事读代码的时候要在两三个地方才能拼出“CORS是怎么生效的”这个全貌维护成本高。第二容易和http.cors()重复配置。万一有人既注册了CorsFilter又在SecurityConfig里写了http.cors(Customizer.withDefaults())那CORS头会被加两次。不同的容器、不同的代理服务器对重复头的处理还不一样最后会拼凑出一个诡异的结果。所以当你看到网上教程说“手动注册CorsFilter解决Spring Security跨域”时可以理解他的动机但更建议你回到姿势二。3.4 Spring Boot 2.7/3.x 与 Spring Security 5.7/6.x 版本差异写这篇东西的时候Spring Boot 3.x 和 Spring Security 6.x 已经很主流了。但网上还残留着大量旧代码很容易让人照抄翻车。我把主要差异整理一下。项目旧写法Security 5.6 / Boot 2.6新写法Security 5.7 / Boot 3.x配置类继承WebSecurityConfigurerAdapter声明SecurityFilterChainBean放行表达式antMatchers(/api/**)requestMatchers(/api/**)CORS接入http.cors().and().csrf().disable()http.cors(Customizer.withDefaults())Lambda风格可选Spring Security 6中成为主流风格CSRF默认默认开启默认开启推荐按无状态与否显式处理一个基于Spring Boot 3.2 Spring Security 6.2的完整配置大概是这样的Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .cors(Customizer.withDefaults()) .authorizeHttpRequests(auth - auth .requestMatchers(HttpMethod.OPTIONS, /**).permitAll() .requestMatchers(/api/auth/**, /error).permitAll() .anyRequest().authenticated() ) .formLogin(form - form.disable()) .httpBasic(basic - basic.disable()); return http.build(); } }关于CSRF我在第4节还会细聊这里先按住不表。但要注意一点csrf.disable()不是跨域的必选项。如果认证方式还是Cookie/Session跨域加携带凭证会把CSRF问题放大直接关闭不是好习惯。4. allowCredentials、Cookie SameSite与OAuth2.1跨域配置里最容易翻车的三件事4.1 allowCredentials(true) 与 allowedOrigins(*) 这对天敌先看一个最常见的报错The value of the Access-Control-Allow-Origin header in the response must not be the wildcard * when the requests credentials mode is include.翻译成人话你允许了跨域携带凭证但允许来源的列表却是*。浏览器出于安全考虑禁止在响应头Access-Control-Allow-Origin为通配符的前提下再返回Access-Control-Allow-Credentials: true。因为这等于允许任意网站拿着用户Cookie去调用你的接口属于严重安全漏洞。这个限制是浏览器层级的你没法用后端的配置绕过去。解决办法只有两个设置明确的setAllowedOrigins()把允许的来源写死使用setAllowedOriginPatterns()允许用通配符匹配一组来源规则。区别在于setAllowedOrigins里不能写*但setAllowedOriginPatterns里可以写https://*.example.com这样的模糊匹配而且可以和allowCredentials(true)共存。如果你的前端域名是动态环境比如测试环境用dev-01.example.comdev-02.example.com那么allowedOriginPatterns(https://*.example.com)会比写死列表更省心。之前我见过一个生产事故运维临时给前端加了新域名结果忘掉通知后端所有跨域请求直接失败。原因是配置里把旧域名写死了。自那以后我在偏动态域名的项目里都优先使用allowedOriginPatterns规则范围放得合理一点效果很好。4.2 谷歌浏览器把SameSite默认Lax之后Session跨域怎么办这条坑和Spring Security本身关系不算大但它会直接伪装成跨域问题所以一定要带上。Chrome 80之后Cookie的SameSite属性默认从None改成了Lax。SameSiteLax意味着浏览器一般不会在跨站请求里主动携带Cookie只有顶级导航等少数场景例外。如果你的后端认证方式还是传统的Session那Cookie可能在前端发出请求时就根本没带上后端收到的是一个匿名请求于是返回401或者302跳转到登录页。更迷惑的是这个场景下浏览器控制台不一定抛“CORS error”而是正常发请求、正常拿401但你的前端全局拦截器一看401就跳登录页用户被莫名其妙踢下线。要解决SessionCookie在跨域、跨站下的携带问题给Cookie设置server: servlet: session: cookie: same-site: none secure: trueSameSiteNone; Secure是跨站携带Cookie的通行证。注意Secure要求HTTPS环境本地联调如果只有HTTPChrome在localhost上有时会放行但生产环境一定要有HTTPS否则Cookie根本不会写入。这里我提醒一句使用Cookie/Session方案时跨域问题的排查重点不只是CORS头还要看Cookie是否真的被带上了。你可以先打开Network面板看请求头的Cookie字段是否存在。没有Cookie那后端的Session认证必然失败这锅不能让CORS背。4.3 JWT与OAuth2.1登录场景下的跨域配置思路如果你的项目已经用JWT情况会简单很多。前端把Token放在Authorization: Bearer xxx请求头上每次请求都主动携带不需要浏览器为您自动管理Cookie所以不存在SameSite和第三方Cookie的问题。对应的CORS配置需要注意两点Access-Control-Allow-Headers里要包含Authorization预检响应要允许Authorization请求头否则浏览器会认为这是一个不被允许的Header。一个OAuth2.1资源服务器风格的配置http .cors(Customizer.withDefaults()) .oauth2ResourceServer(oauth2 - oauth2 .jwt(jwt - jwt.jwtAuthenticationConverter(customJwtConverter())) ) .authorizeHttpRequests(auth - auth .requestMatchers(HttpMethod.OPTIONS, /**).permitAll() .anyRequest().authenticated() );在OAuth2.1的授权码流程里前端SPA需要跳转到授权服务器登录然后再跳回前端页面。这两个跳转都是浏览器顶层导航不涉及CORS不需要在Spring Security里为它们额外配置跨域。真正需要CORS的是SPA用fetch/Axios调资源服务器API、调token端点、调用户信息端点这些场景。所以“OAuth2.1跨域”其实被很多人想复杂了。你要做的还是那两件事授权服务器和资源服务器各自加上CORS允许前端的来源同时确保Token这类敏感信息放在请求头里而不是依赖Cookie传递。4.4 不要混淆跨域和跨站CSRF策略为什么跟着变跨域cross-origin和跨站cross-site不是一回事。协议 域名 端口只要有一个不同就是跨域。跨站看的是scheme协议和registrable domain有效域名端口不影响。举个例子http://localhost:8080和http://localhost:8081是跨域但它们是同站。https://a.example.com和https://b.example.com是跨域也是跨站。为什么CSRF策略要跟着变因为CSRF攻击的根基是“浏览器会自动携带Cookie”。如果你用的是Cookie认证又允许跨站请求携带凭证那攻击者完全可以构造一个恶意页面用它自己的域名向你的接口发包浏览器会把Cookie带上后端无法区分真正的用户请求和攻击请求。这种情况下Spring Security默认开启的CSRF保护其实是你的救星。反过来说如果你已经用JWTToken放在Authorization头里攻击者没法强迫用户的浏览器把Token塞进去CSRF风险就大幅降低。这也是很多无状态API干脆关闭Spring Security CSRF保护的原因。我在实践中的习惯是JWT 无状态API显式关闭CSRF跨域配置只用cors()处理Session/Cookie 跨域凭证保留CSRF且用CookieCsrfTokenRepository.withHttpOnlyFalse()让前端能读取CSRF Token网关统一认证、后端接口服务不对浏览器直接暴露关闭CSRF因为根本没有基于Cookie的认证流程。这里没有标准答案但你要能说清楚“为什么这么选”而不是无脑csrf.disable()。5. 调试跨域问题的手段与真实世界的补充场景5.1 用curl模拟预检请求判断到底是浏览器限制还是后端没配好服务器端的跨域配置是否生效用curl一眼就能看出来。你需要手动模拟一个带Origin和Access-Control-Request-Method的OPTIONS请求curl -i -X OPTIONS \ -H Origin: http://localhost:5173 \ -H Access-Control-Request-Method: POST \ -H Access-Control-Request-Headers: authorization,content-type \ http://localhost:8080/api/order观察响应头。如果配置正确应该能看到HTTP/1.1 200 Access-Control-Allow-Origin: http://localhost:5173 Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS Access-Control-Allow-Headers: authorization,content-type Access-Control-Allow-Credentials: true如果返回401说明OPTIONS被Security拦了优先处理授权放行。如果返回200但没有上述响应头说明CORS过滤器没装配上去检查CorsConfigurationSource到底有没有被Spring Security读到。不要用浏览器作为唯一标准浏览器会把多个错误折叠成一个模糊的CORS提示用curl可以拿到最原始的信息。5.2 浏览器Network面板里的CORS error和401到底在指什么遇到跨域报错打开Network面板先看请求类型。我总结了一个快速判断表现象基本结论下一步OPTIONS 请求返回401预检被Security拦截放行OPTIONS并检查CORS过滤器位置OPTIONS返回200但没有Allow-Origin头CORS过滤器没生效检查CorsConfigurationSource与http.cors()OPTIONS有Allow-Origin但POST没有权宜之计只处理了预检层检查Security认证失败响应是否也经过CorsFilterOPTIONS正常POST返回401/403不是跨域问题是认证/授权问题检查Token、Session、权限配置不少人在第二步和第四步之间反复横跳其实把请求分类看一遍思路就清晰了先定位是“根本没过预检”还是“预检过了、真正请求又出问题”。5.3 uniapp各端与请求库的“伪跨域”现象接着说说前端生态里的一句话提醒。很多人搜“uniapp如何配置跨域”最后跑到后端问Spring Security要不要改配置。其实要分清楚运行的平台App端本质是原生网络请求不受浏览器同源策略约束不存在CORS问题H5端跑在浏览器里CORS规则照常生效需要后端配置跨域或者用h5.proxy做开发代理小程序端有独立的域名白名单机制主要看平台后台配置和浏览器的CORS不是一个套路。如果你用的是axios又要留意withCredentials属性。默认情况下axios不会携带Cookie你需要在请求配置里加withCredentials: true。而一旦加了服务端的Access-Control-Allow-Origin又不能是*。这个往返细节能卡住很多人。这里给后端开发一个实操建议联调时先让前端把请求工具换成curl或Postman如果不换工具就不报错换了浏览器才报错那基本可以锁定是CORS或Cookie问题如果换不换工具都报错大概率是业务接口本身的问题。5.4 网关/nginx层面做了跨域之后Spring Security该怎么配合生产环境很少让后端裸奔前面一般还有nginx或Spring Cloud Gateway。跨域响应头放在网关统一处理比每个服务自己配要规范很多。nginx做了一层CORS后后端Spring Security就去掉http.cors()的配置避免出现两个Access-Control-Allow-Origin头。一个常用的nginx片段location /api/ { if ($request_method OPTIONS) { add_header Access-Control-Allow-Origin https://admin.example.com always; add_header Access-Control-Allow-Methods GET, POST, PUT, DELETE, OPTIONS always; add_header Access-Control-Allow-Headers Authorization, Content-Type always; add_header Access-Control-Max-Age 3600 always; add_header Access-Control-Allow-Credentials true always; return 204; } add_header Access-Control-Allow-Origin https://admin.example.com always; add_header Access-Control-Allow-Credentials true always; proxy_pass http://backend; }这个配置重点有两条用了always保证即使后端返回404、500也带上CORS头OPTIONS请求直接返回204不再打到后端。此时Spring Security后端一般不会再遇到预检请求跨域配置的压力全部在网关。如果你用的是Spring Cloud Gateway则可以配置GlobalCorsConfiguration来统一处理。要注意Gateway的CORS处理同样要先于路由过滤器顺序不对一样会有“响应头缺失”的问题。我个人现在的习惯是网关负责生产环境的CORSSpring Security的http.cors()在开发环境保留一个小范围的放行方便本地联调同时把来源域名收敛到allowedOriginPatterns里绝对不给通配符加凭据的组合留机会。这套配置跑了两个项目没再出过幺蛾子你要直接拿走当模板也可以。
阅读完成 · 觉得有帮助?
咨询建站