Java框架 SpringCloud 快速入门: 网关的 CORS 跨域配置

概述

服务拆分之后,前端页面(比如 localhost:8090)去调网关(localhost:10010)会被浏览器拦下来,控制台一片红。这篇把跨域到底拦在哪一层、预检请求怎么走、以及网关为什么是配 CORS 最省事的地方讲清楚,最后给一份能直接复制的 application.yml。

纲要

  • 同源策略:协议 + 域名 + 端口三者全同才算同源,跨域是浏览器的行为,不是服务器报错
  • 判定规则:域名不同、域名相同端口不同,都属于跨域
  • 简单请求 vs 预检请求(OPTIONS)的触发条件与判定表
  • 预检请求的两轮交互时序(浏览器 → 网关 → 微服务)
  • 网关作为跨域统一入口的价值:配一次,而不是每个微服务配一遍
  • 动手:网关 globalcors 全局 CORS 配置逐字段拆解
  • 高频坑:allowedOrigins: "*" 与 allowCredentials: true 不能同时用
  • 手写 CorsWebFilter Bean 的 Java 配置方式,以及与 yml 方式的取舍
  • 验证手段:curl -I 看响应头、浏览器控制台看报错,以及 curl 验证的局限
  • 实战坑清单:重复响应头、预检被鉴权过滤器拦、漏配 OPTIONS、生产用 * 的风险

先分清:同源策略到底管什么

"同源"的判定只看三样东西:协议、域名、端口,三个全部相同才叫同源。任何一项不同,就是跨域。

页面地址 请求地址 是否跨域 原因
http://localhost:8080 http://localhost:8081 是 域名相同,端口不同
http://localhost:8090 http://127.0.0.1:10010 是 域名(host)不同,端口也不同
http://localhost:8090 http://localhost:10010 是 端口不同(8090 vs 10010)
http://www.taobao.com http://www.taobao.org 是 一级域名不同
https://shop.example.com http://shop.example.com 是 协议不同(https vs http)
http://localhost:10010 http://localhost:10010 否 三者全同

浏览器为什么要这么干?因为 Cookie、Session、Authorization 头都是跟着域名走的。如果没有同源策略,你打开一个恶意站点,它就能用你的身份去请求银行、邮箱、后台管理系统的接口,把返回的 JSON 读走------你的登录态还在,接口也不会拒绝它,因为请求确实是浏览器发出去的。所以限制的目的不是"不让发请求",而是不让别的站点读到你站点下的响应内容。

关键认知:拦住你的是浏览器,不是服务器

