audit-report.md 34 KB

权限管理系统 - 深度代码审计报告(第 5 轮)

审计范围:同第 4 轮,/internal 下全部非测试、非 _gen.go 生产代码。 审计时间:2026-04-19(复盘第 4 轮修复后再度深入) 审计重点:

  • 令牌刷新链路的原子性与重放窗口(HTTP + gRPC 两套入口并行审视)
  • 垂直/水平越权的"等级相等放行"死角
  • 乐观锁以秒级 updateTime 为版本号在真实同秒并发下的丢失更新
  • 软删除用户 / 已删除用户的 JWT 仍有效期内触发的 DB 重复压栈(DoS 向量)
  • 缓存失效链路中残留的"N 次串行 Redis 往返"
  • 上轮部分修复不彻底的遗留项(strings.Contains(errMsg, "uk_code")

相对第 4 轮:第 4 轮 H-1/H-2/H-3(最后一个 ADMIN)、H-5(changePassword 限流)、H-4(DeleteDept 存在性锁读)已实际落地修复(本轮代码阅读已确认)。本轮新暴露一组围绕刷新令牌重放等级相等分配的高危问题,以及若干性能 / 健壮性缺口。


🚩 核心逻辑漏洞 (High Risk)

H-1. RefreshToken 先校验 tokenVersion 再递增,并发刷新可被第三方"接管会话"(HTTP + gRPC 双入口)

  • 位置
    • internal/logic/pub/refreshTokenLogic.go:64-79
    • internal/server/permserver.go(同逻辑的 gRPC RefreshToken 实现)
  • 描述: 核心代码片段(HTTP 版本):

    if claims.TokenVersion != ud.TokenVersion {
      return nil, response.ErrUnauthorized("登录状态已失效,请重新登录")
    }
    if l.svcCtx.TokenOpLimiter != nil {
      code, _ := l.svcCtx.TokenOpLimiter.Take(fmt.Sprintf("refresh:%d", claims.UserId))
      ...
    }
    newVersion, err := l.svcCtx.SysUserModel.IncrementTokenVersion(l.ctx, claims.UserId)
    

    "check 版本号 → 递增版本号"是两条独立的 SQL,中间没有行级锁、也没有条件更新语义。DB 里的 IncrementTokenVersion 实际是无条件 UPDATE ... SET tokenVersion = tokenVersion+1

    攻击 / 真实泄露场景

    1. 攻击者通过设备失窃 / 前端 XSS / localStorage 泄露,拿到受害者一枚仍在有效期内的 refreshToken(claims.TokenVersion = V)。单会话轮转的前提:一旦合法用户刷新过一次,旧 token 就作废。这是标准 OAuth2 refresh token rotation 的基线安全属性。
    2. 受害者在某时刻 T 发起合法刷新;同一秒或几毫秒内攻击者也用窃到的 token 发起刷新。
    3. 两个请求分别读到 ud.TokenVersion = V,双方都通过 claims.TokenVersion == ud.TokenVersion 这一步 → 都进入 IncrementTokenVersion
    4. DB tokenVersion 经两次递增变成 V+2
    5. "最后完成的请求"会得到 newVersion = V+2,并以此签发新 accessToken/refreshToken。"先完成的请求"拿到的 newVersion = V+1,用户端使用它发起后续业务请求时 Middleware 判定 V+1 != V+2 → 登录失效,直接被踢。
    6. 结果:合法用户被登出;攻击者持有 V+2 的 accessToken 和 refreshToken,静默拿走后续完整会话(并且在攻击者拿到的 refreshToken 自然过期前,可以一直续期、一直维持会话)。

    换句话说,refresh token rotation 的"旧 token 必须在发新 token 的同一瞬间失效"这一原子性前提被打破。在攻击者 + 合法用户并发一次的窗口下,会话被直接接管。

    • 影响
    • 这不是普通的时序抖动,是会话劫持 / 账号被盗级别的 P0 问题。
    • gRPC 版本因为根本没有限流(见 H-2),攻击者可以程序化拉高并发,几乎必然落到 race 窗口里。
    • 更恶劣的是:受害者前端只会看到"登录状态已失效"一次,下次重新登录即可,几乎没有任何异常信号可以让风控察觉。

    • 修复方案: 把 check + increment 合并成单条带版本条件的原子更新

      // 新增 model 方法
      func (m *customSysUserModel) IncrementTokenVersionIfMatch(ctx context.Context, id, expected int64) (int64, error) {
      var newVersion int64
      err := m.TransactCtx(ctx, func(ctx context.Context, session sqlx.Session) error {
        q := fmt.Sprintf("UPDATE %s SET `tokenVersion`=LAST_INSERT_ID(`tokenVersion`+1), `updateTime`=? WHERE `id`=? AND `tokenVersion`=?", m.table)
        res, err := session.ExecCtx(ctx, q, time.Now().Unix(), id, expected)
        if err != nil { return err }
        affected, _ := res.RowsAffected()
        if affected == 0 { return ErrTokenVersionMismatch }
        return session.QueryRowCtx(ctx, &newVersion, "SELECT LAST_INSERT_ID()")
      })
      ...
      }
      

logic 层改为:

  newVersion, err := l.svcCtx.SysUserModel.IncrementTokenVersionIfMatch(l.ctx, claims.UserId, claims.TokenVersion)
  if errors.Is(err, userModel.ErrTokenVersionMismatch) {
      return nil, response.ErrUnauthorized("登录状态已失效,请重新登录")
  }

这样两个并发请求只有一个能 WHERE tokenVersion = V 命中(affected=1),另一个 affected=0,明确失败并返回 401。HTTP 与 gRPC 两个入口必须共享这一条原子更新逻辑,不能只修一边。


H-2. gRPC RefreshToken / VerifyToken 完全没有任何限流

  • 位置internal/server/permserver.go(gRPC RefreshTokenVerifyToken RPC 方法)
  • 描述: HTTP 端的 /api/auth/refreshToken 至少在解析成功 claims 之后调了 TokenOpLimiter.Take("refresh:%d"),做了一层按用户的令牌桶限流。但 gRPC 服务同名 RPC 完全没有做这件事:
    • 没有 gRPC interceptor 级别的 IP 限流;
    • 没有 per-user 限流;
    • 也没有 per-refresh-secret 全局限流。

业务语义上 gRPC 是内网其他服务向权限中心"换 accessToken / 验证 token"的主链路,本就应该是最需要限流的地方(被错误部署 / 服务腔体被打穿时,可以直接把 DB 打爆)。

  • 影响

    • 与 H-1 组合时:攻击者可在 gRPC 通道上对 RefreshToken 发起任意并发,几乎必然命中 H-1 的 race 窗口,把会话劫持概率从"需要运气"拉到"只要有网络带宽"。
    • 即便没有 H-1:攻击者用一枚尚未过期的 refreshToken 可以无限换取 accessToken,作为持续化 RCE 工具链的身份通道;也可以对有效 token 进行大量 signature verification,把权限中心 CPU 打满。
    • VerifyToken 无限流:任何下游被攻破后,可以对权限中心做 token-oracle 爆破。
  • 修复方案

    1. 强制添加 gRPC unary interceptor,做 IP 粒度的 PeriodLimit(peer.FromContext 取对端 IP,失败时按 H-4 的做法 fail-close):

      func GrpcRateLimitInterceptor(limiter *limit.PeriodLimit, quota int, window int) grpc.UnaryServerInterceptor {
       return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
           ip, err := extractClientIP(ctx)
           if err != nil {
               return nil, status.Error(codes.Unavailable, "peer not identifiable")
           }
           code, _ := limiter.Take(fmt.Sprintf("grpc:%s:%s", info.FullMethod, ip))
           if code == limit.OverQuota {
               return nil, status.Error(codes.ResourceExhausted, "rate limited")
           }
           return handler(ctx, req, info)
       }
      }
      
      1. RefreshToken / VerifyToken 逻辑内部也加一层 per-user 的 TokenOpLimiter.Take(fmt.Sprintf("grpc-refresh:%d", claims.UserId)),作为 claims 解析成功后的第二道闸。
      2. 把 refresh / verify 的失败结果(claims.TokenVersion != ud.TokenVersionjwt.ParseWithClaims err)接入 IP-level 失败计数器,连续失败 N 次直接封禁一段时间,避免爆破 refresh secret。

      H-3. BindRoles 允许平级自增:MEMBER 可给其他用户绑定与自己最小等级相等的角色,从而让目标等同自己 → 后续再也管不动

      • 位置internal/logic/user/bindRolesLogic.go:86-91
      • 描述go if !authHelper.HasFullProductPerms(caller) { if caller.MinPermsLevel == math.MaxInt64 || r.PermsLevel < caller.MinPermsLevel { return response.ErrForbidden("不能分配权限级别高于自身的角色") } }

