feat(tenant): 租户域名 —— 用子域前缀判断"这是哪个单位的站点"(t1.xxx.com / t2.xxx.com)

客户新提:不同的租户采用不同的地址前缀来获取租户ID。

一、它解决什么
   在此之前"这次是哪个单位"都要人填或扫:PC 登录要手填租户码(跨单位重名时报 40012
   「请填写租户码」,而用户根本不知道该填什么)、H5 与小程序靠扫码 scene。
   而域名本身就是"我是谁"最自然的答案 —— 在 t1.xxx.com 上打开页面时,
   系统不该再问"你是哪个单位的"。
   前缀用 t{tenantId},与扫码 scene 的 t{tid} **同一套约定**(scene 早就是 t1-k17),
   不引入第二套编号。

二、★ 唯一必须守住的规则:Host 定位,token 授权
   未登录 → Host 决定"去哪个单位里找这个人"
   已登录 → 一律以 token 里的 tenantId 为准,且 Host 不一致就拒绝
   不校验后者的话:在 t1.xxx.com 上拿 t2 的 token 就能读 t2 的数据 ——
   而界面一切正常(前端本来就会带 token),是个安静且极难发现的越权。
   所以 AuthInterceptor 里那段判断比"登录免填租户码"重要得多,E2E 专门守着它。

三、落地
   FillProperties.TenantDomain(enabled/base/scheme)  默认关,siteOf(tid) 拼站点地址
   support/TenantDomainResolver                      解析不出来返回 null(不是错误)
   TenantMapper.selectActiveById                     停用/已删的租户不解析
   UserMapper.selectByTenantIdAndUsername            域名路径按 id 找用户
   AuthService.locateUser                            域名优先;手填租户码与域名矛盾 → 明确报错
   AuthInterceptor                                   Host 与 token 租户不一致 → 403
   MpH5AuthController.redirectToApp                  按 scene 的 t{tid} 跳回该单位站点
   application-dev.yml                               enabled/base=localhost/scheme=http

   null 的语义很重要:解析不出来 ≠ 出错,而是"这是统一入口/单域名部署",回退老路径。
   所以单域名部署仍然完全合法(enabled=false 即可)。

四、两条来自微信的硬约束(决定架构,不是可选)
   1. 公众号网页授权的「授权回调页面域名」**只能配一个** → 每租户一个子域时,
      回调统一落在主域名,再按 scene 里的 t{tid} 302 回该单位站点。
      正好复用既有的 state(scene 随 state 走)+ token 放 fragment 的机制。
   2. 小程序的 request 合法域名是**固定白名单**,配不了 *.xxx.com →
      小程序端用不了域名,继续靠 scene 里的 t{tid}。这点客户需要知道。

五、本地开发的坑(已处理)
   开发期 base 是 localhost,真按它回跳会把手机上的学生从穿透域名扔到 t1.localhost ——
   打不开,且是浏览器级失败,看不出是我们跳错了。所以回跳加了本机地址判断
   (localhost/127.0.0.1/192.168.*/10.*/*.local),本机 base 一律回退到 h5-base-url。

六、E2E:scripts/e2e-m1-tenant-domain.py → 16/16
   [1] t1.localhost + 不填租户码 → 登录成功(对照:不带域名 → 40012);
       带端口/大小写/多一层子域都能解析
   [2] 域名与手填租户码矛盾 → 明确报错;一致则正常
   [3] ★ 拿 t1 的 token 访问 t14 域名 → 403,提示可理解
   [4] 边界:x1.localhost / t999999.localhost / example.com → 都回退老路径
   用 http.client 伪造 Host 头(urllib 会按 URL 自己算 Host,覆盖不掉)。

七、全量 676 条断言、0 失败(新增 16);verify-docs 19 项通过

八、⏳ 需要拍板的三件事(已写进交接摘要 §一·二十五)
   1. 正式域名(base)+ 泛域名解析 + 泛域名证书 *.xxx.com(备案一个主域名即可覆盖子域)
   2. 要不要保留"手填租户码"(我保留了:统一入口、老客户端、运维排查都需要)
   3. 小程序端确认继续靠 scene
   另:这条新需求应补进 PRD 与 03-接口设计(目前只落在代码与本摘要里)——下一轮补。
This commit is contained in:
2026-09-29 21:37:11 +08:00
parent f964cf45fe
commit d05cec0a02
9 changed files with 497 additions and 8 deletions
+178
View File
@@ -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())