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
@@ -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
) {
/**
* 租户域名:用<b>子域前缀</b>区分租户({@code t1.xxx.com} / {@code t2.xxx.com})。
*
* <h3>为什么要有它</h3>
*
* <p>在此之前,"这次是哪个单位"有三个来源,且都要人填或扫:
* PC 登录要手填<b>租户码</b>(账号跨单位重名时报 {@code 40012})、
* H5 靠扫码 scene 里的 {@code t{tid}}、小程序同样靠 scene。
* 域名本身就是"我是谁"的最自然来源 —— 用户在 {@code t1.xxx.com} 上打开页面时,
* 系统不该再问他"你是哪个单位的"。
*
* <p>前缀用 {@code t{tenantId}},与扫码 scene 的 {@code t{tid}} 是<b>同一套约定</b>
* (scene 早就是 {@code t1-k17} 了),两处不引入第二套编号。
*
* <h3>⚠️ 三件不能忘的事</h3>
*
* <ol>
* <li><b>默认关闭</b>({@code enabled: false})。单域名部署仍然是合法形态,
* 开了它就要有泛域名解析与泛域名证书,不该默认逼着人配。</li>
* <li><b>Host 只能用来"定位",不能用来"授权"</b>。已登录的请求一律以
* token 里的 {@code tenantId} 为准,并且要校验它与 Host 一致 ——
* 否则在 {@code t1.xxx.com} 上拿 t2 的 token 就能读 t2 的数据。
* 见 {@code AuthInterceptor}。</li>
* <li><b>小程序端用不了它</b>(微信的 request 合法域名是固定白名单,配不了泛域名),
* 所以小程序继续靠 scene 里的 {@code t{tid}}。</li>
* </ol>
*
* @param enabled 是否启用(默认 false)
* @param base 主域名,如 {@code xxx.com};租户域名为 {@code t{tid}.{base}}
* @param scheme 对外协议({@code https},本地开发 {@code http})
*/
public record TenantDomain(Boolean enabled, String base, String scheme) {
public boolean on() {
return Boolean.TRUE.equals(enabled) && base != null && !base.isBlank();
}
public String baseOrDefault() {
return base == null ? "" : base.trim().toLowerCase();
}
public String schemeOrDefault() {
return scheme == null || scheme.isBlank() ? "https" : scheme.trim();
}
/** 某个租户的站点地址,如 {@code https://t1.xxx.com}。 */
public String siteOf(Long tenantId) {
return schemeOrDefault() + "://t" + tenantId + "." + baseOrDefault();
}
}
/**
* 材料文件存哪里。
*
@@ -51,8 +104,7 @@ public record FillProperties(
Qiniu qiniu,
Boolean selfCheckOnStartup,
Boolean keepRaw
) {
public String providerOrDefault() {
) { public String providerOrDefault() {
// 默认 local:这样"什么都不配"也能把项目跑起来,
// 而不是因为缺一个云存储密钥连本地开发都做不了
return provider == null || provider.isBlank() ? "local" : provider.trim().toLowerCase();
@@ -63,6 +63,9 @@ public class AuthService {
*/
private final String dummyHash;
/** 从域名判断"这是哪个单位的站点"(t1.xxx.com)——只有启用了租户域名才会有值。 */
private final cn.tianbaotong.fill.support.TenantDomainResolver tenantDomainResolver;
public AuthService(UserMapper userMapper,
UserRoleMapper userRoleMapper,
TenantMapper tenantMapper,
@@ -73,7 +76,8 @@ public class AuthService {
PasswordEncoder passwordEncoder,
cn.tianbaotong.fill.security.JwtService jwtService,
TokenBlacklist tokenBlacklist,
PermissionChecker permissionChecker) {
PermissionChecker permissionChecker,
cn.tianbaotong.fill.support.TenantDomainResolver tenantDomainResolver) {
this.userMapper = userMapper;
this.userRoleMapper = userRoleMapper;
this.tenantMapper = tenantMapper;
@@ -85,6 +89,7 @@ public class AuthService {
this.jwtService = jwtService;
this.tokenBlacklist = tokenBlacklist;
this.permissionChecker = permissionChecker;
this.tenantDomainResolver = tenantDomainResolver;
this.dummyHash = passwordEncoder.encode("this-is-not-a-real-password");
}
@@ -110,7 +115,7 @@ public class AuthService {
}
// 3) 定位用户(账号唯一性是 (tenant_id, username),可能跨租户重名)
SysUser user = locateUser(req, account);
SysUser user = locateUser(req, account, httpReq);
// 4) 密码校验
String plainPassword;
@@ -271,10 +276,35 @@ public class AuthService {
* </ol>
* 第 3 条绝不"随便挑一条"——那会让用户登进别人的学校。
*/
private SysUser locateUser(LoginReq req, String account) {
/**
* 定位用户(账号唯一性是 {@code (tenant_id, username)},可能跨租户重名)。
*
* <h3>租户从哪来:域名优先</h3>
*
* <p>启用租户域名后({@code t1.xxx.com}),<b>域名本身已经回答了"你是哪个单位的"</b>,
* 所以这条路径下不再需要人填租户码 —— 那句"该账号存在于多个单位,请填写租户码"
* ({@code 40012})在租户域名下根本不会出现。
*
* <p>⚠️ 如果用户<b>同时</b>填了租户码,而它与域名对不上,<b>明确报错而不是悄悄忽略</b>:
* 悄悄忽略会让"我在 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<SysUser> candidates;
if (req.getTenantCode() != null && !req.getTenantCode().isBlank()) {
candidates = userMapper.selectByTenantCodeAndUsername(req.getTenantCode().trim(), account);
if (hostTenant != null) {
SysTenant tenant = tenantMapper.selectActiveById(hostTenant);
if (code != null && !code.isEmpty() && tenant != null
&& !code.equalsIgnoreCase(tenant.getTenantCode())) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"当前访问的是「" + tenant.getTenantName() + "」的站点("
+ tenant.getTenantCode() + "),与填写的租户码不一致,请检查链接");
}
candidates = userMapper.selectByTenantIdAndUsername(hostTenant, account);
} else if (code != null && !code.isEmpty()) {
candidates = userMapper.selectByTenantCodeAndUsername(code, account);
} else {
candidates = userMapper.selectByUsernameAcrossTenants(account);
}
@@ -141,11 +141,40 @@ public class MpH5AuthController {
* 换句话说:<b>参数要么放外层 query(会进服务器日志),要么放 hash 路由的 query</b>,
* 别放裸 hash。
*/
/**
* 是不是本机/内网地址({@code localhost} / {@code 127.0.0.1} / {@code *.local})。
*
* <p>用来决定"要不要按租户域名回跳":开发期的 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<Void> redirectToApp(String token, String scene, String error) {
String base = props.mpH5() == null ? null : props.mpH5().h5BaseUrlOrDefault();
if (base == null || base.isBlank()) {
base = "http://127.0.0.1:5174";
}
// ★ 租户域名优先:授权回调**只能配一个域名**(微信的"授权回调页面域名"是单值),
// 所以回调统一落在主域名上,再按 scene 里的 t{tid} 跳回该单位的站点。
// 这就是"每租户一个域名"与"微信只允许一个回调域名"之间的桥。
//
// ⚠️ 但**本地/内网地址不能这么跳**:开发期 base 是 localhost,
// 真照它跳的话,手机上的学生会从穿透域名被扔到 t1.localhost —— 打不开,
// 而且报错是浏览器级的,看不出是我们跳错了。所以本机地址一律回退到 h5-base-url。
Long tid = scene == null || scene.isBlank() ? null : MpSceneService.tenantIdOf(scene);
if (tid != null && props.tenantDomain() != null && props.tenantDomain().on()
&& !isLoopbackHost(props.tenantDomain().baseOrDefault())) {
base = props.tenantDomain().siteOf(tid);
}
StringBuilder q = new StringBuilder();
if (token != null) {
q.append("token=").append(enc(token));
@@ -1,7 +1,10 @@
package cn.tianbaotong.fill.module.tenant.mapper;
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/**
* 租户 Mapper。
@@ -11,4 +14,18 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
* (由 {@code @RequirePerm(TENANT_MANAGE)} 在 Controller 层把关)。
*/
public interface TenantMapper extends BaseMapper<SysTenant> {
/**
* 按 id 取<b>可用</b>的租户 —— 不存在 / 已停用 / 已删都返回 {@code null}。
*
* <p>给租户域名解析用({@code t1.xxx.com} → 1):域名对得上不代表这个单位还能用,
* 停用的单位不该有人能登进去。返回整个实体(而不只是 id),
* 是因为登录时还要拿它的名字/租户码去比对手填的租户码。
*
* <p>⚠️ 关掉租户拦截器并显式按主键查:{@code sys_tenant} 是<b>租户本身那张表</b>,
* 它没有 {@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);
}
@@ -50,6 +50,24 @@ public interface UserMapper extends BaseMapper<SysUser> {
List<SysUser> selectByTenantCodeAndUsername(@Param("tenantCode") String tenantCode,
@Param("username") String username);
/**
* 按<b>租户域名解析出的 tenantId</b> + 登录名精确定位(租户域名下的路径)。
*
* <p>与上面那条的区别只是"用 id 还是用 code":域名前缀是 {@code t{tenantId}}
* (与扫码 scene 的 {@code t{tid}} 同一套约定),所以这里按 id 查。
*
* <p>租户状态也一并卡住:已停用的单位,域名还在解析,但不该有人能登进去。
*/
@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<SysUser> selectByTenantIdAndUsername(@Param("tenantId") Long tenantId,
@Param("username") String username);
/** 按手机号 HMAC 查用户(同一租户内)。检索走 hash 列,密文列无法等值查询。 */
@Select("SELECT * FROM sys_user WHERE tenant_id = #{tenantId} AND phone_hash = #{phoneHash} AND deleted = 0")
List<SysUser> selectByPhoneHash(@Param("tenantId") Long tenantId,
@@ -30,6 +30,7 @@ public class AuthInterceptor implements HandlerInterceptor {
private final JwtService jwtService;
private final TokenBlacklist blacklist;
private final PermissionChecker permissionChecker;
private final cn.tianbaotong.fill.support.TenantDomainResolver tenantDomainResolver;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
@@ -50,6 +51,21 @@ public class AuthInterceptor implements HandlerInterceptor {
}
LoginUser user = jwtService.parse(token);
// ★ 租户域名与 token 的租户必须一致 —— 这是"用域名区分租户"唯一必须守住的点。
//
// Host 只用来**定位**(这个请求落在谁的站点上),授权一律以 token 为准。
// 不校验的话:在 t1.xxx.com 上拿着 t2 的 token 就能读 t2 的数据 ——
// 一个安静、且极难被发现的越权(界面一切正常,因为前端本来就会带 token)。
Long hostTenant = tenantDomainResolver.resolve(request);
if (hostTenant != null && !hostTenant.equals(user.tenantId())) {
log.warn("租户域名与 token 不一致:uid={} tokenTenant={} hostTenant={} host={}",
user.uid(), user.tenantId(), hostTenant,
cn.tianbaotong.fill.support.TenantDomainResolver.hostOf(request));
throw new BizException(ErrorCode.FORBIDDEN,
"你登录的是另一个单位,请回到本单位的网址重新登录");
}
UserContext.set(user);
request.setAttribute(ATTR_TOKEN, token);
@@ -0,0 +1,127 @@
package cn.tianbaotong.fill.support;
import cn.tianbaotong.fill.config.FillProperties;
import cn.tianbaotong.fill.module.tenant.mapper.TenantMapper;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 从<b>域名</b>判断"这是哪个单位的站点"({@code t1.xxx.com} / {@code t2.xxx.com})。
*
* <h3>它只回答"定位",不回答"授权"</h3>
*
* <p>这是本类唯一需要记住的边界:
*
* <table>
* <tr><th></th><th>来源</th><th>用途</th></tr>
* <tr><td>未登录</td><td><b>Host</b></td><td>定位:登录时该去哪个单位里找这个人、H5 回调该跳回哪</td></tr>
* <tr><td>已登录</td><td><b>token</b></td><td>授权:以 token 里的 tenantId 为准</td></tr>
* </table>
*
* <p>两者不一致时**必须拒绝**(见 {@code AuthInterceptor}):否则在 {@code t1.xxx.com}
* 上拿着 t2 的 token 就能读到 t2 的数据 —— 那是个安静、且极难被发现的越权。
*
* <h3>前缀约定与扫码 scene 是同一套</h3>
*
* <p>{@code t{tenantId}} —— 扫码 scene 早就是 {@code t1-k17} 了({@code WxMiniProgramService.sceneOf}),
* 这里沿用同一个编号方式,不引入第二套"租户短码"。
*
* <p>⚠️ 小程序端<b>用不了域名</b>:微信的 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;<b>解析不出来就返回 {@code null}</b> ——
* 表示"这是统一入口/单域名部署",调用方应回退到原有的方式(填租户码、靠 scene)。
* <b>不要把 null 当成错误</b>:单域名部署是合法形态。
*/
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}(花生壳穿透 / 反代场景),再去掉端口。
*
* <p>为什么要看 {@code X-Forwarded-Host}:本地与穿透环境下,Tomcat 看到的
* {@code Host} 常常是 {@code 127.0.0.1:8080},真实域名在转发头里。
* <b>只在有配置时采信它</b>(生产是网关直连,一般没有这个头)。
*/
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;
}
}
@@ -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
+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())