权限模型约定:PermsLevel 数字越小表示权限越高。这里的判定是 r.PermsLevel < caller.MinPermsLevel,即 严格小于 才拒绝。结果:调用者 MinPermsLevel = 5 时允许分配 PermsLevel = 5 的角色。

CheckManageAccesscheckPermLevel 的判定是:

  if caller.MinPermsLevel >= targetLevel {
      return response.ErrForbidden("无权管理权限级别高于或等于您的用户")
  }

即目标用户的最小 PermsLevel 必须严格大于调用者(即目标权限严格低于调用者)。两边判定口径不一致:

  • MEMBER A(MinPermsLevel=5)调用 /api/user/bindRoles 给属于自己部门子树的 MEMBER B(MinPermsLevel=6)追加一个 PermsLevel=5 的角色 → 通过。
  • 此后 B 的 MinPermsLevel=5,与 A 平级;A 再想 UpdateUser/BindRoles/SetUserPerms/UpdateUserStatus 管理 B,都会在 checkPermLevel 里被 5 >= 5 拦住 → 管不了了。
  • 也就是说 A 合法地把下属 B 提到自己平级,然后永久失去对 B 的后续管控权。如果此时 A 自己被冻结 / 账号被挤下线,B 也不能被原 A 的同级 MEMBER 回收——要么超管 / 产品 ADMIN 出手,要么永久残留。
  • 同样的 gap 在 CheckMemberTypeAssignment>= 拦截)与此处(< 拦截)之间是不一致的:前者严格高一级才能分配 memberType,这里却允许"同级角色"。

  • 影响

    • 垂直权限泄露:即便没有恶意,普通配置错误就能制造出"产品里多个同级 owner,互相管不了对方"的死结,业务上得靠人肉联系平台超管收拾残局。
    • 有恶意的 MEMBER 可以主动把攻击者账号拉到自己平级,从此攻击者的权限只能由超管吊销 → 实际上完成了一次越权提升,攻击者绕过了"MEMBER 只能管下级"的心智模型。
    • UpdateRoleLogic 的"非超管不能降低 PermsLevel"结合:A 把 B 拉到平级后,如果再找一个 ADMIN 去调整角色 PermsLevel,整个产品里的等级模型会越发混乱。
  • 修复方案: 把 < 改为 <=,与 checkPermLevel>= 对称:

    if caller.MinPermsLevel == math.MaxInt64 || r.PermsLevel <= caller.MinPermsLevel {
      return response.ErrForbidden("不能分配权限级别高于或等于您自身的角色")
    }
    

    同时:

    1. BindRoles / SetUserPerms 所在文件里抽一个 guardRoleLevelAssignable(caller, role) helper,强约束"只能分配严格更低等级的角色"——与 checkPermLevel(严格更低等级才能管)对齐,后续任何新增绑定场景不会再走错。
    2. 补一条单测:模拟 MEMBER (level=5) 给 MEMBER 绑 level=5 的角色,必须 403。

    H-4. UpdateUserLogicDeptId = 0 分支跳过部门管辖校验:下属可以被合法"移出部门"、失去上级管辖

    • 位置internal/logic/user/updateUserLogic.go:106-120
    • 描述go if req.DeptId != nil { if *req.DeptId > 0 { newDept, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, *req.DeptId) ... if !caller.IsSuperAdmin && caller.MemberType != consts.MemberTypeAdmin && caller.DeptPath != "" && !strings.HasPrefix(newDept.Path, caller.DeptPath) { return response.ErrForbidden("无权将用户调入非自己管辖的部门") } } deptId = *req.DeptId }

