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:
@@ -70,6 +70,7 @@
|
||||
| 2026-09-28 | **P3 接口设计** | 完成 `03-接口设计.md`(102KB / 2626 行)。**117 个接口**全部字段级定义(PRD 原 96 + 新增 21);新增 §2 枚举全集(含「无」的三条铁律)、§3 通用数据结构(12 个 VO,字段名与 DDL 列一一对应)、§15 错误码全集(50 个,新增 30);**自校验通过**:模块表合计=声称=实际唯一数=117,无真重复,引用字段名 116 个全部存在于 DDL |
|
||||
| 2026-10-09 | **验收走查后的四项修正** | ① **去掉 3 条无用菜单**(名单管理 / 表单设计器 / 材料规则)—— 功能都在「收集任务」行内入口,菜单里那一条只是指路;后端菜单键同步删除;② **修掉班主任点工作台必弹 403**(工作台无条件调了要 `org:view` / `progress:viewAll` 的两个接口,班主任都没这两个权限)→ 改为按权限决定发不发请求;③ **新增请求头 `X-Tenant-Id`(§1.2)**:平台运营的「组织架构 / 账号管理 / 工作台」原先拿到的是**所有租户混在一起**的数据,现在必须先在页面上选定学校;④ **修掉开放接口的"推送重试"从未生效**(F-112:定时扫描缺 `@InterceptorIgnore`,异常被 warn 吞掉;E2E 补了"真的重试了"的断言)。详见 `交接摘要` §一·三十三 |
|
||||
| 2026-10-09 | **二期「角色与授权」上线**(FR-205) | 内置角色**只读** + 自定义角色的权限码 / 菜单可配:新增 **8 个接口**(`docs/03` §4.12 / §5.10,契约总数 121 → **129**)、**V18 迁移**(`sys_role.user_type` + `sys_role_menu`)、错误码 `40045`;权限判定从"零 IO"改为"内置按代码 / 自定义按库 + Redis 缓存";新增每日**内置角色授权同步**(修掉代码与库的静默漂移);PC 新增「角色与授权」页(原型 ⑪)→ **`ready:false` 的菜单归零**;账号页的可选角色 / 用户类型 / 角色名全部改读库(删掉前端三份写死的镜像表)。验证:新增 `scripts/e2e-m10-role.py` **77 条**、`verify-all --with-e2e` 全绿。**「数据范围可配」仍是下一批**(见 `docs/05` M8 的说明) |
|
||||
| 2026-10-20 | **修生产登录 403「Invalid CORS request」** | 生产 `POST /api/v1/auth/login` 返回 403:**浏览器的同源 POST 也带 `Origin`,而 Spring 只看"有没有 Origin"**,`WebConfig` 的 CORS 白名单却**写死**成 localhost/内网(注释声称读 `fill.cors.allowed-origins`,该属性其实不存在)。改为配置驱动:`FillProperties.Cors` + 各 profile 的 `fill.cors.allowed-origins`(可用 `XT_CORS_ALLOWED_ORIGINS` 覆盖),生产默认含 `admin.shenxue.jxit.com` 与 `*.shenxue.jxit.com`,启动日志打印生效名单。**接口 / 库表 / 契约均无变化**。`docs/09` 新增 §2.4:本站地址必须在白名单 + 反代必须传 `Host`/`X-Real-IP`/`X-Forwarded-For`/`X-Forwarded-Proto`(缺了登录日志 IP 全是 127.0.0.1)。详见 `交接摘要` §一·四十四 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"}` | 见下 |
|
||||
| 「查不到数据」但库里有 | 租户上下文 | 定时任务/匿名接口的线程里没有登录用户,租户拦截器默认拒绝 |
|
||||
|
||||
+75
-1
@@ -3735,7 +3735,7 @@ PC 管理端不受影响(它一直是独立静态托管)。
|
||||
|---|---|
|
||||
| H5 与 `/api` **同源** | 微信网页授权的「授权回调页面域名」**只能配一个**,回调落在 `/api/v1/mp/h5/callback`,学生 302 回来的页面必须同域 |
|
||||
| H5 的接口地址是 `location.origin` | `frontend-mp/src/config/index.ts`:跨源时请求会打到页面所在的源,表现是"页面能开、接口全 404" |
|
||||
| 同源还省掉 CORS | 否则要放开 `fill.cors.allowed-origins`(`WebConfig` 里那段只在 dev 放行 localhost/内网) |
|
||||
| 同源"省掉"的是**跨源**,不是 CORS 校验 | ⚠️ **这条原来写的是"同源还省掉 CORS",是错的**:浏览器给同源的 POST 也带 `Origin`,Spring 照样校验白名单 —— 生产登录 403 就是这么来的,见 §一·四十四 |
|
||||
|
||||
所以 nginx 的形态是"**页面与接口都从 80 出**",而不是"H5 一个端口、接口另一个端口"。
|
||||
`application-dev.yml` 的 `h5-base-url`(穿透域名,不带端口)因此**一个字都不用改**。
|
||||
@@ -3805,6 +3805,80 @@ PC 管理端不受影响(它一直是独立静态托管)。
|
||||
要调试直接打后端端口。
|
||||
3. **不做 HTTPS / 证书**:本地由穿透层负责;生产形态由部署侧决定。
|
||||
|
||||
---
|
||||
|
||||
## §一·四十四 生产登录 403「Invalid CORS request」(2026-10-20,生产事故)
|
||||
|
||||
### 一、现象
|
||||
|
||||
- `https://admin.shenxue.jxit.com/api/v1/auth/login` 返回 **403**,响应体是一行
|
||||
`Invalid CORS request`,有 `X-Trace-Id`,但**后端业务日志里一个字都没有**。
|
||||
- 同一条请求用 curl(**不带 `Origin`**)打同一个地址 → 200,正常返回业务校验 `40001`。
|
||||
→ **只有浏览器复现**。
|
||||
- 前端与接口是**同源**的(admin 站点把 `/api` 反代到 `127.0.0.1:8090`)。
|
||||
按"同源就不用管 CORS"的直觉,这不该发生 —— 而它偏偏发生了。
|
||||
|
||||
### 二、根因(两层,都要记住)
|
||||
|
||||
1. **浏览器的 CORS 只约束跨源,但它给同源的 POST/PUT/DELETE 也带 `Origin` 头**(GET 不带)。
|
||||
Spring MVC 的判据是"请求里有没有 Origin",**不是**"是不是真跨源":
|
||||
`DefaultCorsProcessor#processRequest`(Spring Framework 7.0.9,已反编译确认)
|
||||
只有 `isCorsRequest → checkOrigin → 不在白名单就 reject`,**没有同源短路**。
|
||||
于是"同源部署"的登录/上传/提交(全是 POST)照样被 CORS 拦下来。
|
||||
2. `WebConfig` 的白名单**写死**成 `http://localhost:*` / `http://127.0.0.1:*` / `http://192.168.*.*:*`,
|
||||
而它的注释写着"生产配置见 `application-prod.yml` 的 `fill.cors.allowed-origins`" ——
|
||||
**那个属性根本不存在**(`FillProperties` 里没有 `cors`,也没有任何地方读它)。
|
||||
典型的"声明了但没接上":编译、lint、单测都不会红,`check-wiring.py` 也不覆盖这一类。
|
||||
|
||||
### 三、为什么 1712 条 e2e 断言全绿还是漏了
|
||||
|
||||
e2e 脚本是 Python `requests`,**不带 `Origin` 头** → 永远走不到 CORS 分支。
|
||||
本地浏览器验的是 `localhost:5173` / `localhost:80`,正好命中写死的 dev 白名单 → 也绿。
|
||||
**唯一能暴露它的组合是"浏览器 + 正式域名"**,而那个组合只在生产存在。
|
||||
|
||||
> 一般化:**"只在浏览器里发生的行为"(Origin、Referer、预检、Cookie 属性)用脚本测不到**,
|
||||
> 这类东西必须写进部署检查单,或者用真实浏览器走一遍。
|
||||
|
||||
### 四、改了什么
|
||||
|
||||
| 位置 | 改动 |
|
||||
|---|---|
|
||||
| `FillProperties` | 新增 `Cors.allowedOrigins`(+ `patternsOrDefault()` 兜底) |
|
||||
| `WebConfig` | 白名单改为**读配置**;启动时 `log.info` 打印生效名单(排障第一眼看它) |
|
||||
| `application.yml` | 新增 `fill.cors.allowed-origins`:默认 dev 值,可用 `XT_CORS_ALLOWED_ORIGINS` 覆盖 |
|
||||
| `application-dev.yml` | 补上穿透域名 `http://394nb68658le.vicp.fun` —— **H5 走穿透时 POST 同样会被拦,之前也是坏的** |
|
||||
| `application-prod.yml` | 默认 `https://shenxue.jxit.com,https://admin.shenxue.jxit.com,https://*.shenxue.jxit.com` |
|
||||
| `deploy/prod.env.example`、`docs/09` §2.4 / §5 | 同步:写清"本站地址必须在白名单"+反代必须传的四个头 |
|
||||
|
||||
### 五、验证(本地起 jar,2026-10-20)
|
||||
|
||||
| 场景 | 结果 |
|
||||
|---|---|
|
||||
| dev 默认白名单 + `Origin: http://localhost:5173` | 200(进到业务校验) |
|
||||
| dev 默认白名单 + `Origin: https://admin.shenxue.jxit.com` | **403 `Invalid CORS request`** —— 本地 1:1 复现了生产现象 |
|
||||
| 以生产白名单启动 + `https://admin.shenxue.jxit.com` / `https://shenxue.jxit.com` / `https://t1.shenxue.jxit.com` / `https://t12.shenxue.jxit.com` | 全部 200 |
|
||||
| `http://admin...` / `https://evil.example.com` / `https://admin.shenxue.jxit.com.evil.com` | 全部 403(既没放开,也没有后缀绕过) |
|
||||
| OPTIONS 预检(允许的 Origin) | 200 + `Access-Control-Allow-Origin` 回显;不允许的仍 403 |
|
||||
|
||||
### 六、部署时要做的两件事
|
||||
|
||||
1. **重新打包 + 重启后端**(改的是 Java 与 yml,不是热加载)。
|
||||
若临时不想发版:nginx 侧加一句 `proxy_set_header Origin "";` 把 Origin 抹掉即可止血
|
||||
(同源请求本来也不需要它)—— **只当止血,正式修复仍然要发版**。
|
||||
2. **nginx 的 `/api` location 补四个头**:`Host` / `X-Real-IP` / `X-Forwarded-For` /
|
||||
`X-Forwarded-Proto`。不补的表现是**登录日志的 IP 全是 `127.0.0.1`**(审计数据作废)、
|
||||
`Host` 相关逻辑(租户域名)失效、重定向用错协议。顺带确认
|
||||
`client_max_body_size 50m` 与 300s 超时(见 `docs/09` §2.4)。
|
||||
|
||||
### 七、仍然没做的(有意)
|
||||
|
||||
1. **没有做"同源自动放行"**:Spring Framework 7 已移除 servlet 版 `CorsFilter`,
|
||||
要动态放行只能自己写过滤器;而生产的反代当前没传 `Host`,后端也判断不出
|
||||
"请求来自哪个公网源"。→ 结论:**白名单必须显式包含部署域名**,
|
||||
这一条写进了 `docs/09` §2.4、`application.yml` 的注释与启动日志。
|
||||
2. **没有加 e2e 断言**:Python 侧不带 Origin,加了只能测"白名单内/外",
|
||||
测不到"生产域名有没有配" —— 那属于部署检查单(已写进 `docs/09` §2.4)。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user