Files
xiaotutongxue/scripts/e2e-m1-tenant-domain.py
T
lovife d05cec0a02 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-接口设计(目前只落在代码与本摘要里)——下一轮补。
2026-09-29 21:37:11 +08:00

179 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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())