新部门层级校验放在 *req.DeptId > 0 分支内。如果调用者传 deptId: 0,直接 deptId = 0跳过整个层级校验。由 access.go:146-150 可知,target.DeptId == 0 会被 checkDeptHierarchy 视为"仅超管或产品 ADMIN 可管"。

真实业务路径:

  1. MEMBER A(部门 /1/2/,通过 CheckManageAccess 管辖部门 /1/2/3/ 下的 MEMBER B)。
  2. A 调用 POST /api/user/update { id: B.id, deptId: 0 }。第一步校验:A 对 B 有管辖权限(通过,B 确在 A 子树)。后续:req.DeptId != nil && *req.DeptId == 0 → 跳过新部门 Path 校验 → deptId = 0
  3. DB 里 B 的 deptId 被抹成 0。此后 B 的 DeptPath 为空,checkDeptHierarchy 对任何非 ADMIN 都返回"目标用户未归属部门,仅超管或产品管理员可管"。
  4. A 自己也管不了 B 了:因为 B 已经脱离部门体系。等价于 A 把 B "踢出部门树",使后续任何 MEMBER-级别的同级调整都失效,只有超管/产品 ADMIN 能动。
  5. 结合 H-3 的等级平级攻击:A 先把 B 拉到同级,再把 B 踢出部门 → B 变成一个在组织结构里"无归属、无人能管"的幽灵高权账号。
  • 影响

    • 这不是简单的一致性问题,是 MEMBER 可以合法破坏组织架构语义 + 失去组织可管控性:账号进入"灰色托孤态",只有平台超管能打捞。
    • 从合规角度:任何有"部门树 = 数据隔离边界"的业务(如多租户),本接口可以越过部门边界丢弃账号的隶属关系
    • 没有 audit log 配合的情况下,此动作甚至不会立即被察觉。
  • 修复方案: 两种口径任选其一(建议第 2 种,更严格):

    1. 禁止非 ADMIN 把 deptId 改为 0(把原"只在 >0 时校验"变成"不等于当前 deptId 时都要校验"):

      if req.DeptId != nil && *req.DeptId != user.DeptId {
       if *req.DeptId == 0 {
           if !caller.IsSuperAdmin && caller.MemberType != consts.MemberTypeAdmin {
               return response.ErrForbidden("仅超级管理员或产品管理员可移除用户的部门归属")
           }
       } else {
           newDept, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, *req.DeptId)
           if err != nil { return response.ErrBadRequest("部门不存在") }
           if !caller.IsSuperAdmin && caller.MemberType != consts.MemberTypeAdmin &&
               caller.DeptPath != "" &&
               !strings.HasPrefix(newDept.Path, caller.DeptPath) {
               return response.ErrForbidden("无权将用户调入非自己管辖的部门")
           }
       }
       deptId = *req.DeptId
      }
      
      1. 如果产品规范里本就不支持"用户无部门",应直接把 0 当作非法值:if *req.DeptId <= 0 { return ErrBadRequest("部门ID无效") }

      ⚠️ 健壮性与性能建议 (Medium/Low)

      M-1. RefreshTokenLogic 先解析 JWT,再限流:无效 token 穿透限流,可用于爆破 refreshSecret

      • 位置internal/logic/pub/refreshTokenLogic.go:40-73
      • 描述TokenOpLimiter.Take("refresh:%d") 的 key 需要 claims.UserId,所以必须ParseRefreshToken 成功才能限流。对一批攻击用的无效 token(例如在爆破 refresh secret 时构造的伪造签名),全部会走到: go claims, err := authHelper.ParseRefreshToken(tokenStr, l.svcCtx.Config.Auth.RefreshSecret) if err != nil { return nil, response.ErrUnauthorized("refreshToken无效或已过期") }