这是最容易搞错的一点。order-service 调 user-service(8080 → 8081)从来没有跨域问题,因为它们之间是 RestTemplate / Feign 发起的普通 HTTP 调用,全程没有浏览器参与。
#mermaid-svg-iq6QZCh2CiZozeQx{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-iq6QZCh2CiZozeQx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-iq6QZCh2CiZozeQx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-iq6QZCh2CiZozeQx .error-icon{fill:#552222;}#mermaid-svg-iq6QZCh2CiZozeQx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-iq6QZCh2CiZozeQx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-iq6QZCh2CiZozeQx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-iq6QZCh2CiZozeQx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-iq6QZCh2CiZozeQx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-iq6QZCh2CiZozeQx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-iq6QZCh2CiZozeQx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-iq6QZCh2CiZozeQx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-iq6QZCh2CiZozeQx .marker.cross{stroke:#333333;}#mermaid-svg-iq6QZCh2CiZozeQx svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-iq6QZCh2CiZozeQx p{margin:0;}#mermaid-svg-iq6QZCh2CiZozeQx .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-iq6QZCh2CiZozeQx .cluster-label text{fill:#333;}#mermaid-svg-iq6QZCh2CiZozeQx .cluster-label span{color:#333;}#mermaid-svg-iq6QZCh2CiZozeQx .cluster-label span p{background-color:transparent;}#mermaid-svg-iq6QZCh2CiZozeQx .label text,#mermaid-svg-iq6QZCh2CiZozeQx span{fill:#333;color:#333;}#mermaid-svg-iq6QZCh2CiZozeQx .node rect,#mermaid-svg-iq6QZCh2CiZozeQx .node circle,#mermaid-svg-iq6QZCh2CiZozeQx .node ellipse,#mermaid-svg-iq6QZCh2CiZozeQx .node polygon,#mermaid-svg-iq6QZCh2CiZozeQx .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-iq6QZCh2CiZozeQx .rough-node .label text,#mermaid-svg-iq6QZCh2CiZozeQx .node .label text,#mermaid-svg-iq6QZCh2CiZozeQx .image-shape .label,#mermaid-svg-iq6QZCh2CiZozeQx .icon-shape .label{text-anchor:middle;}#mermaid-svg-iq6QZCh2CiZozeQx .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-iq6QZCh2CiZozeQx .rough-node .label,#mermaid-svg-iq6QZCh2CiZozeQx .node .label,#mermaid-svg-iq6QZCh2CiZozeQx .image-shape .label,#mermaid-svg-iq6QZCh2CiZozeQx .icon-shape .label{text-align:center;}#mermaid-svg-iq6QZCh2CiZozeQx .node.clickable{cursor:pointer;}#mermaid-svg-iq6QZCh2CiZozeQx .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-iq6QZCh2CiZozeQx .arrowheadPath{fill:#333333;}#mermaid-svg-iq6QZCh2CiZozeQx .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-iq6QZCh2CiZozeQx .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-iq6QZCh2CiZozeQx .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iq6QZCh2CiZozeQx .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-iq6QZCh2CiZozeQx .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iq6QZCh2CiZozeQx .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-iq6QZCh2CiZozeQx .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-iq6QZCh2CiZozeQx .cluster text{fill:#333;}#mermaid-svg-iq6QZCh2CiZozeQx .cluster span{color:#333;}#mermaid-svg-iq6QZCh2CiZozeQx div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-iq6QZCh2CiZozeQx .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-iq6QZCh2CiZozeQx rect.text{fill:none;stroke-width:0;}#mermaid-svg-iq6QZCh2CiZozeQx .icon-shape,#mermaid-svg-iq6QZCh2CiZozeQx .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iq6QZCh2CiZozeQx .icon-shape p,#mermaid-svg-iq6QZCh2CiZozeQx .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-iq6QZCh2CiZozeQx .icon-shape .label rect,#mermaid-svg-iq6QZCh2CiZozeQx .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iq6QZCh2CiZozeQx .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-iq6QZCh2CiZozeQx .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-iq6QZCh2CiZozeQx :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 没有浏览器参与
RestTemplate / Feign
正常返回
order-service
user-service
有浏览器参与

  1. 发出 ajax 请求
  2. 正常转发
  3. 正常返回 JSON
  4. 响应头缺 Access-Control-Allow-Origin
  5. 浏览器丢弃响应, 控制台报错
    页面 localhost:8090
    网关 localhost:10010
    user-service
    页面拿不到数据

从这张图能看到一个反直觉的事实:服务端其实收到请求了,也正常处理并返回了响应 。你在网关和 user-service 的日志里能看到这条请求完整走完,状态码 200。是浏览器在拿到响应后检查响应头,发现没有 Access-Control-Allow-Origin(或者值不匹配当前页面源),于是把响应内容丢掉了,并在控制台打印:

text 复制代码
Access to XMLHttpRequest at 'http://localhost:10010/user/1' from origin 'http://localhost:8090'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.

所以排查跨域时,先别急着改后端业务代码------后端很可能一点问题都没有。要看的是响应头。

简单请求与预检请求

CORS 把请求分成两类。简单请求浏览器直接发,服务端在响应里补上 Access-Control-Allow-Origin 就行;非简单请求会多一轮 ,浏览器先发一个 OPTIONS 请求去"问路",这叫预检(preflight)。

判定项 简单请求 触发预检(非简单请求)
请求方法 只能是 GET、HEAD、POST 用了 PUT、DELETE、PATCH 等
请求头 只能是 Accept、Accept-Language、Content-Language、Content-Type(且受限)、Range 等安全头 带了自定义头,例如 Authorization、token、X-Requested-With
Content-Type 只能是 text/plain、multipart/form-data、application/x-www-form-urlencoded 用了 application/json
是否携带 Cookie 不强制预检 withCredentials = true 时另受限制

注意最后一行:Content-Type: application/json 是最常见的触发条件。也就是说,只要前端 POST 一段 JSON,就必然走 OPTIONS 预检 。很多同学配了 allowedMethods 却漏掉 OPTIONS,表现就是"请求从来没到过后端",原因就在这里。

