fix(cors): 修复生产登录 403「Invalid CORS request」

- 根因:浏览器对**同源**的 POST/PUT/DELETE 也会带 Origin,而 Spring MVC 只看
  "请求里有没有 Origin"(Spring 7.0.9 的 DefaultCorsProcessor 没有同源短路),
  带了又不在白名单里 → 进 Controller 之前直接 403
- WebConfig 的白名单写死为 localhost/内网;注释声称"生产配置见 application-prod.yml
  的 fill.cors.allowed-origins",但该属性根本不存在、也没人读它 ——
  于是"前后端同源部署"的登录/上传/提交必然登不上(生产 2026-10-20 事故)
- 改为配置驱动:FillProperties.Cors + fill.cors.allowed-origins
  (可用 XT_CORS_ALLOWED_ORIGINS 覆盖);prod 默认
  shenxue.jxit.com / admin.shenxue.jxit.com / *.shenxue.jxit.com;
  dev 补上花生壳穿透域名(H5 走穿透时 POST 同样会被拦)
- 启动日志打印生效白名单,排障先看那一行
- 新增 CorsWhitelistTest(3 条):生产 yml 真能绑定、通配符匹配租户子域、陌生源仍被拒
- docs/09 §2.4:本站地址必须在白名单 + 反代必须传
  Host/X-Real-IP/X-Forwarded-For/X-Forwarded-Proto(缺了登录日志 IP 全是 127.0.0.1);
  交接摘要 §一·四十四 记录事故,并写清"1700+ 条 e2e 全绿为什么还是漏了"

为什么 e2e 没抓到:脚本是 Python requests,不带 Origin 头,永远走不到 CORS 分支;
本地浏览器用的 localhost 正好命中写死的 dev 白名单。唯一能暴露它的是"浏览器 + 正式域名"。

验证:生产白名单下 admin / 主域名 / t1 / t12 → 200,evil / 后缀拼接 / http → 403;
真实 RSA 登录 + Origin: https://admin.shenxue.jxit.com → 200、code=0、拿到 token;
mvn test 15/15;verify-all --quick 6/6;check-test-profile 通过。
接口、库表、契约均无变化。
This commit is contained in:
2026-10-20 09:22:16 +08:00
parent 739c0a34a4
commit 50ad577b29
10 changed files with 330 additions and 12 deletions
+57
View File
@@ -85,6 +85,61 @@ curl -sf http://127.0.0.1:8080/actuator/health # 探活用这个,不是 /
- **H5 静态产物要和新后端一起发**:H5 由 nginx/网关托管(不再由后端托管,2026-10 调整),
但"同源"没变,前后端不同步还是会出现"页面调用了还不存在的接口"。
### 2.4 ⚠️ 反向代理有两件事必须做(2026-10-20 生产 403 的教训)
生产形态:前端静态站 `https://admin.shenxue.jxit.com`,`/api` 反代到本机 jar。
这种"**同源**"部署仍然会踩两个坑,两个都**只在浏览器里复现、后端日志里看不出来**。
#### ① 本站地址必须在 CORS 白名单里(否则登录 403)
**症状**:`POST /api/v1/auth/login` 返回 `403`,响应体一行 `Invalid CORS request`,
`X-Trace-Id` 有值(说明请求已经到了后端),但**业务日志里一个字都没有**。
同一条 curl(不带 `Origin`)却正常。
**原因**:浏览器的 CORS 只约束跨源请求,但它**给同源的 POST/PUT/DELETE 也带
`Origin` 头**;而 Spring MVC 的判据是"请求里有没有 Origin",不在白名单里就在进
Controller 之前直接拒掉。`WebConfig` 曾经把白名单写死成 localhost/内网,
生产域名不在里面 —— 于是"同源部署"反而一定登不上。
**配置**(`application-prod.yml` 已有本项目的默认值,换域名时改它或给环境变量):
```bash
# 逗号分隔;Spring 的 allowedOriginPatterns 语义,可写通配
XT_CORS_ALLOWED_ORIGINS=https://shenxue.jxit.com,https://admin.shenxue.jxit.com,https://*.shenxue.jxit.com
```
启动日志里有一行 `CORS 白名单(/api/**):[...]`,排障先看它。
**改域名/加域名 / 加内网穿透域名,都要同步这里**(H5 走穿透时同样是 POST 会被拦)。
#### ② 必须把 Host / 客户端 IP / 协议透传过去
```nginx
location ^~ /api {
proxy_pass http://127.0.0.1:8090; # 不带 URI 后缀 = 原样转发 /api/...
proxy_http_version 1.1;
# ⚠️ 不传这几个头,后端看到的永远是 127.0.0.1 + http:
# · 登录日志 / 操作日志的 IP 全是 127.0.0.1(合规审计里这是废数据)
# · 任何按 Host 定位的逻辑(租户域名)失效
# · 重定向 / 绝对链接会用错协议(https 页面被跳去 http)
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 学生材料单张可能 >1MB;nginx 默认 1m 直接回 413,而后端日志里没有这条请求
client_max_body_size 50m;
# 导出打包是长事务,别用默认 60s 把还在跑的请求掐掉
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# 上传是流式的,别在 nginx 侧攒完再转发
proxy_request_buffering off;
}
```
> 后端已配 `server.forward-headers-strategy: framework`,有了
> `X-Forwarded-Proto` / `X-Forwarded-For` 它就会按真实协议与客户端 IP 处理。
---
## §3 密钥与凭据
@@ -198,6 +253,8 @@ XT_TENANT_DOMAIN_ENABLED=true # 如果要按域名区分单位
| 学生上传失败 | `xt-server.log` 里 `MaterialFileService` | 存储密钥错 / 空间名错(开 `XT_STORAGE_SELF_CHECK=true` 能在启动时暴露) |
| 页面 404 但接口正常 | 穿透/网关指到了哪个端口 | H5 由 nginx 托管(80),**指到后端的 8080 就只剩接口**;另看 `deploy/.env` 的 `H5_DIR` 是否指向真实产物目录(目录不存在时 docker 会建一个**空目录**,nginx 一切正常而页面 404) |
| 上传报 413(后端日志里没有这条请求) | nginx 的 `client_max_body_size` | 默认 1m;`deploy/nginx/xt.conf` 已设 50m,**换网关时别漏这一条** |
| 浏览器里登录/上传 **403**,但 curl 正常 | 响应体是不是 `Invalid CORS request` | `Origin` 不在 CORS 白名单里 —— **同源的 POST 也会校验**,本站域名必须在 `XT_CORS_ALLOWED_ORIGINS` 里,见 §2.4 |
| 登录日志的 IP 全是 `127.0.0.1` | nginx 有没有传 `X-Forwarded-For` | 见 §2.4 第 ② 条;缺了它审计数据就废了 |
| 本机 8080 被别的项目占用 | `netstat -ano \| findstr :8080` | 用 `--server.port=8081` 起后端,e2e 脚本加 `XT_BASE=http://localhost:8081`(脚本读这个环境变量);**不要去杀别人的进程** |
| 定时任务没生效 | 手工触发一次:`POST /api/v1/schedules/run {"job":"close"}` | 见下 |
| 「查不到数据」但库里有 | 租户上下文 | 定时任务/匿名接口的线程里没有登录用户,租户拦截器默认拒绝 |