audit-report.md 17 KB

权限管理系统 - 深度代码审计报告

审计范围:/internal 下全部非测试生产代码(logic、model、middleware、handler、loaders、server、svc、config、consts、response、util)及入口文件 perm.go、gRPC 客户端 permclient/。 审计时间:2026-04-18


🚩 核心逻辑漏洞 (High Risk)


H-1. UpdateUser 存在 Read-Modify-Write 竞态,可静默撤销 tokenVersion 安全递增

  • 位置internal/logic/user/updateUserLogic.go 第 47-112 行
  • 描述UpdateUser 先通过 FindOne 读取完整用户行(包含 tokenVersion),在内存中修改字段后调用通用 Update() 回写全部字段Update() 对应的 SQL 形如 UPDATE sys_user SET ... tokenVersion=?, ... WHERE id=?,会将内存中的 tokenVersion 值覆盖到数据库。

然而 UpdatePasswordUpdateStatus 使用的是原子 SQL:

  UPDATE sys_user SET tokenVersion = tokenVersion + 1 WHERE id = ?

如果在 UpdateUserFindOneUpdate 之间,另一个请求执行了 UpdatePasswordUpdateStatus,原子递增的 tokenVersion 会被 UpdateUser 的陈旧值静默覆盖回去

  • 影响

    • 场景复现:用户 A 修改密码(tokenVersion 5→6,所有旧 session 应失效)。几乎同时,管理员修改该用户昵称(读到 tokenVersion=5,写回 tokenVersion=5)。结果数据库 tokenVersion 被还原为 5,用户旧 session(tokenVersion=5)仍然有效。
    • 密码修改后的安全失效机制被绕过,攻击者持有旧 token 仍可继续访问系统。
  • 修复方案UpdateUser 应改为部分字段更新,不触碰 tokenVersionpassword 等安全敏感字段。如果需要修改 status 并递增 tokenVersion,应使用与 UpdateStatus 相同的原子 SQL,或在事务中使用 SELECT ... FOR UPDATE 锁定行。

  // 方案一:拆分为部分更新 SQL
  func (m *customSysUserModel) UpdateProfile(ctx context.Context, id int64, fields map[string]interface{}) error {
      // 动态构建 SET 子句,仅更新传入字段,不覆盖 tokenVersion/password
  }

  // 方案二:如果更新 status 需要递增 tokenVersion,单独调用 UpdateStatus
  if req.Status != 0 && user.Status != req.Status {
      if err := l.svcCtx.SysUserModel.UpdateStatus(l.ctx, req.Id, req.Status); err != nil {
          return err
      }
  }

H-2. AdminLogin 缺少按用户名维度的频率限制,暴力破解风险高于产品端

  • 位置internal/logic/pub/adminLoginLogic.gointernal/handler/routes.go 第 174-188 行
  • 描述:产品端登录 ValidateProductLoginloginService.go 中使用了 UsernameLoginLimit(每用户名 300 秒 10 次),但管理后台登录 AdminLogin 只经过 IP 维度的 LoginRateLimit(每 IP 60 秒 20 次),没有按用户名限频。
  • 影响:攻击者通过代理池轮换 IP,可对超级管理员账号进行无限次暴力破解,而 IP 维度的限流完全无法防御此场景。考虑到管理后台登录还需要提供 managementKey,实际风险有所降低,但密钥和密码的双重暴力破解仍然可行。
  • 修复方案:在 AdminLogin 中复用或新增按用户名维度的频率限制器:
  func (l *AdminLoginLogic) AdminLogin(req *types.AdminLoginReq) (*types.LoginResp, error) {
      if l.svcCtx.UsernameLoginLimit != nil {
          code, _ := l.svcCtx.UsernameLoginLimit.Take(req.Username)
          if code == limit.OverQuota {
              return nil, response.ErrTooManyRequests("该账号登录尝试过于频繁,请5分钟后再试")
          }
      }
      // ... 原有逻辑
  }

