Install any skill in seconds. Free to start, no credit card required.
Get Started Free →当代码涉及多租户隔离(TenantContext、tenantId、租户拦截器/过滤器、X-Tenant-Code)时触发。防止租户越权访问、数据串租户等安全问题。
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 83% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 73% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 64% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 129% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 172% | 0% |
<instructions>
当系统涉及多租户架构时,防止租户间数据越权访问。
场景: 拦截器/过滤器从请求头(如 X-Tenant-Code)设置租户上下文,但未与认证 token 中的 tenantId 做一致性校验
请求头可以被客户端任意伪造。如果后端只信任请求头中的租户标识,攻击者只需修改 header 就能访问其他租户的数据。
java// ❌ 错误: 只信任请求头,未校验 token @Override public boolean preHandle(HttpServletRequest request, ...) { String tenantCode = request.getHeader("X-Tenant-Code"); TenantMiniAppConfig config = configRepository.findByTenantCode(tenantCode); TenantContext.setTenantId(config.getTenantId()); // 直接信任 header return true; } // 攻击者拿着 tenantId=1 的 token,配上 X-Tenant-Code: OTHER_TENANT // 就能读到其他租户的数据
java// ✅ 正确: header 只做路由定位,必须与 token tenantId 校验一致 @Override public boolean preHandle(HttpServletRequest request, ...) { String tenantCode = request.getHeader("X-Tenant-Code"); TenantMiniAppConfig config = configRepository.findByTenantCode(tenantCode); // 从认证 token 中取出 tenantId(真相源) Long tokenTenantId = (Long) request.getAttribute("tokenTenantId"); if (tokenTenantId != null && !tokenTenantId.equals(config.getTenantId())) { response.setStatus(403); response.getWriter().write("{\"code\":403,\"message\":\"租户信息不匹配\"}"); return false; } TenantContext.setTenantId(config.getTenantId()); return true; }
Repository.findById(id) 缺租户过滤> 与陷阱 #2 并行:陷阱 #2 是「全局过滤兜底」,本陷阱是「显式深度防御」。即使项目已用 Hibernate @Filter / MyBatis 拦截器,仍建议 Service 层显式调用 findByTenantIdAndId——主键直查在二级缓存命中、getReferenceById 等路径上常常绕过全局过滤。两层一起用,可读性也更好(看 Service 代码就知道隔离了 tenantId)。
场景: 即使有了全局过滤机制,开发者在 Service 里直接 repo.findById(id) 仍可能绕过过滤——主键查询常常被 JPA/Hibernate 当成"按 ID 直查",跳过 entity filter
findById 走持久化上下文,会跳过 @Filterfor (X x : list) { ... repo.findById(x.foreignKey) ... },每次都漏 tenantIdjava// ❌ 错误:直接按主键查 WxUser buyer = wxUserRepository.findById(buyerUserId).orElse(null); // ❌ 错误:N+1 修复时也漏掉 List<User> users = userIds.stream() .map(id -> userRepository.findById(id).orElse(null)) // ← 无租户过滤 .toList(); // ❌ 错误:批量 IN 也忘记带 tenantId List<User> users = userRepository.findAllById(userIds);
Repository 必须提供「带 tenantId 的主键查询」方法,Service 一律调用它:
java// ✅ Repository 强制提供租户感知方法 public interface WxUserRepository extends JpaRepository<WxUser, Long> { Optional<WxUser> findByTenantIdAndId(Long tenantId, Long id); List<WxUser> findByTenantIdAndIdIn(Long tenantId, Collection<Long> ids); } // ✅ Service 一律带 tenantId WxUser buyer = wxUserRepository.findByTenantIdAndId(tenantId, buyerUserId).orElse(null); // ✅ 批量也带 Map<Long, WxUser> userMap = wxUserRepository .findByTenantIdAndIdIn(tenantId, userIds) .stream() .collect(Collectors.toMap(WxUser::getId, u -> u));
bash# 1. Service 层任何裸 findById(绝大多数应迁移) grep -rn "Repository.findById(" src/main/java/**/service/ # 2. JpaRepository 默认方法(这些都"按 ID 直查",绕开 entity filter) grep -rnE "(findById|getOne|getById|getReferenceById|findAllById)\(" src/main/java/ # 3. 检查是否所有 Repository 都有租户感知主键方法 grep -L "findByTenantIdAndId" src/main/java/**/repository/*Repository.java
findById / getOne / getReferenceById / findAllByIdfindByTenantIdAndId 和 findByTenantIdAndIdIn*Service.java 里的 findById( 调用并失败构建场景: 部分查询绕过了租户过滤,导致跨租户数据泄露
依赖开发者在每个查询中手动加 WHERE tenant_id = ?,容易遗漏。
java// ❌ 错误: 忘记加租户过滤 @Query("SELECT p FROM Product p WHERE p.categoryId = :categoryId") List<Product> findByCategoryId(@Param("categoryId") Long categoryId); // 返回所有租户的商品
java// ✅ 方案1: JPA/Hibernate 全局过滤器(推荐) @Entity @FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = Long.class)) @Filter(name = "tenantFilter", condition = "tenant_id = :tenantId") public class Product { private Long tenantId; } // ✅ 方案2: 基类强制携带 tenantId public abstract class TenantAwareEntity { @Column(name = "tenant_id", nullable = false) private Long tenantId; } // ✅ 方案3: MyBatis 拦截器自动追加 tenant_id 条件 @Intercepts(@Signature(type = Executor.class, method = "query", ...)) public class TenantInterceptor implements Interceptor { // 自动在 SQL 中追加 AND tenant_id = ? }
场景: 后端返回 403(租户不匹配),但前端没有正确处理,用户看到空白页或无提示
typescript// ❌ 错误: 只处理 401,忽略 403 request.interceptors.response.use( response => response, error => { if (error.response?.status === 401) { clearAuth(); redirectToLogin(); } return Promise.reject(error); // 403 被静默吞掉 } );
typescript// ✅ 正确: 403 租户不匹配时清理登录态并跳转 request.interceptors.response.use( response => response, error => { const status = error.response?.status; const message = error.response?.data?.message || ''; if (status === 401) { clearAuth(); redirectToLogin(); } else if (status === 403 && message.includes('租户')) { clearAuth(); redirectToLogin(); showToast('登录状态异常,请重新登录'); } return Promise.reject(error); } );
场景: 管理后台的配置表单中,租户 ID 使用手动输入框,容易输错
tsx// ❌ 错误: 手动输入租户 ID,容易输错 <InputNumber placeholder="请输入租户ID" />
tsx// ✅ 正确: 下拉选择租户名称,提交时自动转为 tenantId <Select placeholder="请选择租户" onChange={(value) => { form.setFieldsValue({ tenantId: value }); const tenant = tenants.find(t => t.id === value); form.setFieldsValue({ tenantCode: tenant?.tenantCode }); }} > {tenants.map(t => ( <Option key={t.id} value={t.id}> {t.tenantName} / {t.tenantCode} </Option> ))} </Select>
场景: 仅对特定租户/角色/订阅级开放的功能,只在前端用 isYmhwTenant / hasPermission / isPaid 隐藏入口,后端 endpoint 没有独立的功能授权校验
前端 UI 控制(按钮隐藏、菜单过滤)只是用户体验优化,不是安全边界。任意已登录用户只要知道 endpoint 路径,绕过 UI 直接调用接口,就能使用本不该有的能力。这与"数据层租户隔离"是两个不同维度:
典型场景:
java// ❌ 错误: 后端只过滤当前租户数据,没校验"该租户是否启用该功能" @PostMapping("/orders/miniapp-shipping-template/export") public ResponseEntity<byte[]> export(@RequestBody ExportRequest req) { Long tenantId = TenantContext.getTenantId(); // 只查当前租户的订单(数据隔离 OK),但任何已登录租户都能调用这个接口 return service.export(tenantId, req.getOrderIds()); }
tsx// 前端通过 isYmhwTenant 隐藏入口(只是体验优化,不是安全边界) {isYmhwTenant && <Button onClick={handleExport}>导出小程序发货模板</Button>}
java// ✅ 正确: endpoint 入口处独立校验"该租户是否启用该功能" @PostMapping("/orders/miniapp-shipping-template/export") public ResponseEntity<byte[]> export(@RequestBody ExportRequest req) { Long tenantId = TenantContext.getTenantId(); // 关键: 后端独立校验租户编码/功能开关,不依赖前端 TenantMiniAppConfig config = configRepository.findByTenantId(tenantId) .orElseThrow(() -> new BusinessException(403, "未启用小程序发货模板导出")); if (!"YMHW".equalsIgnoreCase(config.getTenantCode())) { throw new BusinessException(403, "仅鱼米好物租户支持导出小程序发货模板"); } return service.export(tenantId, req.getOrderIds()); }
| 授权依据 | 实现方式 | 适用场景 | |---------|---------|---------| | 租户编码白名单 | endpoint 入口 if 校验 / @RequireTenantCode 注解 + AOP | 单个/少量租户专属 | | 角色权限 | @PreAuthorize("hasRole('ADMIN')") / Spring Security / Casbin | RBAC 体系内 | | 订阅状态 | endpoint 入口校验当前订阅是否覆盖该功能 | SaaS 分版本 |
> 优先级:1-2 个 endpoint 用直接 if 校验(最简、可读性高);3 个以上同样限制再考虑 AOP/middleware 抽象,避免过度工程化。
完整的 Java(Spring Boot 注解 AOP)/ Go(Gin middleware)/ TypeScript(Express middleware + NestJS Guard)实现示例见 references/multi-lang-examples.md。
认证与授权:
功能授权(业务层,参见陷阱 #5):
数据隔离:
前端处理:
> 📋 本回复遵循:`multi-tenant-safety` - [章节名]</instructions>
Other measured skills in the registry, with their headline benchmark lift.