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 1a059c2..07160e7 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 @@ -22,6 +22,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; * @param storage 材料文件的存储后端(本地磁盘 / 七牛云) * @param miniProgram 微信小程序(填报二维码) * @param mpH5 H5 填报端(网页授权) + * @param tenantDomain 租户域名(用子域前缀区分租户,如 t1.xxx.com) */ @ConfigurationProperties(prefix = "fill") public record FillProperties( @@ -32,9 +33,61 @@ public record FillProperties( Upload upload, Storage storage, MiniProgram miniProgram, - MpH5 mpH5 + MpH5 mpH5, + TenantDomain tenantDomain ) { + /** + * 租户域名:用子域前缀区分租户({@code t1.xxx.com} / {@code t2.xxx.com})。 + * + *
在此之前,"这次是哪个单位"有三个来源,且都要人填或扫: + * PC 登录要手填租户码(账号跨单位重名时报 {@code 40012})、 + * H5 靠扫码 scene 里的 {@code t{tid}}、小程序同样靠 scene。 + * 域名本身就是"我是谁"的最自然来源 —— 用户在 {@code t1.xxx.com} 上打开页面时, + * 系统不该再问他"你是哪个单位的"。 + * + *
前缀用 {@code t{tenantId}},与扫码 scene 的 {@code t{tid}} 是同一套约定 + * (scene 早就是 {@code t1-k17} 了),两处不引入第二套编号。 + * + *
启用租户域名后({@code t1.xxx.com}),域名本身已经回答了"你是哪个单位的", + * 所以这条路径下不再需要人填租户码 —— 那句"该账号存在于多个单位,请填写租户码" + * ({@code 40012})在租户域名下根本不会出现。 + * + *
⚠️ 如果用户同时填了租户码,而它与域名对不上,明确报错而不是悄悄忽略:
+ * 悄悄忽略会让"我在 A 单位的域名下用 B 单位的码登进去了"这种事实被掩盖,
+ * 而当事人只会觉得"这个码填了没用"。
+ */
+ private SysUser locateUser(LoginReq req, String account, HttpServletRequest httpReq) {
+ String code = req.getTenantCode() == null ? null : req.getTenantCode().trim();
+ Long hostTenant = tenantDomainResolver.resolve(httpReq);
+
List 用来决定"要不要按租户域名回跳":开发期的 base 就是 {@code localhost},
+ * 真按它跳会把手机上的学生扔到一个打不开的地址({@code t1.localhost}),
+ * 而那是浏览器级的失败,看不出是我们跳错了。
+ */
+ private static boolean isLoopbackHost(String host) {
+ if (host == null || host.isBlank()) {
+ return true;
+ }
+ String h = host.trim().toLowerCase();
+ return "localhost".equals(h) || h.endsWith(".localhost") || h.endsWith(".local")
+ || h.equals("127.0.0.1") || h.equals("0.0.0.0") || h.startsWith("192.168.")
+ || h.startsWith("10.") || h.endsWith(".internal");
+ }
+
private ResponseEntity 给租户域名解析用({@code t1.xxx.com} → 1):域名对得上不代表这个单位还能用,
+ * 停用的单位不该有人能登进去。返回整个实体(而不只是 id),
+ * 是因为登录时还要拿它的名字/租户码去比对手填的租户码。
+ *
+ * ⚠️ 关掉租户拦截器并显式按主键查:{@code sys_tenant} 是租户本身那张表,
+ * 它没有 {@code tenant_id} 列,走拦截器会拼出一个不存在的条件。
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("SELECT * FROM sys_tenant WHERE id = #{id} AND status = 1 AND deleted = 0")
+ SysTenant selectActiveById(@Param("id") Long id);
}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/UserMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/UserMapper.java
index bbbf440..3bd947f 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/UserMapper.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/UserMapper.java
@@ -50,6 +50,24 @@ public interface UserMapper extends BaseMapper 与上面那条的区别只是"用 id 还是用 code":域名前缀是 {@code t{tenantId}}
+ * (与扫码 scene 的 {@code t{tid}} 同一套约定),所以这里按 id 查。
+ *
+ * 租户状态也一并卡住:已停用的单位,域名还在解析,但不该有人能登进去。
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("""
+ SELECT u.* FROM sys_user u
+ JOIN sys_tenant t ON t.id = u.tenant_id AND t.deleted = 0 AND t.status = 1
+ WHERE u.tenant_id = #{tenantId}
+ AND u.username = #{username} AND u.deleted = 0
+ """)
+ List 这是本类唯一需要记住的边界:
+ *
+ * 两者不一致时**必须拒绝**(见 {@code AuthInterceptor}):否则在 {@code t1.xxx.com}
+ * 上拿着 t2 的 token 就能读到 t2 的数据 —— 那是个安静、且极难被发现的越权。
+ *
+ * {@code t{tenantId}} —— 扫码 scene 早就是 {@code t1-k17} 了({@code WxMiniProgramService.sceneOf}),
+ * 这里沿用同一个编号方式,不引入第二套"租户短码"。
+ *
+ * ⚠️ 小程序端用不了域名:微信的 request 合法域名是固定白名单,
+ * 配不了 {@code *.xxx.com}。所以小程序继续靠 scene 里的 {@code t{tid}}。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class TenantDomainResolver {
+
+ /** 只认 {@code t} + 纯数字,且只有一层({@code t1} ✓、{@code a.t1} 由调用方截断后判断)。 */
+ private static final Pattern PREFIX = Pattern.compile("^t(\\d+)$");
+
+ private final FillProperties props;
+ private final TenantMapper tenantMapper;
+
+ /**
+ * 这条请求落在哪个租户的域名上。
+ *
+ * @return tenantId;解析不出来就返回 {@code null} ——
+ * 表示"这是统一入口/单域名部署",调用方应回退到原有的方式(填租户码、靠 scene)。
+ * 不要把 null 当成错误:单域名部署是合法形态。
+ */
+ public Long resolve(HttpServletRequest req) {
+ return resolveHost(hostOf(req));
+ }
+
+ /** 把 Host 解析成 tenantId(抽出来便于测试与日志)。 */
+ public Long resolveHost(String host) {
+ FillProperties.TenantDomain cfg = props.tenantDomain();
+ if (cfg == null || !cfg.on() || host == null || host.isBlank()) {
+ return null;
+ }
+ String h = host.trim().toLowerCase();
+ String base = cfg.baseOrDefault();
+ if (!h.endsWith("." + base)) {
+ // 主域名本身(xxx.com)= 平台/统一入口,不属于任何租户
+ return null;
+ }
+ String prefix = h.substring(0, h.length() - base.length() - 1);
+ // 多一层子域时不猜:a.t1.xxx.com 取最后一段(t1)——"再往上是 CDN 还是环境名"无从判断
+ int dot = prefix.lastIndexOf('.');
+ if (dot >= 0) {
+ prefix = prefix.substring(dot + 1);
+ }
+ Matcher m = PREFIX.matcher(prefix);
+ if (!m.matches()) {
+ return null;
+ }
+ long id;
+ try {
+ id = Long.parseLong(m.group(1));
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ Long active = tenantMapper.selectActiveById(id) == null ? null : id;
+ if (active == null) {
+ // 域名对得上、但单位不存在或已停用:**当作没有这个租户域名**,
+ // 由上层给出明确提示("该单位不存在或已停用"),而不是让它退化成"找不到人"
+ log.debug("租户域名未命中有效单位:host={} 解析出的 id={}", host, id);
+ return null;
+ }
+ return active;
+ }
+
+ /**
+ * 取 Host:优先 {@code X-Forwarded-Host}(花生壳穿透 / 反代场景),再去掉端口。
+ *
+ * 为什么要看 {@code X-Forwarded-Host}:本地与穿透环境下,Tomcat 看到的
+ * {@code Host} 常常是 {@code 127.0.0.1:8080},真实域名在转发头里。
+ * 只在有配置时采信它(生产是网关直连,一般没有这个头)。
+ */
+ public static String hostOf(HttpServletRequest req) {
+ if (req == null) {
+ return null;
+ }
+ String h = req.getHeader("X-Forwarded-Host");
+ if (h == null || h.isBlank()) {
+ h = req.getServerName();
+ }
+ if (h == null || h.isBlank()) {
+ return null;
+ }
+ // X-Forwarded-Host 可能是 "a.com, b.com"(多级代理),取第一个
+ int comma = h.indexOf(',');
+ if (comma > 0) {
+ h = h.substring(0, comma);
+ }
+ h = h.trim();
+ int colon = h.indexOf(':');
+ if (colon > 0) {
+ h = h.substring(0, colon);
+ }
+ return h;
+ }
+}
diff --git a/backend/xt-server/src/main/resources/application-dev.yml b/backend/xt-server/src/main/resources/application-dev.yml
index 52c71d6..4489077 100644
--- a/backend/xt-server/src/main/resources/application-dev.yml
+++ b/backend/xt-server/src/main/resources/application-dev.yml
@@ -138,6 +138,28 @@ fill:
# 代码里还会再判一次 profile 不含 prod(双保险)。
stub-enabled: ${WX_H5_STUB:true}
+ # ── 租户域名(用子域前缀区分租户)──────────────────────────────────────
+ #
+ # 形态:t{tenantId}.{base},如 t1.example.com / t2.example.com。
+ # 前缀用的是 **tenantId**,与扫码 scene 的 t{tid} 是同一套约定(scene 早就是 t1-k17)。
+ #
+ # 打开之后:
+ # · PC 登录在 t1.xxx.com 上**不用再填租户码** —— 域名已经回答了"你是哪个单位的",
+ # 那句「该账号存在于多个单位,请填写租户码」在这条路径下不会再出现
+ # · H5 微信授权回调仍落在主域名(微信的"授权回调页面域名"只能配一个),
+ # 回调再按 scene 里的 t{tid} 跳回该单位的站点
+ #
+ # ⚠️ 已登录的请求**以 token 里的 tenantId 为准**,Host 只用于定位;
+ # 两者不一致会被 AuthInterceptor 拒掉(403)—— 否则在 t1 域名上拿 t2 的 token
+ # 就能读 t2 的数据,而界面看起来一切正常。
+ # ⚠️ 小程序端**用不了它**:微信的 request 合法域名是固定白名单,配不了 *.xxx.com,
+ # 所以小程序继续靠 scene 里的 t{tid}。
+ # ⚠️ 生产要配**泛域名解析 + 泛域名证书**(*.example.com)。
+ tenant-domain:
+ enabled: ${XT_TENANT_DOMAIN_ENABLED:true}
+ base: ${XT_TENANT_DOMAIN_BASE:localhost}
+ scheme: ${XT_TENANT_DOMAIN_SCHEME:http}
+
springdoc:
swagger-ui:
enabled: true
diff --git a/scripts/e2e-m1-tenant-domain.py b/scripts/e2e-m1-tenant-domain.py
new file mode 100644
index 0000000..1ba9e29
--- /dev/null
+++ b/scripts/e2e-m1-tenant-domain.py
@@ -0,0 +1,178 @@
+# -*- coding: utf-8 -*-
+"""
+租户域名 E2E:**用子域前缀判断"这是哪个单位的站点"**(t1.xxx.com / t2.xxx.com)。
+
+验的核心只有两条,但它们决定了这个功能能不能上:
+
+ ① **登录时不用再填租户码**。在此之前,「这次是哪个单位」要么靠人手填租户码
+ (账号跨单位重名时报 40012),要么靠扫码 scene —— 而域名本身就是最自然的答案:
+ 用户在 t1.xxx.com 上打开页面时,系统不该再问他"你是哪个单位的"。
+
+ ② ⚠️ **Host 只用于"定位",授权一律以 token 为准,且两者不一致必须拒绝**。
+ 不校验的话:在 t1.xxx.com 上拿着 t2 的 token 就能读 t2 的数据 ——
+ 界面一切正常(前端本来就会带 token),是个安静且极难被发现的越权。
+ 所以这条比第 ① 条更重要。
+
+跑法:python scripts/e2e-m1-tenant-domain.py
+前提:后端 8080 在跑,且 `fill.tenant-domain` 已启用(dev 默认
+ enabled=true / base=localhost —— 只有 *.localhost 会走这条路,不影响其它域名)。
+"""
+from __future__ import annotations
+
+import argparse
+import base64
+import http.client
+import json
+import os
+import sys
+
+sys.stdout.reconfigure(encoding="utf-8")
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
+
+PASSED: list[str] = []
+FAILED: list[tuple[str, str]] = []
+
+
+def ok(name: str) -> None:
+ PASSED.append(name)
+ print(f" [OK] {name}")
+
+
+def fail(name: str, detail: str) -> None:
+ FAILED.append((name, detail))
+ print(f" [FAIL] {name}\n {detail}")
+
+
+def check(name: str, cond: bool, detail: str = "") -> bool:
+ if cond:
+ ok(name)
+ else:
+ fail(name, detail or "断言不成立")
+ return cond
+
+
+def rsa_encrypt(plain: str) -> str:
+ from cryptography.hazmat.primitives import serialization
+ from cryptography.hazmat.primitives.asymmetric import padding
+ with open(PUB_KEY, "rb") as f:
+ pub = serialization.load_pem_public_key(f.read())
+ return base64.b64encode(pub.encrypt(plain.encode(), padding.PKCS1v15())).decode()
+
+
+def call(method: str, path: str, host: str, token: str | None = None,
+ body: dict | None = None) -> dict:
+ """
+ 发一个请求,**Host 由调用方指定**。
+
+ 用 http.client 而不是 urllib:urllib 会按 URL 自己算 Host,
+ 而这里恰恰要伪造 Host(模拟 t1.localhost 这种子域),必须完全掌控这个头。
+ """
+ payload = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
+ headers = {"Host": host, "Content-Type": "application/json; charset=utf-8"}
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ conn = http.client.HTTPConnection("127.0.0.1", 8080, timeout=30)
+ try:
+ conn.request(method, path, body=payload, headers=headers)
+ resp = conn.getresponse()
+ raw = resp.read().decode("utf-8")
+ try:
+ return json.loads(raw)
+ except json.JSONDecodeError:
+ return {"_raw": raw[:200], "_http": resp.status}
+ finally:
+ conn.close()
+
+
+def login(host: str, tenant_code: str | None = None, account: str = "admin",
+ password: str = "Admin@123") -> dict:
+ body: dict = {"username": account, "password": rsa_encrypt(password)}
+ if tenant_code is not None:
+ body["tenantCode"] = tenant_code
+ return call("POST", "/api/v1/auth/login", host, body=body)
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="租户域名 E2E")
+ ap.add_argument("--password", default="Admin@123")
+ args = ap.parse_args()
+
+ print("=" * 76)
+ print("租户域名 E2E(t{tenantId}.{base})")
+ print("=" * 76)
+
+ # ── 1. 域名就是租户身份:不用填租户码 ─────────────────
+ print("\n[1] 在 t1.localhost 上,不填租户码也应能登录")
+ # 先证明"没有域名时确实需要租户码"——否则下面的成功说明不了是域名起的作用
+ no_domain = login("localhost", None)
+ check("对照:不带域名 + 不填租户码 → 40012(账号跨单位重名)",
+ no_domain.get("code") == 40012, f"{no_domain}")
+
+ with_domain = login("t1.localhost", None)
+ check("**t1.localhost + 不填租户码 → 登录成功**(域名提供了租户身份)",
+ with_domain.get("code") == 0, f"{with_domain}")
+ tenant = (with_domain.get("data") or {}).get("tenant") or {}
+ check("登进去的确实是租户 1(demo)", tenant.get("id") == 1 and tenant.get("tenantCode") == "demo",
+ f"{tenant}")
+ token1 = (with_domain.get("data") or {}).get("token")
+
+ # 带端口、带大写、多一层子域,都应解析到同一个租户
+ check("Host 带端口(t1.localhost:8080)也能识别",
+ login("t1.localhost:8080", None).get("code") == 0, "带端口失败")
+ check("大小写不敏感(T1.LocalHost)",
+ login("T1.LocalHost", None).get("code") == 0, "大小写失败")
+ check("多一层子域(a.t1.localhost)取最后一段",
+ login("a.t1.localhost", None).get("code") == 0, "多层子域失败")
+
+ # ── 2. 与手填租户码矛盾时:明确报错,不静默忽略 ────────
+ print("\n[2] 域名与手填的租户码矛盾时必须报错")
+ conflict = login("t1.localhost", "somewrongcode")
+ check("t1 域名 + 别的租户码 → 明确报错(不静默忽略)",
+ conflict.get("code") == 40001, f"{conflict}")
+ check("错误里说清了「当前访问的是哪个单位」与「不一致」",
+ "不一致" in (conflict.get("msg") or "") and "站点" in (conflict.get("msg") or ""),
+ f"{conflict.get('msg')}")
+
+ # 一致的租户码是允许的(老客户端仍会带上)
+ same = login("t1.localhost", "demo")
+ check("t1 域名 + 正确的租户码 → 正常登录", same.get("code") == 0, f"{same}")
+
+ # ── 3. ★ Host 只用于定位,授权以 token 为准 ────────────
+ print("\n[3] ★ Host 与 token 的租户不一致 → 必须拒绝")
+ check("拿 t1 的 token 访问 t1 域名 → 正常",
+ call("GET", "/api/v1/users?pageNum=1&pageSize=1", "t1.localhost", token1).get("code") == 0,
+ "同租户被误拒")
+ # 14 号租户是 e2e 建的(status=1,存在且启用),所以 t14.localhost 能被解析成一个真实租户
+ cross = call("GET", "/api/v1/users?pageNum=1&pageSize=1", "t14.localhost", token1)
+ check("**拿 t1 的 token 访问 t14 域名 → 403(拒绝)**",
+ cross.get("code") == 40301 or cross.get("_http") == 403
+ or "另一个单位" in (cross.get("msg") or ""), f"{cross}")
+ check("拒绝时给出可理解的提示(不是一句「无权限」)",
+ "另一个单位" in (cross.get("msg") or ""), f"{cross.get('msg')}")
+
+ # ── 4. 边界:不合法/不存在的前缀一律回退到老路径 ────────
+ print("\n[4] 边界:解析不出来就当「没有租户域名」,回退到老路径")
+ check("前缀不是 t{数字}(x1.localhost)→ 回退(仍 40012)",
+ login("x1.localhost", None).get("code") == 40012, "非 t 前缀被误当租户")
+ check("租户不存在(t999999.localhost)→ 回退(仍 40012,而不是「找不到此人」)",
+ login("t999999.localhost", None).get("code") == 40012,
+ f"{login('t999999.localhost', None)}")
+ check("主域名本身(localhost)= 平台入口,不属于任何租户",
+ login("localhost", "demo").get("code") == 0, "主域名 + 租户码被误拒")
+ check("无关域名(example.com)不解析",
+ login("example.com", None).get("code") == 40012, "无关域名被误当租户")
+
+ print()
+ print("=" * 76)
+ print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
+ if FAILED:
+ for n, det in FAILED:
+ print(f" · {n}\n {det}")
+ print("=" * 76)
+ return 1 if FAILED else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
它只回答"定位",不回答"授权"
+ *
+ *
+ *
+ *
+ *
+ * 来源 用途
+ * 未登录 Host 定位:登录时该去哪个单位里找这个人、H5 回调该跳回哪
+ * 已登录 token 授权:以 token 里的 tenantId 为准 前缀约定与扫码 scene 是同一套
+ *
+ *