H-3. RefreshToken 与 Login 共享同一速率限制桶,正常刷新可导致登录被锁

  • 位置internal/handler/routes.go 第 174-201 行
  • 描述refreshToken 路由与 loginadminLogin 共用 LoginRateLimit 中间件(基于 IP,60 秒 20 次)。RefreshToken 是 access token 过期后的常规续签操作,调用频率远高于登录。

  • 影响

    • 如果前端在 access token 过期时自动调用 refreshToken,一个 IP 下有多个用户时(如办公网络 NAT 出口),刷新请求很快耗尽配额,导致同 IP 下所有用户无法登录。
    • 反过来,攻击者可以通过大量发送 refreshToken 请求来消耗某个 IP 的登录配额,造成该 IP 下的所有用户无法登录(拒绝服务)。
  • 修复方案:为 refreshToken 使用独立的速率限制器,或直接移除其 IP 限流(因为 refreshToken 自身已有 JWT 签名验证和 tokenVersion 校验保护):

  // servicecontext.go 中新增
  refreshRlMiddleware := middleware.NewRateLimitMiddleware(
      rds, 60, 60, c.CacheRedis.KeyPrefix+":rl:refresh", c.BehindProxy,
  )

H-4. UpdateProduct 状态校验静默忽略无效值

  • 位置internal/logic/product/updateProductLogic.go 第 49-51 行
  • 描述

    if req.Status == consts.StatusEnabled || req.Status == consts.StatusDisabled {
      product.Status = req.Status
    }
    

    当传入 status=3status=-1 等无效值时,代码静默忽略,不更新也不报错。而 updateRoleLogicupdateUserLogicupdateDeptLogicupdateMemberLogic 对相同场景都会返回 400 错误。

    • 影响:接口行为不一致。调用方传入非法状态值后收到成功响应,误以为状态已修改,实际上并未生效。在前端管理界面可能导致状态显示与实际不符。

    • 修复方案:与其他更新接口保持一致,显式校验并报错:

    if req.Status != 0 {
      if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled {
          return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(禁用)")
      }
      product.Status = req.Status
    }
    

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


M-1. UpdateUserStatus 对同一用户产生 3 次数据库读取

  • 位置internal/logic/user/updateUserStatusLogic.go
  • 描述:请求处理链路中对同一 userId 执行了 3 次 FindOne
    1. 第 40 行 SysUserModel.FindOne —— 检查用户是否存在、是否超管
    2. 第 56 行 CheckManageAccesscheckDeptHierarchy 内部又调用 SysUserModel.FindOne(第 115 行 access.go
    3. 第 62 行 SysUserModel.UpdateStatus 内部再次调用 FindOne(用于构建缓存 key)

虽然 go-zero 的 model 层有 cache,第 2-3 次读取会命中缓存,但仍有序列化/反序列化开销和额外的网络往返(如果 Redis 部署在远端)。

  • 建议:将首次查询的结果传递到后续函数中复用,或让 UpdateStatus 接收 username 参数以跳过内部 FindOne。

M-2. gRPC 客户端使用已弃用的 grpc.WithInsecure()

  • 位置permclient/permclient.go 第 17 行
  • 描述grpc.WithInsecure() 自 gRPC v1.53 起已被弃用,推荐使用 grpc.WithTransportCredentials(insecure.NewCredentials())
  • 建议
  import "google.golang.org/grpc/credentials/insecure"

  conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials()))

M-3. 配置文件包含明文敏感信息并提交到仓库

  • 位置etc/perm-api-dev.yamletc/perm-api-prod.yamletc/perm-api-test.yamletc/perm-api-xiaom.yaml
  • 描述:数据库密码、Redis 密码、JWT 签名密钥、ManagementKey 等敏感信息以明文形式存储在 YAML 配置文件中,且已提交到 Git 仓库。
  • 影响:如果仓库泄露或被分享,所有凭据将暴露。即使后续更改密码,Git 历史中仍保留旧值。
  • 建议:使用环境变量注入或密钥管理服务(如 Vault)管理敏感配置。至少在 .gitignore 中排除含真实密钥的配置文件,仅保留模板文件。

M-4. SyncPerms 接口未限制权限数组大小

  • 位置internal/logic/pub/syncPermsService.go 第 42 行
  • 描述ExecuteSyncPerms 对传入的 perms 数组长度没有上限检查。产品客户端可以一次性发送极大的权限列表,导致:
    • 数据库事务中执行大量 INSERT/UPDATE
    • 内存中构建大量对象
    • 事务持锁时间过长
  • 建议:增加上限检查,如 if len(perms) > 5000 { return error }

M-5. 僵尸代码:GetUserPerms 函数及其参数

  • 位置internal/logic/auth/perms.go
  • 描述GetUserPerms 函数接收 deptIdisSuperAdmin 两个参数,但函数体内完全未使用这两个参数,仅转发给 UserDetailsLoader.Load()。且该函数在所有生产代码中从未被调用,仅在测试文件中使用。
  • 建议:如果此函数预留用于未来 gRPC 接口,应更新签名移除未使用参数;否则应删除。