预检请求的两轮交互

预检请求的作用是:浏览器先问服务器"我要用 PUT 方法、带 Authorization 头访问你的接口,你允许吗",服务器用 Access-Control-Allow-* 系列头回答,浏览器满意了才发真实请求。
user-service 网关 (localhost:10010) 浏览器 (页面 localhost:8090) user-service 网关 (localhost:10010) 浏览器 (页面 localhost:8090) #mermaid-svg-enkVSDIX3FPaf4xU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-enkVSDIX3FPaf4xU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-enkVSDIX3FPaf4xU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-enkVSDIX3FPaf4xU .error-icon{fill:#552222;}#mermaid-svg-enkVSDIX3FPaf4xU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-enkVSDIX3FPaf4xU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-enkVSDIX3FPaf4xU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-enkVSDIX3FPaf4xU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-enkVSDIX3FPaf4xU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-enkVSDIX3FPaf4xU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-enkVSDIX3FPaf4xU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-enkVSDIX3FPaf4xU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-enkVSDIX3FPaf4xU .marker.cross{stroke:#333333;}#mermaid-svg-enkVSDIX3FPaf4xU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-enkVSDIX3FPaf4xU p{margin:0;}#mermaid-svg-enkVSDIX3FPaf4xU .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-enkVSDIX3FPaf4xU text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-enkVSDIX3FPaf4xU .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-enkVSDIX3FPaf4xU .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-enkVSDIX3FPaf4xU .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-enkVSDIX3FPaf4xU .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-enkVSDIX3FPaf4xU #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-enkVSDIX3FPaf4xU .sequenceNumber{fill:white;}#mermaid-svg-enkVSDIX3FPaf4xU #sequencenumber{fill:#333;}#mermaid-svg-enkVSDIX3FPaf4xU #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-enkVSDIX3FPaf4xU .messageText{fill:#333;stroke:none;}#mermaid-svg-enkVSDIX3FPaf4xU .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-enkVSDIX3FPaf4xU .labelText,#mermaid-svg-enkVSDIX3FPaf4xU .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-enkVSDIX3FPaf4xU .loopText,#mermaid-svg-enkVSDIX3FPaf4xU .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-enkVSDIX3FPaf4xU .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-enkVSDIX3FPaf4xU .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-enkVSDIX3FPaf4xU .noteText,#mermaid-svg-enkVSDIX3FPaf4xU .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-enkVSDIX3FPaf4xU .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-enkVSDIX3FPaf4xU .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-enkVSDIX3FPaf4xU .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-enkVSDIX3FPaf4xU .actorPopupMenu{position:absolute;}#mermaid-svg-enkVSDIX3FPaf4xU .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-enkVSDIX3FPaf4xU .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-enkVSDIX3FPaf4xU .actor-man circle,#mermaid-svg-enkVSDIX3FPaf4xU line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-enkVSDIX3FPaf4xU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 第一轮:预检请求 命中 globalcors 配置 预检通过,浏览器缓存该结果 第二轮:真实请求 响应头校验通过,交还给前端代码 OPTIONS /user/1 Origin: http://localhost:8090 Access-Control-Request-Method: PUT Access-Control-Request-Headers: authorization 1 204 No Content Access-Control-Allow-Origin: http://localhost:8090 Access-Control-Allow-Methods: GET,POST,DELETE,PUT,OPTIONS Access-Control-Allow-Headers: * Access-Control-Allow-Credentials: true Access-Control-Max-Age: 360000 2 PUT /user/1 Origin: http://localhost:8090 Content-Type: application/json 3 转发(lb://userservice) 4 200 OK {json} 5 200 OK Access-Control-Allow-Origin: http://localhost:8090 6

第三轮的 Access-Control-Allow-Origin 在真实请求的响应里也必须带,不能只在预检响应里给。预检只决定"允不允许发",真实响应决定"允不允许读"。

为什么跨域应该配在网关

微服务的调用链是 前端 → 网关 → 微服务。前端只认识网关地址,所有请求都从这里进。把 CORS 配在网关,好处很直接:

  • 只配一次 。配在网关是入口层统一处理,不用在 user-service、order-service 里各写一遍。
  • 服务可以专心做业务。微服务之间是内网调用,本来就不涉及浏览器同源策略,没必要知道前端的域名是什么。
  • 避免重复响应头。这是最要命的一条。