不进入限流桶。JWT 验签是较重的 HMAC/RS 运算,攻击者可以借此做 CPU-放大 DoS,或持续爆破 refresh secret(离线爆破失败,就改在线爆破,反正被限流也不限流)。

  • 建议:在 Authorization 头解析(TrimPrefix)成功后,以 IP 为 key加一道最外层 PeriodLimit,如 limiter.Take(fmt.Sprintf("refresh-ip:%s", clientIP))。路由层已经可以通过 RefreshTokenRateLimit 中间件配合来做,但这个中间件目前没挂载在 /api/auth/refreshToken(路由定义里只有 LoginRateLimit 等),建议补上。

M-2. UpdateDeptLogic 对部门成员的 UserDetailsLoader.Clean 在 for 循环里串行同步调用

  • 位置internal/logic/dept/updateDeptLogic.go(循环清缓存处)
  • 描述:在 deptType / status 变更时,代码形如:

    userIds, _ := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id)
    for _, uid := range userIds {
      l.svcCtx.UserDetailsLoader.Clean(l.ctx, uid)
    }
    

    单次 Clean 内部包含 SMEMBERS + DEL(批) + DEL(索引键) + SREM(见 userDetailsLoader.go:185-199, 221-230),至少 4 次 Redis 往返。一个 500 人的部门 = 2000 次 Redis 同步 RTT 卡在请求线程里。这会直接阻塞 HTTP handler(默认 go-zero timeout 可能都兜不住)。

    • 建议
    • loader 层加 CleanMany(ctx, userIds):一次 SMEMBERS 取每个索引键,DEL 合并所有 cacheKey(Redis 支持任意多 key 的批量 DEL),索引键用一次 DEL 或 pipeline 批处理。
    • 或用 BatchDel(userIds, productCode)(已经在 loader 里)——不过 BatchDel 只清特定产品,对"部门跨多个产品"的场景要多次调用。更清爽的做法是按索引键一次性清: go func (l *UserDetailsLoader) CleanByUserIds(ctx context.Context, ids []int64) { idxKeys := make([]string, len(ids)) for i, id := range ids { idxKeys[i] = l.userIndexKey(id) } // pipelined SMEMBERS + DEL }
    1. 当部门用户数量超过阈值(如 200)时,fire-and-forget 到后台 goroutine + 日志,不要阻塞外部请求。

M-3. UserDetailsLoader.Load已删除用户不做负缓存:一把有效 JWT + 删除账号 = 每次请求 7 条 DB 查询

  • 位置internal/loaders/userDetailsLoader.go:119-144
  • 描述

    v, sfErr, _ := l.sf.Do(key, func() (interface{}, error) {
      ud, err := l.loadFromDB(ctx, userId, productCode)
      ...
      if ud.Username == "" {
          return nil, nil
      }
      ...
    })
    ...
    if !ok || ud == nil {
      return &UserDetails{UserId: userId, ProductCode: productCode}
    }
    

    当用户已被硬删除(FindOne 返回 ErrNotFound),loadUserud.Username 留空,外层判定"空,不缓存",返回一个空壳 UserDetails。Middleware 再根据 ud.Username == "" 返回 401。

    但"不缓存"这件事意味着:只要攻击者 / 离线员工仍持有未过期的 accessToken(默认 1~2 小时),每个请求都会触发 loadFromDB → 至少 1~7 次 DB SELECT(loadUser / loadDept / loadProduct / loadMember / loadRoles / loadRolePerms / loadUserPerms)。 虽然 sf.Do 会合并同时到达的相同 key,但在真实场景下"攻击者手里只有一张 token,每 100ms 打一次"时,每次请求都是串行命中。

    一个具体的 DoS 放大场景:

    • 公司大规模离职,某几个离职员工的 accessToken 还在剩余寿命里(甚至能用 refreshToken 续期——虽然 claims.TokenVersion != ud.TokenVersion 会拦住,但 token 失效前的 accessToken 里 tokenVersion 对照"空 ud.TokenVersion=0"反而可能通过 claims.TokenVersion != 0 拦截)。
    • 他们留下的 pipeline 任务 / 前端 polling 以 HTTP 规律重试,相当于针对权限中心 DB 发起常驻压测,且无任何限流(token 能通过 JWT 签名校验,就不会进入 IP 限流的 login 桶)。

    • 建议

    • 对"Username 为空"的路径也写入短 TTL(30~60s)的负缓存 sentinel

      if ud.Username == "" {
       _ = l.rds.SetexCtx(ctx, key, `{"userId":0}`, 30) // 或带一个 "deleted":true 字段
       return nil, nil
      }
      

      加载侧读到 sentinel 立刻返回空 UserDetails,不再走 DB。

    1. 更彻底:引入基于 userId 的全局"已删除"布隆过滤器,在 DeleteUser 时添加;Load 读到命中时直接 short-circuit。
    2. jwtauthMiddleware 对"用户被删除"的路径打印警告日志 + 对应 token 的 userId 加入短期封禁列表(5~10 分钟),避免垃圾 token 长期耗资源。

M-4. UpdateWithOptLock / UpdateProfile / UpdatePassword 的乐观锁以秒级 updateTime 为版本:同秒并发写会丢更新

  • 位置
    • internal/model/user/sysUserModel.go:99-120(UpdateProfile,WHERE id=? AND updateTime=?
    • internal/model/dept/sysDeptModel.go:UpdateWithOptLock
    • internal/model/product/sysProductModel.go:UpdateWithOptLock
    • internal/model/role/sysRoleModel.go:UpdateWithOptLock
  • 描述: 乐观锁的 WHERE updateTime = ? 约束依赖"版本必然变化"。当前实现把 updateTime 写为 time.Now().Unix()(秒级)。同一秒内的两笔并发 UPDATE:
    • Op1 读到 updateTime = T,计算 new.updateTime = T(同秒)。
    • Op2 读到 updateTime = T,计算 new.updateTime = T
    • Op1 先 commit:UPDATE ... SET ...,updateTime=T WHERE id=? AND updateTime=T → 匹配 1 行(MySQL 默认 rows_affected 是"matched rows";如果配置了 CLIENT_FOUND_ROWS 是更明显的匹配),新 updateTime 仍是 T(没变)。
    • Op2 再 commit:同样 WHERE updateTime=T 命中,Op1 的变更被 Op2 覆盖;业务静默丢失

UpdatePassword / UpdateStatus 走的是无乐观锁UPDATE ... WHERE id=?,同秒并发也一样会后写覆盖前写,只不过对"设密码"而言是同一人改两次的幂等场景,影响小;但 UpdateProfile 这种带业务字段的操作,同秒后提交的请求会把前一个合法更改吞掉,且 RowsAffected=1,外层误以为成功。

同秒并发在本项目不算罕见:

  • 管理后台前端批量操作,一次提交里同时改部门下 20 个用户;
  • gRPC 下游 API 网关重放;
  • 运维脚本并行刷数据。

  • 建议

    1. 引入独立的 version 整型列,每次 SET version=version+1 WHERE id=? AND version=?,彻底脱离时间戳。
    2. 过渡方案:在 UpdateXxxWithOptLock 里用 time.Now().UnixNano() 代替 Unix(),并把 updateTime 列类型放宽到 BIGINT(已经是 bigint);同秒间碰撞概率从 1 降到纳秒级,基本规避。
    3. 或使用 UPDATE ... WHERE id=? AND updateTime=? AND ... (其他校验),让 affected=0 时抛 ErrUpdateConflict 触发客户端重试(前提是 updateTime 每次真会变,上面两种方案任选其一先修)。

M-5. CreateProductLogic 仍残留 strings.Contains(errMsg, "uk_code")strings.Contains(errMsg, req.Code) 二级判定

  • 位置internal/logic/product/createProductLogic.go:135-146
  • 描述

    if util.IsDuplicateEntryErr(err) {
      errMsg := err.Error()
      if strings.Contains(errMsg, "uk_code") || strings.Contains(errMsg, req.Code) { ... }
      if strings.Contains(errMsg, "uk_username") || strings.Contains(errMsg, adminUsername) { ... }
      return nil, response.ErrConflict("数据冲突,请稍后重试")
    }
    

    第一步正确使用 IsDuplicateEntryErr,但第二步"到底是哪张唯一键冲突"又退回 strings.Contains(errMsg, ...)。 问题:

    • strings.Contains(errMsg, req.Code) 这一回退 fallback 极其脆弱:DB 错误消息未必内嵌值,也可能有类似子串误命中;
    • MySQL 5.7/8.0 在错误消息里展示键名时带 prefix Duplicate entry 'xxx' for key 'sys_product.uk_code',把 table 前缀去掉后才是 uk_code;不同版本字符串不同;
    • 万一后续 DDL 把索引 rename,消息匹配直接失效,代码编译仍通过——静默退化到 "数据冲突,请稍后重试",前端无法区分是产品 code 冲突还是用户名冲突,UX 降级。
    • 建议: 改用预校验 + 插入并发兜底模式:
    • 在 tx 开始前(已经有这段)用 FindOneByCode / FindOneByUsername 做存在性校验,命中直接返回明确错误消息。
    • 并发插入时命中的重复键错误,类型断言 *mysql.MySQLError 后直接返回通用 ErrConflict("数据冲突,请重试")不要再用字符串匹配区分键。这等价于:
      • 并发场景极少发生(单秒同时建同 code 的产品几乎不会有);
      • 一旦发生,用户看到"冲突请重试"是可接受的;
      • 非并发场景早被步骤 1 的精确文案拦住了。

    M-6. SyncPermsService.ExecuteSyncPerms读快照在 tx 外、写在 tx 内,并发同步会撞 DuplicateEntry

    • 位置internal/logic/pub/syncPermsService.go
    • 描述:代码流程大致是: ```

existing := FindMapByProductCode(productCode) // tx 外,普通 SELECT TransactCtx(func(session) {

  for code, perm := range req.Perms {
      if _, ok := existing[code]; !ok {
          InsertWithTx(...)
      } else {
          UpdateWithTx(...)
      }
  }
  ... disable 不在请求列表里的旧权限 ...

})

  两次并发 `SyncPermissions`:
  - 都读到 `existing` 中没有 code X;
  - 都 `InsertWithTx(..., code=X)`;
  - 第二次 tx 在 `UNIQUE (productCode, code)` 上撞 1062 → rollback。

  当前错误处理把 1062 直接 `return err`,外层包装成 500,对客户端来说看起来"同步随机失败",但其实是两个合法同步的竞态。
- **建议**:
  1. 把 `FindMapByProductCode` **挪进 tx 内**,并在前面做一次 `SELECT id FROM sys_product WHERE code=? FOR UPDATE` 锁住 product 行(或 `GET_LOCK('sync:'+productCode, 5)`),相当于把同步串行化到每个 product。同步是低频操作,串行化是能接受的。
  2. `InsertWithTx` 改用 `INSERT ... ON DUPLICATE KEY UPDATE`:把插入 / 更新合并成一个语义,天然幂等。
  3. `IsDuplicateEntryErr` 的路径显式返回 `response.ErrConflict("权限同步存在并发冲突,请重试")`,前端据此做重试,不要让客户端无脑 500。

---

### M-7. gRPC `PermServer.Login` 的**对端 IP 提取 fail-open**

- **位置**:`internal/server/permserver.go`(`Login` 方法里对 `peer.FromContext` 和 `net.SplitHostPort` 的错误处理分支,clientIP 回退为 `"unknown"` 或原始 `p.Addr.String()`)
- **描述**:
  - `clientIP = "unknown"` 意味着**所有**这种失败 case 共享同一个限流桶,正常请求被 pollute 后限流可能集体打满;
  - 如果回退成 `p.Addr.String()` 又包含端口号,每个连接端口都是一个新 key,限流完全等价于无限流(端口随每个连接变化)。
- **建议**:
  - 不能确定 IP 时**直接拒绝**:`return nil, status.Error(codes.Unavailable, "peer not identifiable")`。
  - 这个规则同样要套在 gRPC `SyncPermissions` / `RefreshToken` / `VerifyToken` / `GetUserPerms` 的新限流中间件里(H-2 的修复路径)。

---

### M-8. `ChangePasswordLogic` / `UpdatePassword`:**无乐观锁 + 并发修改密码双方都成功**

- **位置**:
  - `internal/logic/auth/changePasswordLogic.go`
  - `internal/model/user/sysUserModel.go:122-135`(UpdatePassword)
- **描述**:两笔同时发起的 ChangePassword 请求(例如用户双窗口操作 + 自动化脚本),都能通过 "旧密码比对",都走 `UpdatePassword`,**后写覆盖前写**。`tokenVersion` 被加 2 次,最终 hash 来自后写者。
  这里不涉及 security escalation,但:
  - 如果用户看到第一次"修改成功",过了 1 秒再用新密码登录却失败(因为后写者用的是"旧密码 + 新密码计算出的 hash",最终 hash 属于第二个请求的新密码),用户会以为被盗号。
  - 两个 `IncrementTokenVersion` 把后续所有合法会话踢掉两次,UX 破损。
- **建议**:在 `UpdatePassword` 里用 `WHERE id=? AND tokenVersion=?`(expected = 改密前读到的 tokenVersion),冲突时走 `ErrUpdateConflict` 返回前端"请刷新后重试"。

---

### L-1. `CreateUserLogic` 默认 `mustChangePassword = consts.MustChangePasswordNo`

- **位置**:`internal/logic/user/createUserLogic.go`
- **描述**:管理员为新用户创建账号时,密码是管理员"代填"的,业务上通常应**强制首登改密**。当前默认值是 `No`,意味着管理员口头告诉员工密码后,员工可以长期不改;如果管理员密码库泄露,所有新建账号都通用。
- **建议**:把默认改为 `consts.MustChangePasswordYes`;或请求体加 `mustChangePassword *int` 字段,不传时按 `Yes` 处理。

---

### L-2. `RefreshToken` 限流 key 仅含 `userId`,**不含 IP**

- **位置**:`internal/logic/pub/refreshTokenLogic.go:69`
- **描述**:`TokenOpLimiter.Take(fmt.Sprintf("refresh:%d", claims.UserId))`。若攻击者从多个 IP 同时使用同一 refreshToken(与 H-1 的 race 攻击配套),限流桶共享,单桶配额耗尽攻击即止;但同一合法用户**自己**在多个 IP 刷新(手机 + 电脑)时也会互相挤占配额。
- **建议**:把 IP 加入 key(`refresh:%d:%s`,IP),同时保留 per-user 总量上限(`refresh-u:%d`)。

---

### L-3. `CreateDeptLogic` 的 `InsertWithTx → FindOneWithTx → UpdateWithTx` 组合:**缓存早于事务提交失效**

- **位置**:`internal/logic/dept/createDeptLogic.go:73-95`
- **描述**:`UpdateWithTx` 内部调用 `m.ExecCtx`,go-zero 会**在 exec 成功后立即 `DelCacheCtx`**,此时事务尚未 commit。假设另一 goroutine 在 exec 完成与 commit 之间以 deptId 调 `FindOne`,会:
  - cache miss → DB 查询 → 事务未提交对其他 session 不可见 → 返回 `ErrNotFound` → go-zero cached conn 把"未找到"的 sentinel 写进 cache(典型 TTL ~1 秒)。
  - 事务 commit 之后,真正的行已存在,但 cache 里是"未找到" sentinel,后续请求命中错误缓存。
  现象:创建部门后的前 1 秒,其他查询可能偶发"部门不存在"。生产罕见,属于一致性微抖动。
- **建议**:这是 go-zero cache 层面的已知模式,不是本项目独有。如需彻底消除,把"计算 Path + 第二次 UpdateWithTx"改成**在插入阶段就把 Path 算好**(trigger 或 app 层先 `LAST_INSERT_ID()`,但 MyISAM/InnoDB 在 `InsertWithTx` 里就能从 `result.LastInsertId()` 拿到),然后 `UPDATE` 一次就好——其实现在的代码也是一次 UPDATE,只是 `FindOneWithTx` 是多余的(拿完整 SysDept 只为回写 Path,完全可以直接构造 UPDATE 语句)。简化为:
  ```go
  result, err := InsertWithTx(...)
  deptId = result.LastInsertId()
  path := fmt.Sprintf("%s%d/", parentPath, deptId)
  _, err = session.ExecCtx(ctx, "UPDATE sys_dept SET `path`=?, `updateTime`=? WHERE `id`=?", path, now, deptId)

同时用 m.DelCacheCtx(cacheSysDeptIdPrefix+deptId) 显式在 tx 外做一次二次清理。


L-4. CheckManageAccess / checkPermLeveltargetLevel = math.MaxInt64(查不到角色)当"目标无权限"处理

  • 位置internal/logic/auth/access.go:185-192
  • 描述:目标用户没有任何启用角色时 FindMinPermsLevelByUserIdAndProductCode 返回 err 或空 → 代码 fallback 为 targetLevel = math.MaxInt64。然后 caller.MinPermsLevel >= targetLevel(MaxInt64)永远为 false(除非 caller 也是 MaxInt64),于是 caller "严格高于" target → 允许管辖。 语义上这是合理的(无角色用户的等级最低),但一旦 FindMinPermsLevelByUserIdAndProductCode 因为 DB 抖动真正返回 err,会被同化成"目标无角色",给出错误放行。
  • 建议:把"查不到角色 = MaxInt64"与"DB 抖动 = err"的路径分开:err != nil && !errors.Is(err, sqlx.ErrNotFound) 时直接返回错误而不是降级放行。

L-5. CreateProductLogic.generateRandomHex 的 tx 外密钥生成:事务失败时密钥被泄露到日志 / 响应

  • 位置internal/logic/product/createProductLogic.go
  • 描述appKey / appSecret / adminPassword 在 tx 前生成;tx 失败时函数直接 return nil, err,响应体里不会带走这些值(OK),但:
    • logx.Errorf("internal error: %+v", err) 有可能在 stack 之外打印 req;
    • 如果调用方带 X-Request-Id 等 trace header,这次失败生成的 appSecret 不会重试时复用,等于每次重试都把一串密钥丢到熵池里直到 tx 成功。不是安全问题但无意义。
  • 建议:把密钥生成挪进 tx 内部(或 tx 成功 commit 后再最后一步),避免失败态的"幽灵密钥";响应返回的明文 AppSecret / AdminPassword 建议换成一次性下载 URL 或要求创建者当场抄写,别持久化在响应体。

结论与修复优先级

优先级 finding 概要
P0 H-1 RefreshToken TOCTOU 导致会话劫持(HTTP + gRPC)
P0 H-2 gRPC RefreshToken / VerifyToken 零限流
P0 H-3 BindRoles 平级放行破坏管理层级
P0 H-4 UpdateUser deptId=0 绕过部门管辖
P1 M-4 秒级 updateTime 乐观锁丢更新
P1 M-3 UserDetailsLoader 无负缓存 → DoS
P1 M-2 UpdateDept 缓存失效串行 Redis
P1 M-6 SyncPermissions 并发 1062
P1 M-1 refreshToken 先解析再限流
P2 M-5 / M-7 / M-8 错误分类脆弱 / IP 提取 / ChangePassword 并发
P3 L-1 ~ L-5 默认值 / 限流 key / cache 抖动 / err 降级 / 密钥生命周期

建议的修复次序

  1. 先修 H-1 + H-2 一起修IncrementTokenVersionIfMatch + gRPC 限流中间件,一次封住会话劫持通道。这两条必须原子上线,否则单修一边(例如只修 HTTP)攻击者改用 gRPC 就绕过。
  2. 修 H-3 / H-4:两条都是一行到十行级别的条件修正,配套写单测"MEMBER 给下属绑同级角色必须 403"、"MEMBER 把下属 deptId 改 0 必须 403"。
  3. 修 M-4 乐观锁:先用纳秒级 updateTime 做过渡,同时评估加 version 列的 DDL 变更计划。
  4. 修 M-3 负缓存 + M-2 缓存失效批处理:两者都是影响线上稳定性的中等问题,配合可观测(UserDetailsLoader 每次 loadFromDB 打一个 metric)。
  5. 修 M-6 SyncPermissionsFindMapByProductCode 进 tx,把插入改 ON DUPLICATE KEY UPDATE;错误回复改 ErrConflict
  6. 收尾:M-1 / M-5 / M-7 / M-8 / L-*。

说明:第 5 轮审计不再重列已在第 4 轮完成修复的 H-1/H-2/H-3(最后一个 ADMIN)、H-5(changePassword TokenOpLimiter)、H-4(DeleteDept 存在性锁读),也不重列已由测试覆盖的 TokenVersion 基本路径。以上 findings 在当前 HEAD 代码中复现无误,均有可触发的真实业务 / 攻击路径。