M-6. 僵尸代码:多个 Middleware Context Helper 函数

  • 位置internal/middleware/jwtauthMiddleware.go 第 110-136 行
  • 描述:以下函数在生产代码中从未被调用(仅在 testutil 中使用):
    • GetUsername(ctx) —— 生产代码中直接使用 GetUserDetails(ctx).Username
    • GetMemberType(ctx) —— 同上
    • IsSuperAdmin(ctx) —— 生产代码中直接用 caller.IsSuperAdmin
  • 建议:如果这些函数不打算作为公开 API 暴露给外部包使用,应考虑移除以减少维护负担。

M-7. 僵尸代码:多个 Model 层函数仅被测试代码调用

  • 位置:各 model 包的自定义 Model 文件
  • 描述:以下接口方法在生产代码中从未被调用,仅存在于测试或 mock 中:

| 函数 | 所在包 | 说明 | |------|--------|------| | DeleteByUserId | userrole, userperm | 生产代码全部使用带 ForProduct 后缀的变体 | | DeleteByUserIdForProduct(非 Tx 版本) | userrole, userperm | 生产代码全部使用 Tx 事务版本 | | DeleteByRoleId(非 Tx 版本) | roleperm | 生产代码使用 DeleteByRoleIdTx | | DisableNotInCodes(非 Tx 版本) | perm | 生产代码使用 DisableNotInCodesWithTx | | FindAllByProductCode | perm | 生产代码使用 FindAllCodesByProductCode | | FindListByDeptIds | user | 从未在任何 logic 中被调用 | | FindByUserId | userperm, userrole, productmember | 仅测试中使用 | | FindPermIdsByUserIdAndEffect(不含 ForProduct 后缀) | userperm | 生产代码使用 ForProduct 版本 | | DeleteByUserIdTx(不含 ForProduct) | userrole, userperm | 生产代码使用 ForProductTx 版本 |

  • 建议:这些函数可能是为了方便测试编写的辅助方法。如果确认不会对外暴露,可以标注注释说明用途;如果完全冗余,建议移除以保持接口精简。

