audit-report.md 36 KB

权限管理系统 - 深度代码审计报告(第 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=2CountActiveAdmins==0。此后:

  • 该产品下无人能通过 RequireProductAdminFor 的 Admin 校验(除了超管);
  • 所有需要 ADMIN 的接口(CreateRole/BindRolePerms/SetUserPerms/AddMember/…)都失锁,只能走超管通道;
  • 该产品上的 ADMIN 自助救援路径不存在,需要联系平台管理员。

  • 影响

    • 直接破坏"产品始终保留至少一个可用 ADMIN"的业务不变式;
    • 非超管的恶意 ADMIN 可以"离职前锁死产品";
    • 普通运维失误也可能造成同样结果,且没有任何回滚机制。
  • 修复方案: 把 needAdminCheck 扩展为"任何会让这条 ADMIN 记录变为非活跃的写入":

  // 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" 同时在 RemoveMemberUpdateMember 里抽成一个 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
  • 描述

    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 几乎同时:UpdateMemberM 提升成 ADMIN(事务提交)。
    • T1:进入事务,FindOneForUpdateTx(M) 返回 ADMIN(被丢弃)。
    • T1if member.MemberType == ADMIN 用的是旧 MEMBER → 跳过 count 检查
    • T1:删除 M
    • 如果 T2 是因为 A1/A2 之一被先移除而把 M 提到 ADMIN 补位(运营流程),T1 就在恢复期内删掉了新晋 ADMIN;极端下可导致 0 active admin。

    即使不考虑跨线程竞态,这也违背了"同一事务内一次读到一次写之间必须用事务内的视图来决策"的原则。

    • 影响:与 H-1 同类,破坏 "至少一个 ADMIN" 不变式。

    • 修复方案

    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/ProductCodeCheckManageAccess 和事后清缓存。


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
  • 描述

    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 m2m1 ≠ 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 加共享锁)

    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
  • 描述

    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 的行锁没有任何关系。
    • CreateDeptinternal/logic/dept/createDeptLogic.go:46-52)在事务外 FindOne 读父部门,不加任何锁,随后 InsertWithTx 写子行。同样 CreateUserUpdateUser(改 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 已删的孤儿用户,loadDeptFindOne 会失败静默,DeptPath="",进而触发 CheckManageAccess → checkDeptHierarchy 的"您的部门信息异常"分支,用户相当于完全失去权限。

    • 影响

    • 组织树出现悬空父指针,DeptTree 展示异常;

    • 用户的 deptId 成孤儿,loadDept 静默失败,用户被持续推到"无权操作"分支;

    • 如果外部系统以 deptPath 鉴权,会出现"应该有权限但系统说没"或反之的权限紊乱。

    • 修复方案: 在 DeleteDept 事务内,对 parentId 索引deptId 索引 做"存在性锁定读":

    // 子部门:如果有任意一行,就失败;并在 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
  • 描述

    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
    • 逻辑层:LogoutLogicRefreshTokenLogic 都挂了 svcCtx.TokenOpLimiter,但 ChangePasswordLogic 没有;也没有 UsernameLoginLimit 类的每用户限流。
    • bcrypt.CompareHashAndPassword 大约 ~100ms,已登录用户可以串行每秒 ~10 次对自己当前密码做爆破。
    • 威胁模型:
    • 攻击者偷到 access token(XSS/设备共用),想"把密码改成自己的"以实现持久化。由于必须知道旧密码,于是他用盗来的 token 不停调 changePassword 枚举旧密码。虽然 bcrypt 慢,但没有限流意味着可以一直尝试——被害人直到 access token 过期(默认 AccessExpire,通常 2h~1d)都无法自救。
    • 更现实:同一台办公机、同一设备同一账号,未离开时间段内 malicious script 执行百万次尝试;每次失败 ErrBadRequest("原密码错误"),既不记录日志也不递增 tokenVersion,失败完全"无痕"。
    • 对比:LogoutLogic 做了 10/60s 限流,RefreshTokenLogic 也做了;ChangePasswordLogic 显然遗漏。

    • 影响

    • 提供了"持有短期 access token 后可以 brute-force 当前密码,命中后改密持久化"的攻击路径;

    • 一旦命中,新密码会触发 tokenVersion+1UpdatePassword 里),踢掉真正的用户,攻击者用新密码重新登录即可接管(管理后台也同理,因为它用同一套 bcrypt)。

    • 修复方案

    // 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-52internal/model/user/sysUserModel.go:137-150
  • 描述UpdateUserStatus 没有对比 user.Statusreq.Status,不管是否真的变更,都调用 SysUserModel.UpdateStatus;模型层 SQL 为 UPDATE … SET status=?, tokenVersion=tokenVersion+1 … 无条件递增。结果:管理员点一次"启用"按钮(用户原本就是启用),所有该用户在场的会话全部下线。
    • 对比 UpdateUserLogicupdateUserLogic.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
  • 描述

    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) 生成 appSecret32 字节熵,不是 64;
    • generateRandomHex(32) 生成 appKey16 字节 / 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
  • 描述loadFromDBud.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 → CleanUpdateUser → CleanUpdateRole → 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-75internal/logic/role/bindRolePermsLogic.go:127-128
  • 描述

    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:69SysRoleModel.Update(ctx, role)
    • internal/logic/product/updateProductLogic.go:58SysProductModel.Update(...)
    • internal/logic/member/updateMemberLogic.go:75SysProductMemberModel.UpdateWithTx(...)
    • 描述UpdateUserLogicUpdateDeptLogic 用了 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.Loginpeer.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
  • 描述

    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 已被拦截)。防御纵深建议双保险。
  • 修复

    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("")==MaxInt32if 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,UpdateMemberRemoveMember 都调用它;内部先 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 对应条目。