From d05cec0a02ae6b2b957bc88d58a54bcb9e56cfd5 Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Tue, 29 Sep 2026 21:37:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(tenant):=20=E7=A7=9F=E6=88=B7=E5=9F=9F?= =?UTF-8?q?=E5=90=8D=20=E2=80=94=E2=80=94=20=E7=94=A8=E5=AD=90=E5=9F=9F?= =?UTF-8?q?=E5=89=8D=E7=BC=80=E5=88=A4=E6=96=AD"=E8=BF=99=E6=98=AF?= =?UTF-8?q?=E5=93=AA=E4=B8=AA=E5=8D=95=E4=BD=8D=E7=9A=84=E7=AB=99=E7=82=B9?= =?UTF-8?q?"=EF=BC=88t1.xxx.com=20/=20t2.xxx.com=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 客户新提:不同的租户采用不同的地址前缀来获取租户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-接口设计(目前只落在代码与本摘要里)——下一轮补。 --- .../fill/config/FillProperties.java | 58 +++++- .../fill/module/auth/service/AuthService.java | 40 +++- .../mp/controller/MpH5AuthController.java | 29 +++ .../module/tenant/mapper/TenantMapper.java | 17 ++ .../fill/module/user/mapper/UserMapper.java | 18 ++ .../fill/security/AuthInterceptor.java | 16 ++ .../fill/support/TenantDomainResolver.java | 127 +++++++++++++ .../src/main/resources/application-dev.yml | 22 +++ scripts/e2e-m1-tenant-domain.py | 178 ++++++++++++++++++ 9 files changed, 497 insertions(+), 8 deletions(-) create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/support/TenantDomainResolver.java create mode 100644 scripts/e2e-m1-tenant-domain.py 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} 了),两处不引入第二套编号。 + * + *

⚠️ 三件不能忘的事

+ * + *
    + *
  1. 默认关闭({@code enabled: false})。单域名部署仍然是合法形态, + * 开了它就要有泛域名解析与泛域名证书,不该默认逼着人配。
  2. + *
  3. Host 只能用来"定位",不能用来"授权"。已登录的请求一律以 + * token 里的 {@code tenantId} 为准,并且要校验它与 Host 一致 —— + * 否则在 {@code t1.xxx.com} 上拿 t2 的 token 就能读 t2 的数据。 + * 见 {@code AuthInterceptor}。
  4. + *
  5. 小程序端用不了它(微信的 request 合法域名是固定白名单,配不了泛域名), + * 所以小程序继续靠 scene 里的 {@code t{tid}}。
  6. + *
+ * + * @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(); diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/AuthService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/AuthService.java index 8bd69d6..513f45f 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/AuthService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/AuthService.java @@ -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 { * * 第 3 条绝不"随便挑一条"——那会让用户登进别人的学校。 */ - private SysUser locateUser(LoginReq req, String account) { + /** + * 定位用户(账号唯一性是 {@code (tenant_id, username)},可能跨租户重名)。 + * + *

租户从哪来:域名优先

+ * + *

启用租户域名后({@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 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); } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpH5AuthController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpH5AuthController.java index 6bf330f..e0cc0e4 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpH5AuthController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpH5AuthController.java @@ -141,11 +141,40 @@ public class MpH5AuthController { * 换句话说:参数要么放外层 query(会进服务器日志),要么放 hash 路由的 query, * 别放裸 hash。 */ + /** + * 是不是本机/内网地址({@code localhost} / {@code 127.0.0.1} / {@code *.local})。 + * + *

用来决定"要不要按租户域名回跳":开发期的 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 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)); diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/mapper/TenantMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/mapper/TenantMapper.java index da26f40..c280078 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/mapper/TenantMapper.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/mapper/TenantMapper.java @@ -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 { + + /** + * 按 id 取可用的租户 —— 不存在 / 已停用 / 已删都返回 {@code null}。 + * + *

给租户域名解析用({@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 { List selectByTenantCodeAndUsername(@Param("tenantCode") String tenantCode, @Param("username") String username); + /** + * 按租户域名解析出的 tenantId + 登录名精确定位(租户域名下的路径)。 + * + *

与上面那条的区别只是"用 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 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 selectByPhoneHash(@Param("tenantId") Long tenantId, diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/AuthInterceptor.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/AuthInterceptor.java index e17cfb9..8256516 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/AuthInterceptor.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/AuthInterceptor.java @@ -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); diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/TenantDomainResolver.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/TenantDomainResolver.java new file mode 100644 index 0000000..504d5c7 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/TenantDomainResolver.java @@ -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; + +/** + * 从域名判断"这是哪个单位的站点"({@code t1.xxx.com} / {@code t2.xxx.com})。 + * + *

它只回答"定位",不回答"授权"

+ * + *

这是本类唯一需要记住的边界: + * + * + * + * + * + *
来源用途
未登录Host定位:登录时该去哪个单位里找这个人、H5 回调该跳回哪
已登录token授权:以 token 里的 tenantId 为准
+ * + *

两者不一致时**必须拒绝**(见 {@code AuthInterceptor}):否则在 {@code t1.xxx.com} + * 上拿着 t2 的 token 就能读到 t2 的数据 —— 那是个安静、且极难被发现的越权。 + * + *

前缀约定与扫码 scene 是同一套

+ * + *

{@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())