# 权限管理系统 - 深度代码审计报告(第 4 轮) > 审计范围:`/internal` 下全部非测试、非 `_gen.go` 生产代码(logic、loaders、model 的 custom 层、middleware、handler、server、svc、consts、response、util)+ 入口 `perm.go` + 接口定义 `perm.api`。 > 审计时间:2026-04-19 > 审计重点: > - 并发场景下"最后一个 ADMIN"保护的真实可打破性(跨行 TOCTOU、事务内外数据脱钩) > - 状态变更接口的"无变化也强制递增 tokenVersion"导致的不必要踢出 > - 级联删除的 TOCTOU(父部门删除 vs 子部门/用户插入) > - 高频写接口的限流盲区(changePassword 等) > - 负缓存缺失 / 缓存索引集合与数据 key 的非原子 SADD/SetEx 导致的漏清理 > - 依赖 `strings.Contains(err, "1062")` 的脆弱错误分类 > - 僵尸 scaffolded 中间件 > > 相对上一轮:本轮新发现一批**未被上一轮覆盖**的 P0/P1 问题,都涉及生产环境真实可触发的业务逻辑/数据完整性破坏路径。 --- ## 🚩 核心逻辑漏洞 (High Risk) ### H-1. `UpdateMember` 的"最后一个 ADMIN"保护只看 `memberType` 变化,不看 `status` 变化 → **可把最后一个 ADMIN 直接禁用**(产品瞬间"无人管理") - **位置**:`internal/logic/member/updateMemberLogic.go:51,54-59,66-73` - **描述**: ```go needAdminCheck := member.MemberType == consts.MemberTypeAdmin && req.MemberType != consts.MemberTypeAdmin member.MemberType = req.MemberType if req.Status != 0 { if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled { return response.ErrBadRequest("状态值无效...") } member.Status = req.Status } ... if needAdminCheck { adminCount, _ := ...CountActiveAdminsTx(...) if adminCount <= 1 { return response.ErrBadRequest("不能降级该产品的最后一个管理员") } } ``` `needAdminCheck` 只在 **memberType 从 ADMIN 改成非 ADMIN** 时为 `true`。如果请求保持 `memberType=ADMIN` 但 **`status` 改为 `StatusDisabled`**,`needAdminCheck=false`,直接跳过计数检查。`CountActiveAdminsTx` 只计 `status=1` 的 ADMIN,所以"禁用最后一个 ADMIN"是在绕过该保护之下合法完成的。 攻击/误操作路径(任何持有该产品 ADMIN 令牌的账号都可做): ```http POST /api/member/update { "id": <最后一个 ADMIN 的 member 记录 id>, "memberType": "ADMIN", "status": 2 } ``` 执行结果:DB 里这条 ADMIN 的 `status=2`,`CountActiveAdmins==0`。此后: - 该产品下无人能通过 `RequireProductAdminFor` 的 Admin 校验(除了超管); - 所有需要 ADMIN 的接口(CreateRole/BindRolePerms/SetUserPerms/AddMember/…)都失锁,只能走超管通道; - 该产品上的 ADMIN 自助救援路径不存在,需要联系平台管理员。 - **影响**: - 直接破坏"产品始终保留至少一个可用 ADMIN"的业务不变式; - 非超管的恶意 ADMIN 可以"离职前锁死产品"; - 普通运维失误也可能造成同样结果,且没有任何回滚机制。 - **修复方案**: 把 `needAdminCheck` 扩展为"任何会让这条 ADMIN 记录变为非活跃的写入": ```go // internal/logic/member/updateMemberLogic.go nextType := req.MemberType nextStatus := member.Status if req.Status != 0 { nextStatus = req.Status } willBecomeInactiveAdmin := member.MemberType == consts.MemberTypeAdmin && member.Status == consts.StatusEnabled && (nextType != consts.MemberTypeAdmin || nextStatus != consts.StatusEnabled) ``` 并把后续赋值/校验改成基于 `nextType`/`nextStatus`。同时建议把 `needAdminCheck`/"最后一个 ADMIN" 同时在 `RemoveMember` 和 `UpdateMember` 里抽成一个 helper `guardLastAdminTx(session, productCode, excludingId)`,统一用 **行锁 + FOR UPDATE 遍历** 方式做。参考 H-3 的修复方案。 --- ### H-2. `RemoveMember` 在"是不是 ADMIN"判断上使用了**事务外读到的** `member.MemberType`,而把事务内 `FindOneForUpdateTx` 的返回值扔掉 → 并发下最后一个 ADMIN 会被删掉 - **位置**:`internal/logic/member/removeMemberLogic.go:32,42-53` - **描述**: ```go member, err := l.svcCtx.SysProductMemberModel.FindOne(l.ctx, req.Id) // 事务外 ... if err := l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error { if _, err := l.svcCtx.SysProductMemberModel.FindOneForUpdateTx(ctx, session, req.Id); err != nil { // <-- 返回值被丢弃 return response.ErrNotFound("成员不存在") } if member.MemberType == consts.MemberTypeAdmin { // <-- 用的是事务外的 member adminCount, err := ...CountActiveAdminsTx(...) ... } ... }) ``` `FindOneForUpdateTx` 仅用来加行锁,但返回的最新值**直接丢掉**,后续判断"这条是不是 ADMIN"仍然走事务外的 `member`。 攻击/误操作路径: - 初始:`productA` 有 ADMIN = [A1, A2];`M` 是 MEMBER。 - `T1`:调用 `RemoveMember(member.Id = M)`: - 事务外 FindOne → `member.MemberType = MEMBER`。 - `T2` 几乎同时:`UpdateMember` 把 `M` 提升成 ADMIN(事务提交)。 - `T1`:进入事务,`FindOneForUpdateTx(M)` 返回 ADMIN(被丢弃)。 - `T1`:`if member.MemberType == ADMIN` 用的是旧 MEMBER → **跳过 count 检查**。 - `T1`:删除 `M`。 - 如果 T2 是因为 A1/A2 之一被先移除而把 `M` 提到 ADMIN 补位(运营流程),T1 就在恢复期内删掉了新晋 ADMIN;极端下可导致 0 active admin。 即使不考虑跨线程竞态,这也违背了"同一事务内一次读到一次写之间必须**用事务内的**视图来决策"的原则。 - **影响**:与 H-1 同类,破坏 "至少一个 ADMIN" 不变式。 - **修复方案**: ```go if err := l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error { locked, err := l.svcCtx.SysProductMemberModel.FindOneForUpdateTx(ctx, session, req.Id) if err != nil { return response.ErrNotFound("成员不存在") } if locked.MemberType == consts.MemberTypeAdmin && locked.Status == consts.StatusEnabled { // 只有当前是"活跃 ADMIN"才需要计数 adminCount, err := ...CountActiveAdminsTx(...) if err != nil { return err } if adminCount <= 1 { return response.ErrBadRequest("不能移除该产品的最后一个管理员") } } ... }) ``` 外层的 `member` 只用来取 `UserId`/`ProductCode` 做 `CheckManageAccess` 和事后清缓存。 --- ### H-3. "最后一个 ADMIN" 保护基于 `SELECT COUNT(*)` 的快照读 → 并发移除/降级两个不同 ADMIN 会同时通过检查(跨行 TOCTOU,业务不变式被打破) - **位置**: - `internal/model/productmember/sysProductMemberModel.go:62-69` (`CountActiveAdminsTx`) - `internal/logic/member/updateMemberLogic.go:66-73` - `internal/logic/member/removeMemberLogic.go:45-52` - **描述**: ```go func (m *customSysProductMemberModel) CountActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string) (int64, error) { var count int64 query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE `productCode` = ? AND `memberType` = ? AND `status` = ?", m.table) if err := session.QueryRowCtx(ctx, &count, query, productCode, consts.MemberTypeAdmin, consts.StatusEnabled); err != nil { return 0, err } return count, nil } ``` - 非锁定读(`session.QueryRowCtx` 不附带 `FOR UPDATE` / `LOCK IN SHARE MODE`)。在 MySQL InnoDB 默认 `REPEATABLE READ` 下,这是 MVCC 快照读,**不会**阻塞也**不会**被其他事务的 INSERT/UPDATE 阻塞。 - 事务 T1 锁定并计划降级/移除 ADMIN `m1`;事务 T2 几乎同时锁定并计划降级/移除 ADMIN `m2`(`m1 ≠ m2`,所以行锁不互斥)。 - 两个事务同时做 `CountActiveAdmins`,都读到 snapshot=2,都通过 `<=1` 检查,都 commit → 活跃 ADMIN 数变成 0。 这是一个真实存在的**跨行 TOCTOU**,经典情景: - 两位超管同时对不同 ADMIN 做"降级为 DEVELOPER"; - 一个 ADMIN 并行点了"退出产品"(自己 RemoveMember)和"降级自己为 MEMBER"; - 批处理脚本 + 人工操作的并发。 - **影响**:与 H-1/H-2 累积。"至少一个 ADMIN"是系统的关键不变式,它被三条独立路径共同破坏: - H-1:通过 disable 绕过; - H-2:通过事务内外视图不一致绕过; - H-3:通过快照读+跨行并发绕过。 - **修复方案**(推荐方案 B): **方案 A(局部:对所有活跃 ADMIN 加共享锁)**: ```go func (m *customSysProductMemberModel) LockActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string) (int64, error) { var ids []int64 // 锁定该产品下所有当前活跃的 ADMIN 行;任何其它事务想把这些行变更,都必须等我 q := fmt.Sprintf( "SELECT `id` FROM %s WHERE `productCode`=? AND `memberType`=? AND `status`=? FOR UPDATE", m.table) if err := session.QueryRowsCtx(ctx, &ids, q, productCode, consts.MemberTypeAdmin, consts.StatusEnabled); err != nil { return 0, err } return int64(len(ids)), nil } ``` 在所有会让一条 ADMIN 变为非活跃的路径(`UpdateMember` 的 disable/降级 分支、`RemoveMember`)上:**先**调用 `LockActiveAdminsTx`,再判断 `count <= 1`。这样两个并发事务都会尝试锁定 **相同的一组 ADMIN 行**,其中一个会被阻塞;赢的那个先计数 → 允许或拒绝 → 提交后释放锁;输的那个获得锁后再看到准确的 count-1。 **方案 B(更稳:对产品行做粗粒度互斥)**: 在所有"可能影响 ADMIN 数量"的事务入口先 `SELECT ... FOR UPDATE` 产品行(或建一张 `sys_product_mutex` 表),让对同一产品的 ADMIN 集合变更天然串行化。业务上这些操作并发率极低(手动运营动作),粗粒度锁不会成为性能瓶颈。 --- ### H-4. `DeleteDept` 子部门/用户计数用非锁定快照读 → `CreateDept` / `CreateUser` / `UpdateUser` 能在 Delete 事务中"偷偷插入"→ 产生孤儿引用 - **位置**:`internal/logic/dept/deleteDeptLogic.go:36-63` - **描述**: ```go return l.svcCtx.SysDeptModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error { // 仅锁定要删的那一行 lockQuery := fmt.Sprintf("SELECT `id` FROM %s WHERE `id` = ? FOR UPDATE", ...) ... var childCount int64 countChildQuery := "SELECT COUNT(*) FROM sys_dept WHERE parentId = ?" session.QueryRowCtx(..., countChildQuery, req.Id) // 快照读,无锁 var userCount int64 countUserQuery := "SELECT COUNT(*) FROM sys_user WHERE deptId = ?" session.QueryRowCtx(..., countUserQuery, req.Id) // 快照读,无锁 return l.svcCtx.SysDeptModel.DeleteWithTx(...) }) ``` - 父部门加了 `FOR UPDATE`,但这只是对 `sys_dept.id=X` 这一行的 X 锁。 - 子部门计数走 `sys_dept.parentId=X`。父部门行 X 锁**不会**传递给"引用这个父"的子行,也**不会**在 `parentId` 索引上建立 gap lock(因为 COUNT(*) 不是锁定读)。 - 用户计数更离谱:`sys_user.deptId=X` 分属另一张表,跟 `sys_dept` 的行锁没有任何关系。 - `CreateDept`(`internal/logic/dept/createDeptLogic.go:46-52`)在事务外 `FindOne` 读父部门,**不加任何锁**,随后 InsertWithTx 写子行。同样 `CreateUser`、`UpdateUser`(改 deptId)都不会锁父部门。 结果: - T1 开始 Delete dept=5:父行加锁,childCount=0、userCount=0,通过。 - T2 `CreateDept(parentId=5)`:不需要任何锁,Insert 成功,commit。 - T1:DELETE parent,commit。 - 数据库里有 `sys_dept` 子行挂在一个**已不存在**的 parentId=5 → `DeptTreeLogic` 的 "parent 不存在则视为 root" 兜底 (`deptTreeLogic.go:60-62`) 只是把异常用户吞掉,组织架构彻底错乱。 - 对 `sys_user` 同理:可能生产出 `deptId=5` 但 dept 已删的孤儿用户,`loadDept`→`FindOne` 会失败静默,`DeptPath=""`,进而触发 `CheckManageAccess → checkDeptHierarchy` 的"您的部门信息异常"分支,用户相当于完全失去权限。 - **影响**: - 组织树出现悬空父指针,`DeptTree` 展示异常; - 用户的 deptId 成孤儿,`loadDept` 静默失败,用户被持续推到"无权操作"分支; - 如果外部系统以 `deptPath` 鉴权,会出现"应该有权限但系统说没"或反之的权限紊乱。 - **修复方案**: 在 `DeleteDept` 事务内,对 **parentId 索引** 和 **deptId 索引** 做"存在性锁定读": ```go // 子部门:如果有任意一行,就失败;并在 parentId 索引上加 gap/next-key 锁,阻塞并发 INSERT var tmp int64 q := "SELECT 1 FROM sys_dept WHERE parentId=? LIMIT 1 FOR UPDATE" err := session.QueryRowCtx(ctx, &tmp, q, req.Id) if err == nil { return response.ErrBadRequest("该部门下存在子部门,无法删除") } if !errors.Is(err, sql.ErrNoRows) { return err } // 用户引用:同样用锁定读 q = "SELECT 1 FROM sys_user WHERE deptId=? LIMIT 1 FOR UPDATE" err = session.QueryRowCtx(ctx, &tmp, q, req.Id) ... ``` `CreateDept` / `CreateUser` / `UpdateUser` 在写 `parentId` / `deptId` 之前,也要 `SELECT 1 FROM sys_dept WHERE id=? FOR SHARE`(S-lock 父行),这样 `DeleteDept` 的 X-lock 会阻塞这些插入,TOCTOU 被彻底消除。 对长期工程:建议直接加 MySQL FK `RESTRICT`;但即使不加 FK,应用层也必须把"存在性检查 + 删除"放在同一事务的锁定读语义下。 --- ### H-5. `ChangePasswordLogic` 无任何限流,JWT 被盗/会话仍在场景下可**暴力爆破当前密码** - **位置**:`internal/logic/auth/changePasswordLogic.go:32-62` - **描述**: ```go func (l *ChangePasswordLogic) ChangePassword(req *types.ChangePasswordReq) error { if msg := util.ValidatePassword(req.NewPassword); msg != "" { ... } userId := middleware.GetUserId(l.ctx) user, err := l.svcCtx.SysUserModel.FindOne(l.ctx, userId) ... if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil { return response.ErrBadRequest("原密码错误") } ... } ``` - 路由层:`/api/auth/changePassword` 只有 `JwtAuth` 中间件,**无 RateLimitMiddleware**。 - 逻辑层:`LogoutLogic` 和 `RefreshTokenLogic` 都挂了 `svcCtx.TokenOpLimiter`,但 `ChangePasswordLogic` 没有;也没有 `UsernameLoginLimit` 类的每用户限流。 - `bcrypt.CompareHashAndPassword` 大约 ~100ms,已登录用户可以串行每秒 ~10 次对自己当前密码做爆破。 - 威胁模型: 1. 攻击者偷到 access token(XSS/设备共用),想"把密码改成自己的"以实现持久化。由于必须知道旧密码,于是他用盗来的 token 不停调 `changePassword` 枚举旧密码。虽然 bcrypt 慢,但没有限流意味着**可以一直尝试**——被害人直到 access token 过期(默认 `AccessExpire`,通常 2h~1d)都无法自救。 2. 更现实:同一台办公机、同一设备同一账号,未离开时间段内 malicious script 执行百万次尝试;每次失败 `ErrBadRequest("原密码错误")`,既不记录日志也不递增 tokenVersion,失败完全"无痕"。 - 对比:`LogoutLogic` 做了 10/60s 限流,`RefreshTokenLogic` 也做了;`ChangePasswordLogic` 显然遗漏。 - **影响**: - 提供了"持有短期 access token 后可以 brute-force 当前密码,命中后改密持久化"的攻击路径; - 一旦命中,新密码会触发 `tokenVersion+1`(`UpdatePassword` 里),**踢掉真正的用户**,攻击者用新密码重新登录即可接管(管理后台也同理,因为它用同一套 bcrypt)。 - **修复方案**: ```go // internal/logic/auth/changePasswordLogic.go if l.svcCtx.TokenOpLimiter != nil { code, _ := l.svcCtx.TokenOpLimiter.Take(fmt.Sprintf("chpwd:%d", userId)) if code == limit.OverQuota { return response.ErrTooManyRequests("操作过于频繁,请稍后再试") } } ``` 建议在 `ValidatePassword` 之后、`FindOne` 之前立即做(失败也计数,避免 "先写合法新密码才触发限流" 的绕过)。同时把失败做一条 `logx.WithContext(l.ctx).Infof("change-password old-password mismatch userId=%d", userId)`,给 SOC 提供可观测性。 --- ## ⚠️ 健壮性与性能建议 (Medium / Low) ### M-1. `UpdateUserStatus` 对"状态无变化"的请求仍然强制递增 `tokenVersion`,把被操作用户不必要地踢下线 - **位置**:`internal/logic/user/updateUserStatusLogic.go:31-52`、`internal/model/user/sysUserModel.go:137-150` - **描述**:`UpdateUserStatus` 没有对比 `user.Status` 与 `req.Status`,不管是否真的变更,都调用 `SysUserModel.UpdateStatus`;模型层 SQL 为 `UPDATE … SET status=?, tokenVersion=tokenVersion+1 …` **无条件**递增。结果:管理员点一次"启用"按钮(用户原本就是启用),所有该用户在场的会话全部下线。 - 对比 `UpdateUserLogic`(`updateUserLogic.go:122-135`):显式 `if user.Status != req.Status { statusChanged = true }`,只在真正变化时才递增。两处逻辑不一致。 - **修复**:进入 logic 时先读当前值,仅当 `user.Status != req.Status` 时调 UpdateStatus;或者在 SQL 上加 `WHERE id=? AND status<>?` 并仅当 `RowsAffected>0` 时认为"确实变更"。 ### M-2. `generateRandomHex` 长度截断导致 `appSecret` / `adminPassword` 熵减半(上一轮 M-3,仍未修复) - **位置**:`internal/logic/product/createProductLogic.go:158-164` - **描述**: ```go func generateRandomHex(length int) (string, error) { b := make([]byte, length) rand.Read(b) return hex.EncodeToString(b)[:length], nil // 😱 截断 } ``` - `hex.EncodeToString(N bytes) => 2N chars`;`[:length]` 拿到 `length` 个 hex 字符 = `length/2` 字节随机性。 - `generateRandomHex(64)` 生成 `appSecret` → **32 字节**熵,不是 64; - `generateRandomHex(32)` 生成 `appKey` → **16 字节** / 128 bit,勉强; - `generateRandomHex(8)` 生成首任管理员初始密码 → **4 字节 = 32 bit**,可在 10 分钟内离线爆破(就算走 bcrypt,超管持有明文返回值且要邮件/IM 明文传给运营方——泄漏风险真实存在)。 - **修复**: ```go func generateRandomHex(byteLen int) (string, error) { b := make([]byte, byteLen) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil // 返回 2*byteLen 个 hex 字符 } ``` 调用方按"字节数"来传;初始管理员密码至少 12 字节随机 → 96 bit 熵,或者直接改用人类可读的 passphrase。 ### M-3. `UserDetailsLoader` 对 "不存在的用户 / 错误的 (userId, productCode)" 没有负缓存 → 每次请求都穿透到 DB - **位置**:`internal/loaders/userDetailsLoader.go:119-144` - **描述**:`loadFromDB` 若 `ud.Username == ""`(用户不存在或已被删),`sf.Do` 返回 `(nil, nil)`,**不写任何缓存**。`Load` 返回一个空 UserDetails。下一次同样请求仍然走一次完整 DB 链路(`FindOne` → 命中模型层 cache miss → DB)。 - **影响**: - 账号被删除后,旧 access token 的每次请求都会触发 1 次 `sys_user.FindOne` 未命中的 DB 查询(直到 token 过期,默认可达 1h~24h)。 - 攻击者发送垃圾 access token(签名正确但 `userId` 为不存在 id)即可放大 DB 压力;虽然签名伪造需要 secret,但一个离职用户保留的 token 就能让内网攻击者制造 DB 噪声。 - **修复**:在 `loadFromDB` 识别到 "用户不存在 / 已删除" 时,写一条 TTL 很短(15~60s)的负缓存标记(例如 cache 一个 `ud.Username=="_not_found_"`),`Load` 检测到该标记直接返回"不存在",无需再次查库。 ### M-4. `UserDetailsLoader` 的 "index set 添加 (SADD) 与数据 key 写入 (SETEX)" 非原子 → 并发下 `Clean` 可能漏清 - **位置**:`internal/loaders/userDetailsLoader.go:127-132,185-199,201-219` - **描述**:写链路顺序是 `SETEX(key, json)` → `SADD(userIdxKey, key)` → `EXPIRE(userIdxKey)`;清链路是 `SMEMBERS(userIdxKey)` → `DEL(keys...)` → `DEL(userIdxKey)`。 交错: 1. Thread A 写入:`SETEX` 完成。 2. Thread B 触发 `Clean(userId)`:`SMEMBERS` 尚未看到新 key → `DEL` 清单中不含它 → `DEL idxKey` 清掉索引。 3. Thread A 继续:`SADD` 把 key 加回到(已被删除的)索引 → 再 `EXPIRE`(这步会重新创建 set,于是孤儿索引);同时数据 key 仍在 Redis 里。 4. 结果:**数据 key 实际仍在**,`Clean` 却已经认为"清完了"。stale 数据持续到 `ttl=300s` 才自然过期。 这在 `BindRoles → Clean`、`UpdateUser → Clean`、`UpdateRole → BatchDel` 等高频写入路径都可复现。 - **修复**: - 用 Redis 事务 / 脚本把 `SETEX + SADD + EXPIRE` 打包成单次 `EVAL`; - 清理链路也用 Lua 把 `SMEMBERS + DEL keys + DEL idxKey` 原子化; - 更简洁的替代:弱一致保证下,在每次 `Del/Clean` 后发一条 "延迟二次清除" 消息(比如 `DEL after 1s`),补偿这一窄竞态。 ### M-5. `UpdateRole` / `BindRolePerms` 在事务提交后读 `FindUserIdsByRoleId` 时**忽略错误**,Redis 抖动时会漏清缓存 - **位置**:`internal/logic/role/updateRoleLogic.go:73-75`、`internal/logic/role/bindRolePermsLogic.go:127-128` - **描述**: ```go affectedUserIds, _ := l.svcCtx.SysUserRoleModel.FindUserIdsByRoleId(l.ctx, req.Id) l.svcCtx.UserDetailsLoader.BatchDel(l.ctx, affectedUserIds, role.ProductCode) ``` `_,_` 丢 err,`affectedUserIds` 为空即 `BatchDel` 无作为。底层 DB 任何抖动都会让这次变更的缓存失效丢失;用户最多需要 5 分钟才拿到新权限。 - **修复**:`return err` 并在调用链把这个错误归为 "已更新但缓存未清" 的 500;或者失败时写进 retry 队列,由后台任务重做。 ### M-6. `UpdateRole` / `UpdateProduct` / `UpdateMember` 没有乐观锁(与 `UpdateUser` / `UpdateDept` 的策略不一致) - **位置**: - `internal/logic/role/updateRoleLogic.go:69`(`SysRoleModel.Update(ctx, role)`) - `internal/logic/product/updateProductLogic.go:58`(`SysProductModel.Update(...)`) - `internal/logic/member/updateMemberLogic.go:75`(`SysProductMemberModel.UpdateWithTx(...)`) - **描述**:`UpdateUserLogic` 和 `UpdateDeptLogic` 用了 `UpdateWithOptLock(expectedUpdateTime)` 防并发覆盖;但相同模式的 `UpdateRole` / `UpdateProduct` / `UpdateMember` 仍是无条件 `UPDATE`: - 两位管理员同时修改同一角色(A 改名字,B 改 permsLevel),后提交者会全字段覆盖先提交者的改动,**丢失字段**。 - **修复**:统一给三者加 `UpdateWithOptLock(expectedUpdateTime)` / 或在 SQL WHERE 子句中加 `updateTime = ?`,并在受影响行数为 0 时返回 `ErrUpdateConflict`。 ### M-7. `AdminLoginLogic` 缺少"不存在账号的 dummy bcrypt",响应时间可用于 username 枚举 - **位置**:`internal/logic/pub/adminLoginLogic.go:48-66` - **描述**:`ValidateProductLogin` 在用户不存在时跑一次 `dummyBcryptHash` 做恒时对齐(`loginService.go:53`),但 `AdminLoginLogic` 没有——直接 `return "用户名或密码错误"`。结合错误信息差异("账号已被冻结" vs "仅超级管理员可通过管理后台登录")和响应时间差异(bcrypt ~100ms vs 不调用 bcrypt ~1ms),攻击者可在 `AdminLoginRateLimit(20/min)`、`UsernameLoginLimit(10/5min)` 限额内做较精确枚举。 - **修复**:与 `ValidateProductLogin` 对齐: ```go if errors.Is(err, user.ErrNotFound) { bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(req.Password)) return nil, response.ErrUnauthorized("用户名或密码错误") } ``` 并把"账号已被冻结" / "仅超级管理员可通过管理后台登录" 这两个分支统一归到 `"用户名或密码错误"`(管理后台侧不暴露任何有效账号的状态信息),让登录失败无法区分。 ### M-8. `ProductList` / `ProductDetail` / `DeptTree` 无访问控制,普通成员能看全量产品名 / 组织架构(上一轮 M-4/M-5,仍未修复) - **位置**: - `internal/logic/product/productListLogic.go:31-58` - `internal/logic/product/productDetailLogic.go:29-48` - `internal/logic/dept/deptTreeLogic.go:27-67` - **描述**:都只有 `JwtAuth`,没有任何"是当前产品成员 / 当前部门管辖"过滤。`ProductList` 只是把 `appKey` 在非超管时置空,名称/备注仍返回;`ProductDetail` 给任何 id 都能读。`DeptTree` 更是一次性返回所有部门——对外泄漏组织结构和产品清单。 - **修复**: - `ProductList` 改为"按 `sys_product_member` inner join `productCode` 过滤成当前调用者所属的产品集合";超管才能看全量; - `ProductDetail` 对非超管校验 `caller.ProductCode == product.Code`; - `DeptTree` 至少要求 `RequireSuperAdmin` 或返回以调用者部门为根的子树。 ### M-9. `SetUserPermsLogic` 重复执行了同一条 `FindOneByProductCodeUserId` 查询(`CheckManageAccess → checkPermLevel` 内部做一次,随后 `SetUserPerms` 里又做一次) - **位置**:`internal/logic/user/setUserPermsLogic.go:54-64` + `internal/logic/auth/access.go:148-157` - **描述**:`CheckManageAccess(req.UserId, productCode)` 内部 `checkPermLevel` 已经 `FindOneByProductCodeUserId(productCode, targetUserId)`;紧随其后 `SetUserPerms` 又做一次同样的读,再加上一次 `FindOne(user)`、一次 `FindOneByCode(product)`、一次 `FindByIds(perms)`——在写路径上累计 5~6 次 DB read。考虑到模型层都有 `CachedConn`,多数命中缓存,但 `FindMinPermsLevelByUserIdAndProductCode` 是无缓存查询,每次接口调用都会实际打一次 DB。 - **修复**: - 让 `CheckManageAccess` 把解析出来的 `targetMember` / `targetRoleLevel` 通过返回值或 context 透出,SetUserPerms 直接复用; - 或把整个权限校验前置到单独的 "AuthContext" 对象,一次加载。 ### M-10. `VerifyToken` gRPC 对"无效 token"完全不记录日志,也没有调用方识别字段(appKey/serviceName)→ 生产上不可观测 - **位置**:`internal/server/permserver.go:173-208` - **描述**:所有失败分支都 `return &pb.VerifyTokenResp{Valid: false}, nil`,不分类、不打日志。线上"用户反馈总是 401"时没法判断是 `TokenVersion` 不符还是 `ProductStatus` 禁用还是别的;更没有"该产品服务在过去 10 分钟发生了 10k 次 VerifyToken 失败"这种告警能力。 - **修复**:至少保留 `logx.WithContext(ctx).Infof("verifyToken fail userId=%d reason=%s", claims.UserId, reason)`;`reason` 单独落字段方便日志聚合。 ### M-11. gRPC `PermServer.Login` 在 `peer.FromContext` 失败时静默跳过限流(fail-open) - **位置**:`internal/server/permserver.go:62-76` - **描述**:`if s.svcCtx.GrpcLoginLimiter != nil { if p, ok := peer.FromContext(ctx); ok { … rate limit … } }`——如果 `ok == false`(走 in-process / socket 无 peer 信息的场景)就**不限流**。配合 ExecuteSyncPerms 同样逻辑(gRPC 不限流,HTTP 层才有),理论上内部调用或错误配置下会绕过保护。 - **修复**:`ok == false` 时把 key 当作 `"grpc:login:unknown"` 走限流,更严苛的做法是"没 peer → 直接拒绝"(因为生产环境都在 gRPC over TCP)。 ### M-12. 三个 scaffolded 中间件文件是僵尸代码,从未注册也没被引用 - **位置**: - `internal/middleware/adminloginratelimitMiddleware.go` - `internal/middleware/productloginratelimitMiddleware.go` - `internal/middleware/syncratelimitMiddleware.go` - **描述**:三个文件各自声明一个空壳类型和 `Handle` 方法(直接 passthrough),路由里实际使用的是 `svc.ServiceContext` 里基于 `RateLimitMiddleware` 构造出来的实例。两边同名但互不相干,静态分析工具看来这些 `*Middleware` 类型无任何引用。 - **修复**:直接删除这三个文件;或者把它们改成 `// Deprecated. Use ...` 并在下次发版一起删。 ### M-13. `bindRolesLogic` 的 "role level" 检查对 DEVELOPER 无条件放行 - **位置**:`internal/logic/user/bindRolesLogic.go:85-91` - **描述**: ```go if !caller.IsSuperAdmin && caller.MemberType != consts.MemberTypeAdmin && caller.MemberType != consts.MemberTypeDeveloper { if caller.MinPermsLevel == math.MaxInt64 || r.PermsLevel < caller.MinPermsLevel { return response.ErrForbidden("不能分配权限级别高于自身的角色") } } ``` DEVELOPER 绕过 perms-level 校验——**语义正确前提**是"DEVELOPER 已经拿到产品全部权限(`loadPerms` 里走全权分支)",因此让 TA 给 MEMBER 分高阶角色不算越权。但这条业务语义并不显式写在代码里,任何未来把 DEVELOPER 权限收窄的改动都会立刻让这里成为漏洞。 - **修复**:在 `access.go` 里统一写一个 `callerIsFullPermInProduct(ud) bool`(条件:SuperAdmin / ADMIN / DEVELOPER / 或 `DeptType==DEV` 且成员启用),所有依赖"caller 已拥有全权"做的短路都复用它,变更只需改一处。`loadPerms` 的判定也统一走它。 ### M-14. `strings.Contains(err.Error(), "1062")` 脆弱的错误识别(多处) - **位置**: - `internal/logic/user/createUserLogic.go:92-96` - `internal/logic/role/createRoleLogic.go:67-71` - `internal/logic/member/addMemberLogic.go:76-80` - `internal/logic/product/createProductLogic.go:134-144` - **描述**:所有冲突检测都走字符串匹配 `"1062"` / `"Duplicate entry"`。一旦换 driver 版本或 MySQL 升级导致文案变化,这些检测直接失效,逻辑全部变成 500。产品/用户的唯一索引冲突会被当成内部错误吃掉。 - **修复**:换成 `mysql.MySQLError` 类型判断: ```go import "github.com/go-sql-driver/mysql" var me *mysql.MySQLError if errors.As(err, &me) && me.Number == 1062 { return response.ErrConflict(...) } ``` ### M-15. `ChangePasswordLogic` 在 bcrypt 校验之前不检查 `user.Status` 冻结状态 - **位置**:`internal/logic/auth/changePasswordLogic.go:37-45` - **描述**:`FindOne → bcrypt.CompareHashAndPassword`,没有 `if user.Status != StatusEnabled`。JWT 中间件已经对冻结用户拦截,但 (a) 冻结后中间件 loader cache 未及时失效的 race 窗口;(b) 冻结状态的用户理论上仍可调 changePassword(虽然此 JWT 已被拦截)。防御纵深建议双保险。 - **修复**: ```go if user.Status != consts.StatusEnabled { return response.ErrForbidden("账号已被冻结") } ``` ### L-1. `SetUserPerms` / `BindRoles` / `BindRolePerms` 的去重写回到了 `req`(副作用入参) - **位置**: - `setUserPermsLogic.go:72-86` - `bindRolesLogic.go:58-68` - `bindRolePermsLogic.go:44-54` - **描述**: ```go uniquePerms := make([]types.UserPermItem, 0, len(req.Perms)) ... req.Perms = uniquePerms ``` 对外部传入的 `req` 做原地写入会让上层(例如 handler、中间件、单元测试)读到被改过的结构,违背"logic 不修改入参"原则。 - **修复**:改成局部变量 `perms := uniquePerms`;后续所有流程使用 `perms`,不再碰 `req.Perms`。 ### L-2. `memberTypePriority("")` 返回 `MaxInt32`,在 `CheckMemberTypeAssignment` 下会与未知类型保持等价判定 - **位置**:`internal/logic/auth/access.go:15-28,67-69` - **描述**:若 `caller.MemberType == ""`(例如产品禁用后被 loader 清空),`memberTypePriority("")==MaxInt32`;`if MaxInt32 >= priority(assigned)` 恒真 → `CheckMemberTypeAssignment` 直接拒绝。这里"fail-closed"是正确行为,但逻辑靠的是 sentinel 值而非显式分支,容易在将来扩展 MemberType 时出错。 - **修复**:显式判断 `caller.MemberType == "" → return ErrForbidden("缺少产品成员上下文")`。 ### L-3. `UserDetailsLoader.Clean` 的错误忽略会扩散 - **位置**:`internal/loaders/userDetailsLoader.go:150-153,177-179,186-198` - **描述**:Redis 所有操作失败都只 `Errorf` 记日志,然后继续。当 Redis 间歇不可用时,管理员以为"清完了",实际上旧缓存可能至少 5 分钟内生效。 - **修复**:对关键变更路径(角色授权、状态变更)做"两次清理 + 失败回退":第一次 `Del` 失败 → 在队列里起一次 retry;或者把 UserDetails 的缓存层 TTL 缩短到 60s 并接受 DB 压力(负载允许前提下)。 ### L-4. `DeptTree` 对"孤儿 parent"只打日志即当 root 并继续返回 → 静默数据异常 - **位置**:`internal/logic/dept/deptTreeLogic.go:55-63` - **描述**:如果发生 H-4 的 TOCTOU,树里会出现 "parentId 指向不存在的 id" 的部门,代码会把它"视作 root"继续放回前端。**数据已损坏但用户/管理员无感知**,管理员很可能在之后一通操作里把 children 移到别处却不清楚真正丢失的是哪棵子树。 - **修复**:同一批异常记录直接在响应里带一个 `warnings` 字段或在 HTTP 头里 flag,让前端报警;后台做一条 alerting 指标 `dept_orphan_count > 0`。 ### L-5. 多数接口在 DB 错误时直接 `return err` 而不包装为统一 500,生产会透出底层 sqlx/gorm/bcrypt 错误文本 - **位置**:几乎所有 logic 文件的尾端 - **描述**:`response.Setup` 会把非 `*CodeError` 的错误映射成 `{code: 500, msg: "服务器内部错误"}` 并 `logx.Errorf`。这本身没问题,但很多 logic 在上下文敏感的位置(比如 bcrypt 生成失败)返回原始 err 到调用栈,日志里出现原始 bcrypt 错误文本也是一种信息披露。建议所有 logic 统一包装成 `response.ErrInternal("xxx 失败")` 并把原 err 放入 `logx.Errorf` 里,避免上下层关心如何转换。 --- ## 🧭 总结与本轮优先级 | 优先级 | 问题 | 关键词 | | --- | --- | --- | | P0 | H-1 | UpdateMember 禁用最后 ADMIN 绕过 | | P0 | H-2 | RemoveMember 事务内外视图脱钩 | | P0 | H-3 | CountActiveAdmins 跨行 TOCTOU | | P0 | H-4 | DeleteDept 子部门/用户 TOCTOU | | P0 | H-5 | ChangePassword 无限流可暴力破旧密码 | | P1 | M-1 | UpdateUserStatus "无变化也踢下线" | | P1 | M-2 | generateRandomHex 熵减半 | | P1 | M-6 | UpdateRole/Product/Member 缺乐观锁 | | P1 | M-7 | AdminLogin username 枚举 | | P1 | M-8 | ProductList / ProductDetail / DeptTree 无访问控制 | | P2 | M-3/M-4/M-5/M-9/M-10/M-11/M-13/M-14/M-15 | 可观测 / 容错 / 代码一致性 | | P3 | M-12 / L-* | 僵尸代码、副作用入参、错误透传 | ### 建议的修复顺序 1. **立刻修 H-1 / H-2 / H-3**:三者共同保护"产品至少一个 ADMIN"不变式,彼此独立,必须三条路径一起堵。建议抽一个 `guardLastActiveAdminTx(session, productCode, targetMemberId)` helper,`UpdateMember` 和 `RemoveMember` 都调用它;内部先 `SELECT id ... FOR UPDATE` 锁定所有活跃 ADMIN,再做 count/是否将失活判断。 2. **修 H-4**:部门删除用 `SELECT 1 ... FOR UPDATE` 做存在性锁定读;`CreateDept`/`CreateUser`/`UpdateUser` 写 deptId 前对父部门 `SELECT ... FOR SHARE`。短期方案即可生效,长期改 FK。 3. **修 H-5**:changePassword 加 `TokenOpLimiter.Take("chpwd:%d")`;补充失败日志。 4. **批量修 M-1 / M-6 / M-2 / M-7**:这几条都是一行到十行级的小改,收益很高。 5. **有余力再做 M-3/M-4/M-5/M-10/M-14**:涉及缓存层或错误分类,需要更严谨的回归测试。 > 注:本轮报告不再列"已在上轮修复"或"已被单测覆盖"的 finding(比如 H-A/H-B、TokenVersion 相关等),见 `test-report.md` 对应条目。