如果网关配了、某个微服务里也配了(比如早期用 @CrossOrigin 注解或者加了 CorsWebFilter),浏览器拿到的响应里会出现两个同名头:

text 复制代码
Access-Control-Allow-Origin: http://localhost:8090
Access-Control-Allow-Origin: http://localhost:8090

浏览器立刻报错:

text 复制代码
Access to XMLHttpRequest at 'http://localhost:10010/user/1' from origin 'http://localhost:8090'
has been blocked by CORS policy: The 'Access-Control-Allow-Origin' header contains
multiple values 'http://localhost:8090, http://localhost:8090', but only one is allowed.

看到 contains multiple values 或者 contains multiple values, but only one is allowed,基本可以断定:网关和下游服务两边都配了跨域 。这时要做的不是调参数,而是删掉一侧的配置,只保留网关这一处。搜一下下游服务里有没有 @CrossOrigin、CorsWebFilter、WebMvcConfigurer#addCorsMappings 和 yml 里的 globalcors,全部清掉。

动手:网关全局 CORS 配置

网关自身就是一个 Spring Boot 模块(gateway),配置写在 src/main/resources/application.yml。

tree 复制代码
cloud-demo
├── gateway
│   └── src/main/
│       ├── java/cn/itcast/gateway
│       │   ├── GatewayApplication.java     # 启动类
│       │   └── AuthorizeFilter.java        # 全局过滤器(鉴权用,下面会提到它和预检的冲突)
│       └── resources/
│           └── application.yml             # 跨域配置写在这里
├── user-service
├── order-service
└── pom.xml

在已有配置的基础上追加下面的内容(讲义口径,globalcors 是 spring.cloud.gateway 下的节点):

yaml 复制代码
server:
  port: 10010

spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: nacos:8848 # nacos地址
    gateway:
      routes:
        - id: user-service                     # 路由标示,必须唯一
          uri: lb://userservice                # 路由的目标地址
          predicates:                          # 路由断言,判断请求是否符合规则
            - Path=/user/**                    # 路径断言
        - id: order-service
          uri: lb://orderservice
          predicates:
            - Path=/order/**
      globalcors: # 全局的跨域处理
        add-to-simple-url-handler-mapping: true # 解决 OPTIONS 请求被拦截的问题
        corsConfigurations:
          '[/**]':
            allowedOrigins: # 允许哪些网站的跨域请求
              - "http://localhost:8090"
            allowedMethods: # 允许的跨域 ajax 的请求方式
              - "GET"
              - "POST"
              - "DELETE"
              - "PUT"
              - "OPTIONS"
            allowedHeaders: "*" # 允许在请求中携带的头信息,* 代表一切请求头
            allowCredentials: true # 是否允许携带 cookie,true 表示允许
            maxAge: 360000 # 这次跨域检测的有效期,单位:秒

字段逐个说清楚:

配置项 含义 说明
globalcors 全局跨域配置的根节点 配在这里,所有路过网关的请求统一生效,不用在路由里单独配
add-to-simple-url-handler-mapping 是否把 CORS 配置加到 SimpleUrlHandlerMapping 上 网关底层是 WebFlux,OPTIONS 预检不经过业务路由。设为 true,预检请求才不会被网关当成普通请求拦掉,这一项必须开
corsConfigurations 跨域规则集合,key 是路径匹配模式 yml 里要写成 '[/**]',表示拦截一切进入网关的请求做跨域预处理。写成 [/**] 不引号也能解析,但加引号更稳妥
allowedOrigins 允许哪些源跨域 写具体来源时必须带协议和端口 (http://localhost:8090),写 localhost:8090 浏览器不认
allowedMethods 允许的跨域请求方法 OPTIONS 一定要列进去,否则预检直接被拒
allowedHeaders 允许携带的请求头 "*" 代表放行所有请求头
allowCredentials 是否允许跨域时携带 Cookie Cookie 里是登录态等敏感信息,允许携带会被浏览器和服务器双向收紧
maxAge 预检结果缓存时间(秒) 360000 秒约 100 小时。缓存期内浏览器不再重复发预检,减少一半请求量

关于 maxAge 值得多说一句:OPTIONS 预检意味着每一次跨域请求都要多发一轮,请求量直接翻倍。给它一个合理有效期,浏览器在有效期内直接按上次的授权结果走,不再询问。生产环境常见的取值是 1800 到 3600 秒,360000 是讲义里为了演示效果给的大值。

命名小提示:Spring Boot 支持 relaxed binding,corsConfigurations 和 cors-configurations 都能被识别;add-to-simple-url-handler-mapping 写成驼峰 addToSimpleUrlHandlerMapping 也一样。讲义用的是前一种写法,照抄不会错。

最容易踩的坑:allowedOrigins: "*" 配 allowCredentials: true 不生效

很多人图省事写:

yaml 复制代码
            allowedOrigins: "*"
            allowCredentials: true

然后发现请求还是失败。这不是配置写错,是浏览器规范不允许 :携带 Cookie 时,Access-Control-Allow-Origin 不能是通配符 *,必须是明确的源。原因很直白------如果服务器说"谁都可以带 Cookie 访问",那就等于把用户凭据向全网开放了。

Spring 在启动或运行期也会直接抛异常拦下这种组合:

text 复制代码
java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot
contain the special value "*" since that cannot be set on the "Access-Control-Allow-Origin"
response header. To allow credentials to a set of origins, list them explicitly or consider
using "allowedOriginPatterns" instead.

异常信息里已经给了答案,两条路:

yaml 复制代码
      globalcors:
        add-to-simple-url-handler-mapping: true
        corsConfigurations:
          '[/**]':
            # 方案一:明确列出允许的源(推荐,生产用这个)
            allowedOrigins:
              - "http://localhost:8090"
              - "https://app.example.com"
            allowedMethods: "*"
            allowedHeaders: "*"
            allowCredentials: true
            maxAge: 3600

如果确实需要匹配一批域名(比如多环境测试域名),用 allowedOriginPatterns 代替 allowedOrigins,它允许通配模式且能与 allowCredentials: true 共存:

yaml 复制代码
      globalcors:
        add-to-simple-url-handler-mapping: true
        corsConfigurations:
          '[/**]':
            allowedOriginPatterns:
              - "http://*.example.com"
              - "http://localhost:*"
            allowedMethods: "*"
            allowedHeaders: "*"
            allowCredentials: true
            maxAge: 3600

反过来,如果前端不需要 带 Cookie(比如用 Authorization 头放 token,而不是靠 Cookie 维持会话),那就关掉凭据,配 allowedOrigins: "*" 也能跑通。二者取其一,别硬凑。

另一种方式:手写 CorsWebFilter Bean

yml 是最省事的做法,但有些场景需要动态判断(比如从数据库读白名单、按环境切换),就得用代码。在网关模块里加一个配置类:

java 复制代码
package cn.itcast.gateway.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsWebFilter;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
import org.springframework.web.util.pattern.PathPatternParser;

import java.util.List;

@Configuration
public class CorsConfig {

    @Bean
    public CorsWebFilter corsWebFilter() {
        CorsConfiguration config = new CorsConfiguration();
        // 允许跨域的源,注意:不能写成 "*"(allowCredentials 为 true 时浏览器会拒绝)
        config.setAllowedOrigins(List.of("http://localhost:8090"));
        // 允许的请求方法,OPTIONS 必须包含,否则预检失败
        config.addAllowedMethod("GET");
        config.addAllowedMethod("POST");
        config.addAllowedMethod("DELETE");
        config.addAllowedMethod("PUT");
        config.addAllowedMethod("OPTIONS");
        // 允许的请求头,* 代表全部
        config.addAllowedHeader("*");
        // 允许携带 Cookie
        config.setAllowCredentials(true);
        // 预检结果缓存 1 小时
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(new PathPatternParser());
        // 对所有路径生效
        source.registerCorsConfiguration("/**", config);
        return new CorsWebFilter(source);
    }
}

两个细节:网关是 WebFlux 技术栈,必须用 org.springframework.web.cors.reactive 包下的响应式 CorsWebFilter,不能用 Servlet 版本的 org.springframework.web.filter.CorsFilter,否则 bean 创建失败或者根本不生效。另外 UrlBasedCorsConfigurationSource 要传 PathPatternParser,这是 WebFlux 下的路径解析器。

两种方式对比:

维度 yml(globalcors) Java(CorsWebFilter Bean)
配置量 一段 yaml,改完重启即可 一个配置类,约 30 行
灵活性 静态配置,适合固定域名 可编程,能读配置中心、数据库动态决定白名单
可读性 集中,一眼看完 分散在 Java 代码里
生效方式 由 Gateway 的 CORS 处理器统一处理 作为 WebFlux 过滤器链的一环
适用场景 绝大多数项目 多租户、动态域名白名单

这两种方式只能选一种 。同时存在时,同一个响应会被写两次 Access-Control-Allow-Origin,浏览器直接报 contains multiple values,就是前面说过的高频坑。项目里用 yml 就够了。

验证:怎么确认配好了

改完 yml 必须重启网关 (globalcors 不走配置中心热更新)。然后有两种验证手段。

用 curl 看响应头

bash 复制代码
curl -I \
  -H "Origin: http://localhost:8090" \
  http://localhost:10010/user/1

-I 只取响应头。配好了会看到(关键就头两行):

text 复制代码
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:8090
Access-Control-Allow-Credentials: true
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: application/json

预检请求也可以单独打一发:

bash 复制代码
curl -i -X OPTIONS \
  -H "Origin: http://localhost:8090" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: authorization" \
  http://localhost:10010/user/1

正常返回 204 No Content,并带上 Access-Control-Allow-Methods、Access-Control-Allow-Headers。如果返回 401,看下一节的"预检被鉴权过滤器拦掉"。

这里有个必须说清的局限 :curl 压根不受同源策略约束。它是命令行工具,不是浏览器,无论有没有 CORS 响应头都会把响应体打印出来。所以"curl 能拿到数据"完全不能证明 "浏览器里不报跨域"。curl 的用途只有一个------确认服务端有没有吐 Access-Control-Allow-* 头;至于浏览器是否接受,还得用浏览器验。

用浏览器控制台看

把前端页面(讲义课前资料里的 index.html,用 ajax 请求网关地址 http://localhost:10010/user/1)放到 tomcat 或 nginx 里,以 localhost:8090 启动,打开 F12 控制台。修复前后各看一次:

  • 修复前:has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present,Network 面板里该请求状态显示为 (failed) 或 CORS error。
  • 修复后:请求正常 200,控制台打印出用户 JSON;Network 面板里能看到 OPTIONS 和 GET 两条记录。

实测下来这个顺序最省时间:先在控制台确认报的到底是"缺头"还是"头重复",再决定是补配置还是删配置。

实战坑清单

现象 原因 处理
contains multiple values ... but only one is allowed 网关和下游微服务都配了跨域,响应头被写了两次 只保留网关一处,删掉下游的 @CrossOrigin / CorsWebFilter / addCorsMappings
启动报 When allowCredentials is true, allowedOrigins cannot contain the special value "*" allowedOrigins: "*" 和 allowCredentials: true 冲突 列具体域名,或改用 allowedOriginPatterns
OPTIONS 预检返回 401 / 请求根本没进业务代码 全局鉴权过滤器把预检请求拦了(预检不带自定义头,鉴权必然失败) 鉴权过滤器里先放行 OPTIONS,见下方代码
配了跨域但还是 CORS 报错,且日志里看不到请求 allowedMethods 漏了 OPTIONS,预检直接被 CORS 处理器拒绝 把 OPTIONS 加进 allowedMethods
预检能过、真实请求失败 真实响应里没带 Access-Control-Allow-Origin(只配在预检路径上) 确认路径模式是 '[/**]',覆盖所有请求
用 IP 访问就报错,用域名正常 源不同,allowedOrigins 里没有 IP 这一项 把 IP 形式也加进白名单,或统一用域名访问

关于 OPTIONS 被鉴权过滤器拦截这条,要展开说。网关里的全局过滤器默认对所有请求生效,包括预检请求。而预检请求不携带 业务自定义头(浏览器不会把 Authorization 放进预检),过滤器一检查就返回 401 UNAUTHORIZED,预检失败,浏览器直接判定跨域被拒,真实请求根本不会发出。日志里看不到真实请求,只有一条 OPTIONS 返回 401。

修法是让过滤器先放行预检。以工程里的 AuthorizeFilter 为例:

java 复制代码
package cn.itcast.gateway;

import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

@Component
public class AuthorizeFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();

        // 关键:CORS 预检请求不带业务请求头,必须放行,否则浏览器判定跨域失败
        if (HttpMethod.OPTIONS.equals(request.getMethod())) {
            return chain.filter(exchange);
        }

        MultiValueMap<String, String> params = request.getQueryParams();
        String auth = params.getFirst("authorization");
        if ("admin".equals(auth)) {
            return chain.filter(exchange);
        }
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        return exchange.getResponse().setComplete();
    }

    @Override
    public int getOrder() {
        return -1;
    }
}

生产环境还有一条:别用 allowedOrigins: "*" 。通配符等于允许任何站点在浏览器里读你的接口响应,配合 allowCredentials 的绕过尝试会让白名单形同虚设。按环境列出真实域名(测试 http://localhost:8090、预发、生产各一条),是成本最低的安全措施。

API 速览

配置项 / API 作用 取值示例
spring.cloud.gateway.globalcors.add-to-simple-url-handler-mapping 让 OPTIONS 预检不被拦截 true
spring.cloud.gateway.globalcors.corsConfigurations 跨域规则集合,key 为路径模式 '[/**]'
allowedOrigins 允许跨域的源,必须带协议与端口 http://localhost:8090
allowedOriginPatterns 允许跨域的源(支持通配),可与凭据共存 http://*.example.com
allowedMethods 允许的请求方法,含 OPTIONS GET / POST / DELETE / PUT / OPTIONS
allowedHeaders 允许携带的请求头 "*"
allowCredentials 是否允许携带 Cookie true / false
maxAge 预检结果缓存时间(秒) 3600
CorsWebFilter WebFlux 下的响应式 CORS 过滤器 org.springframework.web.cors.reactive.CorsWebFilter
UrlBasedCorsConfigurationSource 按路径注册跨域配置 new UrlBasedCorsConfigurationSource(new PathPatternParser())

官方文档

总结

  • 跨域是浏览器的同源策略在起作用,服务器其实正常收到了请求也返回了响应,被丢弃的是响应内容。判断依据是响应头,不是后端日志。
  • 非简单请求(PUT/DELETE、Content-Type: application/json、自定义头)会先发 OPTIONS 预检,网关必须开 add-to-simple-url-handler-mapping 并把 OPTIONS 写进 allowedMethods。
  • 网关是配 CORS 的正确位置:前端只访问网关,配一次即可,微服务保持干净。
  • 那句 has been blocked by CORS policy: The 'Access-Control-Allow-Origin' header contains multiple values,十有八九是网关和下游服务都配了跨域造成的,删掉一侧即可。
  • allowedOrigins: "*" 与 allowCredentials: true 天生冲突,要么列具体域名,要么用 allowedOriginPatterns,要么关掉凭据。
  • 鉴权类全局过滤器要先放行 OPTIONS,否则预检 401、CORS 直接失败。
  • curl 不受同源策略限制,能通不代表浏览器能通;最终判定一律以浏览器控制台为准。
相关推荐
驭渊的小故事1 小时前
NC229023 题解:二分答案求解“最大化最小值“问题
java·算法
小蒜学长1 小时前
基于SSM+VUE的电影售票平台的设计与实现(代码+数据库+LW)
java·vue.js·spring boot·后端·ssm框架·电影售票平台
code2cat1 小时前
【随笔】MCP工具错误怎样分层:先读反馈,再决定下一步
开发语言·后端·ai agent·mcp
朝朝辞暮i1 小时前
C++ 第 36 课:Action——机械臂/VLA 非常重要
开发语言·c++·算法·ros2
谢亮_vipxieliang1 小时前
Go 接口设计原则核心知识点
开发语言·ios·golang
Wang's Blog1 小时前
Java框架 SpringCloud 快速入门: Feign 替代 RestTemplate 实现声明式远程调用
java·开发语言·spring cloud
谢亮_vipxieliang1 小时前
Go 结构体与方法集核心知识点
java·开发语言·golang
郝学胜-神的一滴1 小时前
Numpy数据处理详解 01:NumPy 从环境搭建到入门上手
开发语言·人工智能·python·程序人生·数据分析·numpy
尘客-追梦1 小时前
qmake / jom / 影子构建:把编译管起来
开发语言·c++·qt