M-8. CreateProduct 缺少产品编码格式校验

  • 位置internal/logic/product/createProductLogic.go 第 44-55 行
  • 描述CreateUserusername 有严格的正则校验(^[a-zA-Z0-9_]{2,64}$),但 CreateProductcode 仅校验了长度上限(64 字符),未校验格式。产品编码被广泛用作数据库 WHERE 条件和 Redis 缓存 key 的一部分。
  • 影响:如果传入包含特殊字符(如空格、中文、/:)的产品编码,虽然不会导致 SQL 注入(参数化查询),但可能导致:
    • Redis key 格式混乱
    • 自动生成的管理员用户名 admin_{code} 不合法
    • 前端 URL 编码问题
  • 建议:增加正则校验:

    var productCodeRegexp = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]{1,63}$`)
    if !productCodeRegexp.MatchString(req.Code) {
      return nil, response.ErrBadRequest("产品编码只能包含字母、数字、下划线和中划线,须以字母开头")
    }
    

    M-9. UpdateDept 修改 status 时未清理子部门用户的缓存

    • 位置internal/logic/dept/updateDeptLogic.go 第 73-92 行
    • 描述:当修改部门 status 为禁用时,仅清理了该部门直属用户的缓存。子部门用户的缓存清理逻辑被包裹在 if req.DeptType == "NORMAL" || req.DeptType == "DEV" 条件中,仅当请求中显式传入了 DeptType 字段才会执行。
    • 影响:如果禁用一个 DEV 类型部门时只传入 status=2 而不传入 deptType,该部门下所有直属用户的缓存会被清理,但子部门的用户缓存不会。由于 loadPerms 只检查用户自身部门的 DeptType 和 Status,子部门用户不受父部门状态影响,所以当前行为在逻辑上是正确的。但代码意图不够清晰,建议增加注释说明为什么子部门不需要级联清理。

    L-1. UserList 在超管模式下无 productCode 时查询全量用户

    • 位置internal/logic/user/userListLogic.go 第 56-62 行
    • 描述:当超管不传 productCode 时,执行 FindListByPage 查询全量用户。在用户量较大(如数千用户)时,虽然有分页,但 COUNT(*) 查询可能较慢(全表扫描,无 WHERE 条件)。
    • 建议:如果用户表数据量增长到万级以上,考虑为 COUNT(*) 增加缓存或近似计数。当前业务规模下此为低优先级。

    L-2. DeptTree 一次性加载所有部门到内存构建树

    • 位置internal/logic/dept/deptTreeLogic.go 第 28 行
    • 描述FindAll 一次性查询所有部门记录,在内存中构建树结构。对于权限系统的典型规模(几十到几百个部门),这是完全合理的做法。
    • 建议:无需优化。仅在此记录,如果未来部门数量异常增长,可考虑前端懒加载。

    L-3. BindRoles 和 BindRolePerms 采用全量删除后重新插入

    • 位置internal/logic/user/bindRolesLogic.gointernal/logic/role/bindRolePermsLogic.go
    • 描述:绑定角色/权限时,事务中先 DELETE 该用户(或角色)在当前产品下的所有关联记录,再 BatchInsert 新记录。这种"先删后插"方式简洁可靠,适合关联数量不大(通常每用户 < 20 个角色,每角色 < 200 个权限)的场景。
    • 建议:当前实现合理。如果未来出现单角色绑定数百个权限的场景,可优化为差异更新(仅插入新增、删除移除)以减少写入量。

    L-4. createDeptLogic 在事务中执行两步操作以回填 Path

    • 位置internal/logic/dept/createDeptLogic.go 第 68-93 行
    • 描述:创建部门时需要先 INSERT 获得自增 ID,再用 ID 构建 path 字段并 UPDATE。代码使用了事务保证两步操作的原子性,实现正确。
    • 建议:当前实现合理。如果追求极致优化,可考虑使用 UUID 或预分配 ID 来避免两步操作,但收益极小。

    L-5. permclient.GetUserPerms 缺少 AppKey/AppSecret 参数传递

    • 位置permclient/permclient.go 第 58-63 行
    • 描述go func (c *PermClient) GetUserPerms(ctx context.Context, userId int64, productCode string) (*pb.GetUserPermsResp, error) { return c.cli.GetUserPerms(ctx, &pb.GetUserPermsReq{ UserId: userId, ProductCode: productCode, }) }

gRPC 服务端 GetUserPerms 需要 AppKeyAppSecret 进行产品身份验证,但客户端封装函数未传递这两个参数,请求必然失败(返回 Unauthenticated)。

  • 影响:任何通过 permclient.GetUserPerms 发起的调用都无法通过服务端的认证校验。
  • 修复方案go func (c *PermClient) GetUserPerms(ctx context.Context, appKey, appSecret string, userId int64, productCode string) (*pb.GetUserPermsResp, error) { return c.cli.GetUserPerms(ctx, &pb.GetUserPermsReq{ AppKey: appKey, AppSecret: appSecret, UserId: userId, ProductCode: productCode, }) } --- ## 📋 审计总结 | 维度 | 评估 | |------|------| | 逻辑一致性 | 整体良好。UpdateProduct 状态校验是唯一的行为不一致点。 | | 并发与竞态 | 发现 1 个严重的 Read-Modify-Write 竞态条件(H-1),涉及安全关键的 tokenVersion 字段。 | | 资源管理 | 良好。所有数据库操作通过 go-zero 连接池管理,事务使用正确,无泄漏风险。 | | 数据完整性 | 关键写操作(BindRoles、BindPerms、RemoveMember、DeleteRole、CreateProduct、SyncPerms)均在事务中执行,原子性有保障。 | | 安全漏洞 | SQL 注入风险:无(全部参数化查询)。水平越权:已有完善的 CheckManageAccess 层级校验。发现管理后台暴力破解风险(H-2)和限流竞争问题(H-3)。 | | 边界处理 | 对 nil、空值、可选字段的处理较为完善。UserDetails 的零值初始化合理。 | | 数据库性能 | 存在可优化的冗余读取(M-1),但整体无 N+1 查询问题。列表接口批量查询+map 组装的模式正确。 | | 僵尸代码 | 发现 1 个僵尸函数(M-5)、3 个未使用的 middleware helper(M-6)、10+ 个仅测试调用的 model 方法(M-7)。 | | 接口契约 | gRPC 客户端 GetUserPerms 缺少必要参数(L-5),会导致调用方无法正常使用。 |