新增内容: 1. 添加 AGENTS.md 和 CLAUDE.md AI 助手配置文件 2. 添加安全修复说明文档 (doc/security-fixes.md) 3. 新增单元测试用例: - fund-common: TenantContextHolderTest, UserContextHolderTest, PageResultTest, ResultTest - fund-sys: AuthServiceImplTest, RoleServiceImplTest, TenantServiceImplTest 修改内容: 1. 数据库初始化脚本更新 (fund_sys_init.sql) 2. 前端依赖更新 (package.json) 3. 登录和密码管理功能优化: - 管理后台和移动端登录页面 - 密码修改功能 4. 租户上下文处理优化 (TenantLineHandlerImpl) 5. 网关过滤器增强: - TenantGatewayFilter 租户过滤 - TokenAuthFilter 认证过滤 6. Controller 层代码优化 7. DTO 和 Service 层代码改进 技术改进: - 密码加密方式从 BCrypt 改为 MD5(前后端一致) - 登录验证流程优化,支持多租户 - 增加日志输出便于调试 - 代码规范性和可维护性提升
169 lines
6.9 KiB
Markdown
169 lines
6.9 KiB
Markdown
# 租户隔离安全漏洞修复记录
|
||
|
||
**修复日期**: 2026-03-01
|
||
**修复分支**: master
|
||
**修复范围**: 多租户数据隔离机制
|
||
|
||
---
|
||
|
||
## 漏洞一:网关未覆盖客户端伪造的 X-Tenant-Id(高危)
|
||
|
||
### 漏洞描述
|
||
|
||
`TokenAuthFilter` 在 Token 验证通过后,使用 Spring WebFlux 的 `request.mutate().header()` 将 Token 中的 tenantId 写入请求头。然而 WebFlux 的 `header()` 方法是**追加**而非替换语义,若客户端预先在请求中设置了 `X-Tenant-Id`,下游服务从 `getFirst()` 取到的仍然是客户端伪造的值。攻击者可以通过在请求中携带任意 `X-Tenant-Id` 来访问其他租户的数据。
|
||
|
||
### 攻击场景
|
||
|
||
```
|
||
攻击者发送请求:
|
||
Authorization: Bearer <合法Token,属于租户A>
|
||
X-Tenant-Id: 999 ← 伪造的租户ID
|
||
```
|
||
|
||
原始代码中,下游 `ContextInterceptor` 从请求头取到的 `X-Tenant-Id` 可能是 999,导致 `TenantContextHolder` 被设置为 999,进而 MyBatis-Plus 租户插件以 tenant_id=999 过滤 SQL,越权访问其他租户数据。
|
||
|
||
### 修复方案
|
||
|
||
在 `TokenAuthFilter` 中先移除客户端传来的 `X-Tenant-Id`,再写入 Token 中已认证的值:
|
||
|
||
```java
|
||
// 安全修复:先移除客户端传来的 X-Tenant-Id,再写入 Token 中已认证的值
|
||
ServerHttpRequest mutatedRequest = request.mutate()
|
||
.headers(headers -> headers.remove(TENANT_ID_HEADER)) // 先删除客户端值
|
||
.header(USER_ID_HEADER, String.valueOf(tokenInfo.getUserId()))
|
||
.header(USERNAME_HEADER, tokenInfo.getUsername())
|
||
.header(TENANT_ID_HEADER, String.valueOf(tokenInfo.getTenantId())) // 再写入认证值
|
||
.build();
|
||
```
|
||
|
||
### 修复位置
|
||
|
||
`fund-gateway/src/main/java/com/fundplatform/gateway/filter/TokenAuthFilter.java` 第 86-93 行
|
||
|
||
---
|
||
|
||
## 漏洞二:IGNORE_TABLES 包含业务敏感表(高危)
|
||
|
||
### 漏洞描述
|
||
|
||
`TenantLineHandlerImpl.IGNORE_TABLES` 中包含了 `sys_user`、`sys_role`、`sys_dept`、`sys_config` 等业务表,这些表均有 `tenant_id` 字段,属于租户私有数据。将其加入忽略列表后,MyBatis-Plus 不会为这些表的查询自动注入 `tenant_id` 过滤条件,导致以下问题:
|
||
|
||
1. **跨租户用户数据泄漏**:查询 `sys_user` 时返回所有租户的用户数据
|
||
2. **跨租户角色泄漏**:查询 `sys_role` 时返回所有租户的角色配置
|
||
3. **跨租户部门泄漏**:查询 `sys_dept` 时返回所有租户的部门结构
|
||
|
||
### 修复方案
|
||
|
||
`IGNORE_TABLES` 中仅保留真正全平台共享的静态数据表:
|
||
|
||
```java
|
||
// 修复后:仅保留平台级全局共享表
|
||
private static final Set<String> IGNORE_TABLES = new HashSet<>(Arrays.asList(
|
||
"sys_menu", // 菜单表(系统菜单结构全局共享)
|
||
"sys_dict", // 字典表(枚举数据全局共享)
|
||
"sys_log", // 日志表(独立存储逻辑)
|
||
"gen_table", // 代码生成表
|
||
"gen_table_column"
|
||
));
|
||
// 已移除:sys_user、sys_role、sys_dept、sys_config(均为租户私有数据)
|
||
```
|
||
|
||
### 修复位置
|
||
|
||
`fund-common/src/main/java/com/fundplatform/common/mybatis/TenantLineHandlerImpl.java` 第 34-46 行
|
||
|
||
---
|
||
|
||
## 漏洞三:租户 ID 缺失时 Fallback 到默认值 1(中危)
|
||
|
||
### 漏洞描述
|
||
|
||
`TenantLineHandlerImpl.getTenantId()` 中,当从 `TenantContextHolder` 获取不到租户 ID 时,代码回退到默认值 `1L`:
|
||
|
||
```java
|
||
// 漏洞代码
|
||
if (tenantId == null) {
|
||
tenantId = 1L; // 危险:误操作租户1的数据
|
||
}
|
||
```
|
||
|
||
这导致在没有认证上下文的场景(如定时任务、内部错误等)下,SQL 会意外查询租户1的数据,可能造成租户1数据泄漏或误写入。
|
||
|
||
### 修复方案
|
||
|
||
当租户上下文缺失时直接抛出异常,中断 SQL 执行:
|
||
|
||
```java
|
||
// 修复后:缺失租户上下文时强制报错
|
||
if (tenantId == null) {
|
||
throw new IllegalStateException("[Security] 当前请求缺少租户上下文,拒绝执行SQL");
|
||
}
|
||
```
|
||
|
||
### 修复位置
|
||
|
||
`fund-common/src/main/java/com/fundplatform/common/mybatis/TenantLineHandlerImpl.java` 第 56-66 行
|
||
|
||
---
|
||
|
||
## 漏洞四:登录接口跨租户用户名混乱(中危)
|
||
|
||
### 漏洞描述
|
||
|
||
登录接口 `/auth/login` 在 Token 白名单中,`TenantContextHolder` 为空。由于 `sys_user` 原来在 `IGNORE_TABLES` 中(全局查询),登录时仅按用户名查询不区分租户:
|
||
|
||
```java
|
||
// 漏洞代码:不同租户可能有同名用户,查到哪个是不确定的
|
||
wrapper.eq(SysUser::getUsername, request.getUsername());
|
||
// 未加 tenantId 条件
|
||
SysUser user = userDataService.getOne(wrapper);
|
||
```
|
||
|
||
当不同租户有同名用户时,认证结果不确定,存在以下风险:
|
||
- 用户 A(租户1)可能以租户2用户的身份登录
|
||
- 登录成功后 Token 中绑定了错误的 tenantId
|
||
|
||
### 修复方案
|
||
|
||
1. `LoginRequestDTO` 增加 `tenantId` 字段(必填),前端登录时显式指定租户
|
||
2. 登录查询使用 `TenantIgnoreHelper` 跳过自动租户过滤,同时显式加入 `tenant_id` 条件:
|
||
|
||
```java
|
||
// 修复后:使用 TenantIgnoreHelper + 显式 tenantId 条件
|
||
SysUser user = TenantIgnoreHelper.ignore(() -> {
|
||
LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>();
|
||
wrapper.eq(SysUser::getUsername, request.getUsername());
|
||
wrapper.eq(SysUser::getTenantId, request.getTenantId()); // 显式限定租户
|
||
wrapper.eq(SysUser::getDeleted, 0);
|
||
return userDataService.getOne(wrapper);
|
||
});
|
||
```
|
||
|
||
### 修复位置
|
||
|
||
- `fund-sys/src/main/java/com/fundplatform/sys/dto/LoginRequestDTO.java` — 新增 tenantId 字段
|
||
- `fund-sys/src/main/java/com/fundplatform/sys/service/impl/AuthServiceImpl.java` 第 31-44 行
|
||
|
||
---
|
||
|
||
## 修复总结
|
||
|
||
| 编号 | 漏洞类型 | 危险级别 | 修复文件 |
|
||
|------|---------|---------|---------|
|
||
| 1 | 网关未覆盖客户端伪造的 X-Tenant-Id | 高危 | `fund-gateway/.../TokenAuthFilter.java` |
|
||
| 2 | IGNORE_TABLES 包含业务敏感表 | 高危 | `fund-common/.../TenantLineHandlerImpl.java` |
|
||
| 3 | 租户 ID 缺失时 fallback 到默认值 | 中危 | `fund-common/.../TenantLineHandlerImpl.java` |
|
||
| 4 | 登录查询未限定租户范围 | 中危 | `fund-sys/.../AuthServiceImpl.java`, `LoginRequestDTO.java` |
|
||
|
||
## 修复后的安全保证
|
||
|
||
1. **网关层**:经过 Token 验证的请求,`X-Tenant-Id` 由 Token 中的认证值强制覆盖,客户端无法伪造
|
||
2. **数据层**:业务表(sys_user、sys_role、sys_dept、sys_config)均受 MyBatis-Plus 租户插件保护,自动注入 `tenant_id` 过滤
|
||
3. **兜底保护**:租户上下文为空时 SQL 拒绝执行,不会 fallback 到任何租户
|
||
4. **登录安全**:登录时必须指定 tenantId,确保同名用户不会跨租户混乱认证
|
||
|
||
## 注意事项
|
||
|
||
- 所有需要跨租户操作的合法场景(如超管管理所有租户),必须显式使用 `TenantIgnoreHelper.ignore()` 包装,并在代码中注明安全意图
|
||
- 定时任务、事件监听器等异步场景在执行 DB 操作前,必须先设置 `TenantContextHolder`,执行完后在 finally 中清理
|