diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java index a0c2027..333fba5 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java @@ -2,6 +2,7 @@ package cn.tianbaotong.fill.config; import org.springframework.boot.context.properties.ConfigurationProperties; +import java.util.List; import java.util.Map; /** @@ -25,6 +26,7 @@ import java.util.Map; * @param miniProgram 微信小程序(填报二维码) * @param mpH5 H5 填报端(网页授权) * @param tenantDomain 租户域名(用子域前缀区分租户,如 t1.xxx.com) + * @param cors 跨域白名单 */ @ConfigurationProperties(prefix = "fill") public record FillProperties( @@ -36,9 +38,46 @@ public record FillProperties( Storage storage, MiniProgram miniProgram, MpH5 mpH5, - TenantDomain tenantDomain + TenantDomain tenantDomain, + Cors cors ) { + /** + * 跨域白名单(Spring 的 {@code allowedOriginPatterns} 语义:可写 + * {@code http://localhost:*}、{@code https://*.example.com})。 + * + *

⚠️ "本站自己的地址"也必须在这里 —— 这条最反直觉,但它是确定的

+ * + *

浏览器的 CORS 只约束跨源请求,可它给同源的 POST/PUT/DELETE 也带 + * {@code Origin} 头(GET 不带)。而 Spring MVC 的判据是"请求里有没有 Origin", + * 不是"是不是真跨源":只要带了 Origin 又不在白名单里,直接回 + * {@code 403 Invalid CORS request}({@code DefaultCorsProcessor#processRequest}, + * Spring Framework 7.0.9 里没有同源短路)。 + * + *

于是"前后端同源部署 = 不需要 CORS"是错的:登录、上传、提交全是 POST, + * 全都会被这一条拒掉。更麻烦的是它只在浏览器里复现 —— + * 所有 E2E 脚本都是 Python,不带 Origin 头,所以测试全绿 + * (2026-10 生产事故,见 {@code docs/交接摘要.md} §一·四十四)。 + * + *

所以配置要按"浏览器地址栏里会出现哪些源"来写,而不是按"谁会跨域调我"来写。 + * + * @param allowedOrigins 允许的源(pattern)。生产写真实域名, + * 换域名/加域名时改这里或给 {@code XT_CORS_ALLOWED_ORIGINS} + */ + public record Cors(List allowedOrigins) { + + /** 本机开发用的兜底值(base 的 {@code application.yml} 里也有一份,这里防 null)。 */ + public static final List DEFAULTS = List.of( + "http://localhost:*", + "http://127.0.0.1:*", + "http://192.168.*.*:*"); + + /** 没配时退回本机开发值;**生产不许为空**,否则同源 POST 会 403。 */ + public List patternsOrDefault() { + return allowedOrigins == null || allowedOrigins.isEmpty() ? DEFAULTS : allowedOrigins; + } + } + /** * 租户域名:用子域前缀区分租户({@code t1.xxx.com} / {@code t2.xxx.com})。 * diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/WebConfig.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/WebConfig.java index 4522941..fbd64bb 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/WebConfig.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/WebConfig.java @@ -2,11 +2,14 @@ package cn.tianbaotong.fill.config; import cn.tianbaotong.fill.security.AuthInterceptor; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import java.util.List; + /** * Web 层装配。 * @@ -14,11 +17,13 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; * {@code /doc.html}、{@code /v3/api-docs/**} 不走业务鉴权, * 它们由各自的机制保护(actuator 只在内网暴露,文档在生产环境关闭)。 */ +@Slf4j @Configuration @RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { private final AuthInterceptor authInterceptor; + private final FillProperties props; @Override public void addInterceptors(InterceptorRegistry registry) { @@ -29,25 +34,38 @@ public class WebConfig implements WebMvcConfigurer { } /** - * 跨域配置。 + * 跨域配置(白名单来自 {@code fill.cors.allowed-origins})。 * - *

⚠️ 生产环境必须收紧 {@code allowedOriginPatterns}——写 {@code *} 配合 - * {@code allowCredentials} 在浏览器规范下无效,而改成 {@code originPatterns} 放行 - * 全部则等于取消同源策略。生产配置见 {@code application-prod.yml} 里的 - * {@code fill.cors.allowed-origins}。 + *

⚠️ 为什么"同源部署"也离不开这张白名单

+ * + *

浏览器的 CORS 只约束跨源请求,但同源的 POST/PUT/DELETE 也会带 + * {@code Origin} 头;而 Spring MVC 只看"有没有 Origin",不看"是不是真跨源", + * 不在白名单里就回 {@code 403 Invalid CORS request}。登录、上传、提交全是 POST —— + * 于是本站自己的地址必须列在这里,否则"前后端同源"这个部署形态反而一定登不上。 + * + *

这条曾经写死成 localhost/内网,生产域名 {@code https://admin.shenxue.jxit.com} + * 不在里面,表现就是"接口地址明明通、POST 一律 403",而后端日志里一个字都没有 + * (403 是 CORS 处理器在进 Controller 之前回的)。详见 {@code docs/交接摘要.md} §一·四十四。 + * + *

⚠️ 启动时把生效的白名单打出来(见下面的 info 日志):这类事故里最先要看的就是 + * "到底放了谁",而白名单藏在配置里、又只在浏览器里才生效,不打印就只能猜。 */ @Override public void addCorsMappings(CorsRegistry registry) { + FillProperties.Cors cors = props.cors(); + List patterns = cors == null + ? FillProperties.Cors.DEFAULTS + : cors.patternsOrDefault(); + registry.addMapping("/api/**") - .allowedOriginPatterns( - "http://localhost:*", - "http://127.0.0.1:*", - "http://192.168.*.*:*" - ) + .allowedOriginPatterns(patterns.toArray(String[]::new)) .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .exposedHeaders("X-Trace-Id") .allowCredentials(true) .maxAge(3600); + + log.info("CORS 白名单(/api/**):{} —— 浏览器的同源 POST 也会校验," + + "部署域名必须在这里,否则登录会 403「Invalid CORS request」", patterns); } } diff --git a/backend/xt-server/src/main/resources/application-dev.yml b/backend/xt-server/src/main/resources/application-dev.yml index 5800c7f..6e65073 100644 --- a/backend/xt-server/src/main/resources/application-dev.yml +++ b/backend/xt-server/src/main/resources/application-dev.yml @@ -50,6 +50,16 @@ fill: # PKCS#8 私钥(单行 Base64,无 PEM 头尾) private-key: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCeHytLx+201AZKhds9B+3LWoVYDJisyimtysGpGip6JLT3iiB2tpK2QL4OAopI/8gaY1iyoxmxU5fUEWa+mPspszciNot4bAdDRZMiCkGOj0zEKJRrTDctnvLVyB6lSd89FX8yD/LS131Big57u9Sk5BYCMO9ZhgCrN1p1qbf/FoJ16zZ3NWSLtGdqVUx2BQjnSRp4Rzf8fq/49MWSXnBmsVm543B85mZMARp24rZ5SwpMwhtrAfVihwqkfaWAfu51oiIwjf0Q8s/wsfth60WoWipG56onwuPtSECKW/Weg5SUQiwoj95iWae+yQFGXRy7hkjk3FhmQArhoK3jiJbrAgMBAAECggEAA3AoD9jECWeGz34Dm1sdx4srVRPJChDHQTkkZtnjzI4ZxTwiopIrtHzJ5SIXAalWH4TDe/bh6yInLfxG4KQQ6gI0k7Dd7Xtj2QmdPmM8s63X/Hfpaj/Y/0vyecWFLRDjIr/h6SPBouX+mjs3P2eBIyi2dcu8mJS6VP66H87V6lz5LyPraFC3ocXTMCabJh+i/Xy4EqV26Mhv+kSrE0CLB2ngfQW98+RgPo5Ndk/A826eGVA16tKsylcTfq3SB/yMSy1xHv/p/IF+PK+Twl3GmJB2jf/dzZpteJIZrPRz+AJ9VJQa35/Chre57JFcA50Avkf5iySzzVfYZDXd0qk6GQKBgQDMD3opopECI5lo21p9+KUZ6poW8iO/Yp2FNYBldMnk+oHcCL6H92dGIZNu0mvcumjEstKH4X9OoI5qV2FFMUrFc6E7NfXWNKOj+eJtLn3WzNRJp6n4x8W8AEeAXzHuv8IbSlG8nsG/g49Tdnaxr2y/iWNj1qGnTfaSyhGRRh133wKBgQDGXla5EmtD9TpCpl7/OSWG8qzv4GfRTaVjC+qz2KnfU5cqiFKLK27uxkP9F8Kdh6EkvOltRA8024FeafpLgTjH3fEKHFywwXSVvssp94IZdoyuxrAqMccReE7akS6IImawm8fu9U0bAwi7WShZHaw2hS9ScM/9cATkDRfRm0DydQKBgHtcQ+rVlzom0UAdk2giH5U6SSrj+Q5jDH0SKeurnb3qXwTo2F4UB3/THnDZjlFi86Etxa5VeQPMdiv9ZRVSRjR1wZus9FAphF/F2cdNWs+3BgL88/kss/ICbTdC1UnS7QJYfG89vj0LyGKZdAnY1jI4WThq8VDWBeEZfWNIctobAoGBAJMoZRmCqoSNpZzQI2UfFdHYsaZ6SUoiWfDyZDjrIIoGTvxEiUeo/vCvsLxPJhcLCOZiJqDifo1t0bki0YW15VOQOEKR68lMmpqEId+ON7uAELmPuBx1NQKmx4ipb2Wab6fQyS9iVsMVKyMg7ye9NYZ0ozh7bDDNKzOq6uKaVEIJAoGAbulymgadkLNVksxPW0O0qIlLVfaOzOeDLGi6ZO4ZBt9wyKgbyMcfwjvuylerkLF/zZqLQ79sOd4JO15ssqHK/YVKiON/A7pDDgxAJpOXxGxy9RXpZWhHL1KBjI9Gcq0gyMSnQbiwpB1Wx3qmjNGDynclW1kNFCQKQpW9xTs4NI0= + # ── 跨域白名单(本地开发)──────────────────────────────────────────── + # + # ⚠️ 见 application.yml 里的完整说明:**同源的 POST 也会被 CORS 校验**, + # 所以"H5 由 nginx 托管、页面与接口同源"还不够 —— + # 浏览器给的 Origin 是**页面地址**(穿透域名 / localhost),必须列在这里, + # 否则 H5 的登录、上传、提交会在浏览器里 403(而后端日志里一个字都没有)。 + # 这份名单对应三种打开方式:pnpm dev:h5(5174) / xt-nginx(80) / 花生壳穿透。 + cors: + allowed-origins: "${XT_CORS_ALLOWED_ORIGINS:http://localhost:*,http://127.0.0.1:*,http://192.168.*.*:*,http://394nb68658le.vicp.fun}" + # ── 材料文件存储(原图 + 压缩改名后的规范件)────────────────────────── # # ⚠️ provider=local 只适合本地开发:**多副本部署下必然丢文件** —— diff --git a/backend/xt-server/src/main/resources/application-prod.yml b/backend/xt-server/src/main/resources/application-prod.yml index 886e745..90b7129 100644 --- a/backend/xt-server/src/main/resources/application-prod.yml +++ b/backend/xt-server/src/main/resources/application-prod.yml @@ -12,6 +12,8 @@ # WX_MP_CALLBACK_TOKEN # 其他 XT_LOG_DIR XT_UPLOAD_DIR XT_SEED_ENABLED # XT_TENANT_DOMAIN_ENABLED XT_TENANT_DOMAIN_BASE +# XT_CORS_ALLOWED_ORIGINS(**换域名/加域名时必看**:浏览器同源 POST 也走 CORS, +# 漏了本站地址 = 登录 403,见 §一·四十四) # # ⚠️ 学生端**只发布微信小程序,不部署 H5**: # 本文件没有 fill.mp-h5 段。后端 /api/v1/mp/h5/** 接口仍在, @@ -190,6 +192,18 @@ fill: login-lock-minutes: 15 captcha-after-fails: 2 + # ── 跨域白名单(**同源也要配**,见 application.yml 里的完整说明)──────── + # + # ⚠️ 浏览器的同源 POST/PUT/DELETE 也会带 Origin,而 Spring MVC 只看"有没有 Origin": + # 不在白名单里 → 直接 403「Invalid CORS request」→ **登录不上**。 + # PC 端是 https://admin.shenxue.jxit.com,接口是同一个域名下的 /api, + # 所以这个地址**必须在**名单里(2026-10-20 生产事故的根因)。 + # + # 三个来源分别是:主域名(统一入口)/ PC 管理端 / 所有子域(将来的 t1、t2 租户站点)。 + # 换域名或加穿透域名时:改这里,或给 XT_CORS_ALLOWED_ORIGINS(逗号分隔)。 + cors: + allowed-origins: "${XT_CORS_ALLOWED_ORIGINS:https://shenxue.jxit.com,https://admin.shenxue.jxit.com,https://*.shenxue.jxit.com}" + upload: # 单文件上限 50MB(PRD §10.1) max-file-size-mb: 50 diff --git a/backend/xt-server/src/main/resources/application.yml b/backend/xt-server/src/main/resources/application.yml index f1ab46f..c6c6c54 100644 --- a/backend/xt-server/src/main/resources/application.yml +++ b/backend/xt-server/src/main/resources/application.yml @@ -133,3 +133,19 @@ fill: upload: # 单文件上限 50MB(PRD §10.1) max-file-size-mb: 50 + + # ── 跨域白名单(Spring 的 allowedOriginPatterns 语义)──────────── + # + # ⚠️⚠️ **"前后端同源就不用配 CORS"是错的**,这条已经用一次生产事故验证过: + # 浏览器的 CORS 只约束跨源请求,但它**给同源的 POST/PUT/DELETE 也带上 Origin 头**; + # 而 Spring MVC 只看"请求里有没有 Origin"、不看"是不是真跨源" —— + # 带了 Origin 又不在白名单里,就在进 Controller 之前直接 403「Invalid CORS request」。 + # 登录/上传/提交全是 POST,所以**本站自己的地址也必须列在这儿**。 + # + # ⚠️ 它只在**浏览器**里复现:e2e 脚本是 Python,不带 Origin 头 → 1700+ 条断言全绿也发现不了 + # (2026-10-20 生产登录 403 的原因,见 docs/交接摘要.md §一·四十四)。 + # + # ⚠️ 换域名、加域名、加穿透域名 → 改这里或给 XT_CORS_ALLOWED_ORIGINS(逗号分隔)。 + # 启动日志会打印生效的白名单,排障先看那一行。 + cors: + allowed-origins: "${XT_CORS_ALLOWED_ORIGINS:http://localhost:*,http://127.0.0.1:*,http://192.168.*.*:*}" diff --git a/backend/xt-server/src/test/java/cn/tianbaotong/fill/config/CorsWhitelistTest.java b/backend/xt-server/src/test/java/cn/tianbaotong/fill/config/CorsWhitelistTest.java new file mode 100644 index 0000000..72bd271 --- /dev/null +++ b/backend/xt-server/src/test/java/cn/tianbaotong/fill/config/CorsWhitelistTest.java @@ -0,0 +1,83 @@ +package cn.tianbaotong.fill.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.env.PropertySource; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.ClassPathResource; +import org.springframework.web.cors.CorsConfiguration; + +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * CORS 白名单的最小单测(2026-10-20 生产事故的护栏,见 {@code docs/交接摘要.md} §一·四十四)。 + * + *

为什么这条要写成单测:它只在浏览器里才会发生 —— + * 浏览器的同源 POST 也会带 {@code Origin},而 Spring 只看"有没有 Origin"。 + * 所有 e2e 脚本都是 Python(不带 Origin),所以 1712 条断言全绿也发现不了 + * "生产域名没进白名单"。能自动化的只有两件事,都放在这里: + * + *

    + *
  1. 生产配置里确实有这份名单,且它覆盖管理端域名("声明了但没接上"的另一面: + * 名单写在注释里、代码里却读不到);
  2. + *
  3. 通配符真的能匹配子域,而陌生源仍然被拒(既不能漏,也不能变成"全放开")。
  4. + *
+ */ +class CorsWhitelistTest { + + @Test + void 生产白名单必须覆盖管理端域名与租户子域() throws IOException { + CorsConfiguration cfg = new CorsConfiguration(); + cfg.setAllowedOriginPatterns(生产白名单()); + + // PC 管理端的真实 Origin(同源部署下,登录/上传/提交的 POST 都会带它) + assertEquals("https://admin.shenxue.jxit.com", + cfg.checkOrigin("https://admin.shenxue.jxit.com")); + // 主域名(统一入口) + assertEquals("https://shenxue.jxit.com", + cfg.checkOrigin("https://shenxue.jxit.com")); + // 将来的租户站点(t1./t12.)走的是同一条 POST 链路 + assertEquals("https://t1.shenxue.jxit.com", + cfg.checkOrigin("https://t1.shenxue.jxit.com")); + } + + @Test + void 陌生源仍然被拒_白名单不是全放开() throws IOException { + CorsConfiguration cfg = new CorsConfiguration(); + cfg.setAllowedOriginPatterns(生产白名单()); + + assertNull(cfg.checkOrigin("https://evil.example.com")); + // 后缀拼骗:admin.shenxue.jxit.com.evil.com 不是我们的子域 + assertNull(cfg.checkOrigin("https://admin.shenxue.jxit.com.evil.com")); + // 生产是 https,http 版本不放开(避免"降级到 http 的页面也能调接口") + assertNull(cfg.checkOrigin("http://admin.shenxue.jxit.com")); + } + + @Test + void 没配时退回本机开发值_不会是空名单() { + assertTrue(new FillProperties.Cors(null).patternsOrDefault().contains("http://localhost:*")); + assertTrue(new FillProperties.Cors(List.of()).patternsOrDefault() + .contains("http://127.0.0.1:*")); + } + + /** 把 {@code application-prod.yml} 里的 {@code fill.cors} 真绑一遍(保证"配了就能读到")。 */ + private static List 生产白名单() throws IOException { + StandardEnvironment env = new StandardEnvironment(); + List> sources = new YamlPropertySourceLoader() + .load("application-prod.yml", new ClassPathResource("application-prod.yml")); + sources.forEach(env.getPropertySources()::addFirst); + + FillProperties.Cors cors = Binder.get(env) + .bind("fill.cors", FillProperties.Cors.class) + .orElseThrow(() -> new AssertionError( + "application-prod.yml 里读不到 fill.cors.allowed-origins —— " + + "同源 POST 会 403「Invalid CORS request」")); + return cors.patternsOrDefault(); + } +} diff --git a/deploy/prod.env.example b/deploy/prod.env.example index 300a78a..c5676dc 100644 --- a/deploy/prod.env.example +++ b/deploy/prod.env.example @@ -72,3 +72,9 @@ XT_LOG_DIR=/var/log/xt # 按域名区分单位时才打开 XT_TENANT_DOMAIN_ENABLED=false XT_TENANT_DOMAIN_BASE= +# CORS 白名单(逗号分隔,Spring allowedOriginPatterns 语义)。 +# ⚠️ **本站自己的地址也必须列上**:浏览器给同源的 POST 也带 Origin, +# 不在名单里 → 登录/上传一律 403「Invalid CORS request」(2026-10-20 生产事故)。 +# PC 前端在 https://admin.shenxue.jxit.com,就是下面这行的默认值 +# (application-prod.yml 里已经写了同样的默认,这里只是让它可运维覆盖)。 +XT_CORS_ALLOWED_ORIGINS=https://shenxue.jxit.com,https://admin.shenxue.jxit.com,https://*.shenxue.jxit.com diff --git a/docs/00-交付计划.md b/docs/00-交付计划.md index 58ceaac..5ddca06 100644 --- a/docs/00-交付计划.md +++ b/docs/00-交付计划.md @@ -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)。详见 `交接摘要` §一·四十四 | --- diff --git a/docs/09-部署与运维.md b/docs/09-部署与运维.md index 520b87d..0ed97c8 100644 --- a/docs/09-部署与运维.md +++ b/docs/09-部署与运维.md @@ -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"}` | 见下 | | 「查不到数据」但库里有 | 租户上下文 | 定时任务/匿名接口的线程里没有登录用户,租户拦截器默认拒绝 | diff --git a/docs/交接摘要.md b/docs/交接摘要.md index 155a83b..686d1d7 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -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)。 +