audit-report.md 19 KB

权限管理系统 (perms-system-server) 深度代码审计报告

审计时间:2026-04-17 审计范围:全部非测试业务源代码(logic / model / middleware / loaders / server / handler) 审计维度:逻辑一致性、并发与竞态、资源管理、数据完整性、安全漏洞、边界崩溃


🚩 核心逻辑漏洞 (High Risk)


H-1:UserDetailsLoader.loadPerms 跨产品权限泄漏

  • 描述internal/loaders/userDetailsLoader.go 第 329-330 行,在为普通成员加载权限时,FindPermIdsByUserIdAndEffect 查询的 SQL 是 SELECT permId FROM sys_user_perm WHERE userId = ? AND effect = ?,该查询没有按 productCode 过滤。如果一个用户是多个产品的成员,且在不同产品下都被设置了用户级权限(ALLOW/DENY),则加载产品 A 的上下文时,会把产品 B 的用户级权限也混入计算。

具体污染路径:

  1. 用户在产品 B 下有 ALLOW 权限 permId=100(code 为 order:delete
  2. 当加载产品 A 的上下文时,allowIds 包含 permId=100
  3. permId=100 被加入 permIdSet,最终 FindByIds 返回时没有过滤 productCode
  4. 产品 B 的权限 code order:delete 泄漏到产品 A 的 Perms 列表和 JWT Token 中

更严重的是:产品 B 中的 DENY 规则也会泄漏,可能错误阻止产品 A 中本应拥有的权限。

  • 影响

    • 跨产品权限授予:用户在未授权的产品中获得额外权限
    • 跨产品权限拒绝:产品 B 的 DENY 规则错误地屏蔽产品 A 的合法权限
    • JWT Token 中包含其他产品的权限信息(信息泄漏)
  • 修复方案

方案一(推荐):在 loadPerms 中查询时增加产品过滤

  // internal/loaders/userDetailsLoader.go - loadPerms 方法中
  // 修改为通过子查询限定 productCode
  allowIds, _ := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(
      ctx, ud.UserId, consts.PermEffectAllow, ud.ProductCode)
  denyIds, _ := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(
      ctx, ud.UserId, consts.PermEffectDeny, ud.ProductCode)

对应新增 Model 方法:

  func (m *customSysUserPermModel) FindPermIdsByUserIdAndEffectForProduct(
      ctx context.Context, userId int64, effect string, productCode string,
  ) ([]int64, error) {
      var ids []int64
      query := fmt.Sprintf(
          "SELECT up.`permId` FROM %s up "+
          "INNER JOIN `sys_perm` p ON up.`permId` = p.`id` "+
          "WHERE up.`userId` = ? AND up.`effect` = ? AND p.`productCode` = ?",
          m.table)
      if err := m.QueryRowsNoCacheCtx(ctx, &ids, query, userId, effect, productCode); err != nil {
          return nil, err
      }
      return ids, nil
  }

H-2:UpdateUser 接口可绕过超管状态保护

  • 描述internal/logic/user/updateUserStatusLogic.go 明确禁止修改超级管理员的状态:
  if user.IsSuperAdmin == consts.IsSuperAdminYes {
      return response.ErrForbidden("不能修改超级管理员的状态")
  }

internal/logic/user/updateUserLogic.go 中也接受 Status 字段,对于非本人操作只要求调用者是超管,没有检查目标用户是否也是超管

  } else {
      if !caller.IsSuperAdmin {
          return response.ErrForbidden("仅允许修改自己的信息或超管操作")
      }
  }
  // ... 后续直接设置 status,无超管保护
  if req.Status == consts.StatusEnabled || req.Status == consts.StatusDisabled {
      user.Status = req.Status
  }

攻击路径:超管 A 通过 POST /api/user/update 传入 {"id": <超管B的ID>, "status": 2},即可冻结超管 B,绕过 updateUserStatus 的保护逻辑。

  • 影响:超级管理员之间可互相冻结账号,破坏系统管理根基。在最坏场景下,攻击者获取任一超管账号后可瘫痪所有其他超管。

  • 修复方案:在 updateUserLogic.go 中增加超管保护检查:

  if caller.UserId != req.Id {
      if !caller.IsSuperAdmin {
          return response.ErrForbidden("仅允许修改自己的信息或超管操作")
      }
      // 新增:禁止通过此接口修改其他超管的状态
      if req.Status != 0 && user.IsSuperAdmin == consts.IsSuperAdminYes {
          return response.ErrForbidden("不能通过此接口修改超级管理员的状态")
      }
  }

H-3:产品成员被禁用后仍可正常登录

  • 描述sys_product_member 表有 status 字段(1=启用,2=禁用),但登录流程和 UserDetailsLoader 均未校验此字段。

    • loginLogic.go 第 67 行仅检查成员是否存在,不检查 status:

      if _, memberErr := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(
      l.ctx, req.ProductCode, u.Id); memberErr != nil {
      return nil, response.ErrForbidden("您不是该产品的成员")
      }
      
      • permserver.go 第 140 行 gRPC Login 同样如此
      • userDetailsLoader.goloadMembership 方法也不检查 member.Status

      • 影响:管理员将某个成员禁用后,该成员仍然可以正常登录、获取 Token、使用该产品的所有权限,禁用操作形同虚设。

      • 修复方案

      loginLogic.gopermserver.go 的 Login 中增加成员状态检查:

      member, memberErr := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(
      l.ctx, req.ProductCode, u.Id)
      if memberErr != nil {
      return nil, response.ErrForbidden("您不是该产品的成员")
      }
      if member.Status != consts.StatusEnabled {
      return nil, response.ErrForbidden("您在该产品下的成员资格已被禁用")
      }
      

userDetailsLoader.goloadMembership 中增加状态检查:

  if member.Status != consts.StatusEnabled {
      return // 禁用的成员视为无成员身份
  }
  ud.MemberType = member.MemberType

H-4:gRPC VerifyToken 不检查用户实时状态

  • 描述internal/server/permserver.goVerifyToken 方法仅验证 JWT 签名和过期时间,不检查用户当前是否仍然处于启用状态:
  func (s *PermServer) VerifyToken(ctx context.Context, req *pb.VerifyTokenReq) (*pb.VerifyTokenResp, error) {
      token, err := jwt.ParseWithClaims(req.AccessToken, &middleware.Claims{}, ...)
      if err != nil || !token.Valid {
          return &pb.VerifyTokenResp{Valid: false}, nil
      }
      claims, ok := token.Claims.(*middleware.Claims)
      if !ok || claims.TokenType != consts.TokenTypeAccess {
          return &pb.VerifyTokenResp{Valid: false}, nil
      }
      // 直接返回 valid=true,未检查用户实时状态
      return &pb.VerifyTokenResp{Valid: true, ...}, nil
  }

而 HTTP 端的 JWT 中间件(jwtauthMiddleware.go)会通过 UserDetailsLoader.Load 实时检查用户状态。

  • 影响:当用户被冻结或从产品中移除后,如果其他微服务通过 gRPC VerifyToken 接口验证 Token,仍会得到 Valid: true,导致已冻结用户继续使用系统直到 Token 过期(默认 2 小时)。

  • 修复方案:在 VerifyToken 中增加实时状态检查:

  func (s *PermServer) VerifyToken(ctx context.Context, req *pb.VerifyTokenReq) (*pb.VerifyTokenResp, error) {
      // ... JWT 签名验证保持不变 ...

      // 新增:加载用户实时状态
      ud := s.svcCtx.UserDetailsLoader.Load(ctx, claims.UserId, claims.ProductCode)
      if ud.Status != consts.StatusEnabled {
          return &pb.VerifyTokenResp{Valid: false}, nil
      }
      if claims.ProductCode != "" && !ud.IsSuperAdmin && ud.MemberType == "" {
          return &pb.VerifyTokenResp{Valid: false}, nil
      }

      return &pb.VerifyTokenResp{
          Valid:      true,
          UserId:     claims.UserId,
          Username:   claims.Username,
          MemberType: ud.MemberType,  // 使用实时数据而非 Token 中的缓存数据
          Perms:      ud.Perms,
      }, nil
  }

H-5:gRPC Login 端点无速率限制

  • 描述:HTTP 登录端口通过 LoginRateLimit 中间件进行速率限制(60 秒内最多 20 次),但 gRPC 端口的 Login 方法(permserver.go 第 112 行)没有任何速率限制
  // routes.go - HTTP Login 有速率限制
  rest.WithMiddlewares(
      []rest.Middleware{serverCtx.LoginRateLimit},
      []rest.Route{
          {Method: http.MethodPost, Path: "/auth/login", Handler: pub.LoginHandler(serverCtx)},
      }...,
  )

  // permserver.go - gRPC Login 无速率限制
  func (s *PermServer) Login(ctx context.Context, req *pb.LoginReq) (*pb.LoginResp, error) {
      // 直接进入业务逻辑,无任何限流
  }
  • 影响:攻击者可通过 gRPC 端口对登录接口进行高频暴力破解,绕过 HTTP 的速率限制防护。由于使用 bcrypt 校验密码,高并发攻击还可能导致 CPU 资源耗尽。

  • 修复方案:为 gRPC 服务添加 UnaryInterceptor 形式的速率限制,或在 Login 方法入口处增加限流逻辑:

  func (s *PermServer) Login(ctx context.Context, req *pb.LoginReq) (*pb.LoginResp, error) {
      // 使用 peer 获取客户端 IP 进行限流
      p, _ := peer.FromContext(ctx)
      ip := p.Addr.String()
      code, _ := s.limiter.Take(fmt.Sprintf("grpc:login:%s", ip))
      if code == limit.OverQuota {
          return nil, status.Error(codes.ResourceExhausted, "请求过于频繁")
      }
      // ... 原有逻辑 ...
  }

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


M-1:Rate Limiter IP 提取可被伪造

  • 描述ratelimitMiddleware.goX-Forwarded-For → X-Real-IP → RemoteAddr 顺序提取客户端 IP:
  ip := r.Header.Get("X-Forwarded-For")
  if ip == "" {
      ip = r.Header.Get("X-Real-IP")
  }

X-Forwarded-For 头可以被客户端直接伪造。如果服务不在可信反向代理后面,攻击者可以在每次请求中设置不同的 X-Forwarded-For 值来绕过速率限制。

  • 影响:登录接口的速率限制可被绕过,使暴力破解攻击成为可能。

  • 修复方案:如果部署在反向代理后,应提取 X-Forwarded-For 中的第一个非信任 IP;如果直接对外暴露,应优先使用 RemoteAddr。建议根据部署拓扑配置信任代理列表:

  ip := extractRealIP(r, trustedProxies)

M-2:RefreshToken 端点无速率限制

  • 描述/api/auth/refreshTokenroutes.go 中注册时既无 JWT 认证中间件,也无速率限制中间件。
  server.AddRoutes(
      []rest.Route{
          {Method: http.MethodPost, Path: "/auth/refreshToken", Handler: pub.RefreshTokenHandler(serverCtx)},
          {Method: http.MethodPost, Path: "/perm/sync", Handler: pub.SyncPermsHandler(serverCtx)},
      },
      rest.WithPrefix("/api"),
  )
  • 影响:如果 RefreshToken 泄漏,攻击者可以无限次调用此端点,持续生成新的 AccessToken,且无法通过限流缓解。

  • 修复方案:为 RefreshToken 端点增加独立的速率限制,基于 token 中的 userId 进行限流。


M-3:部门禁用后不影响其下用户的登录和权限

  • 描述sys_dept 表有 status 字段,但整个系统中没有任何地方在加载用户信息时检查其所属部门是否处于启用状态。

    • userDetailsLoader.goloadDept 不检查部门状态
    • loadPerms 中依赖 ud.DeptType == consts.DeptTypeDev 判断是否授予全量权限,也不检查部门是否被禁用
    • 一个被禁用的 DEV 类型部门下的用户仍然会获得该产品的全部权限
  • 影响:管理员禁用部门后,预期该部门下用户的权限应受到影响(至少 DEV 部门的自动全权限应该失效),但实际上没有任何效果。

  • 修复方案:在 loadPerms 中判断 DeptType 时,增加部门状态检查:

  if ud.IsSuperAdmin ||
      ud.MemberType == consts.MemberTypeAdmin ||
      ud.MemberType == consts.MemberTypeDeveloper ||
      (ud.DeptType == consts.DeptTypeDev && ud.DeptStatus == consts.StatusEnabled) {
      // 全量权限
  }

M-4:Redis SCAN 在 Cluster 模式下不兼容

  • 描述userDetailsLoader.gocleanByPattern 使用 SCAN 命令按通配符模式清除缓存。代码注释已标注此问题,但仍是一个部署风险。
  // NOTE: SCAN only works on single-node Redis. For Redis Cluster, consider using hash tags
  func (l *UserDetailsLoader) cleanByPattern(ctx context.Context, pattern string) {
      var cursor uint64
      for {
          keys, cur, err := l.rds.ScanCtx(ctx, cursor, pattern, 100)
          // ...
      }
  }

此方法被 Clean(清除某用户所有产品缓存)和 CleanByProduct(清除某产品所有用户缓存)调用。

  • 影响:如果未来 Redis 切换为 Cluster 模式,SCAN 只能在单个节点上执行,无法跨节点匹配 key,导致缓存无法正确失效,引发权限数据过期不清除问题。

  • 修复方案:使用 Redis Hash Tag {tag} 将相关 key 路由到同一 slot,或改用主动记录关联 key 的方式(如维护一个 Set 记录用户关联的所有缓存 key),清除时通过 Set 成员精确删除。


M-5:HTTP 与 gRPC 登录逻辑高度重复

  • 描述loginLogic.gopermserver.go Login 中的登录逻辑几乎完全相同(校验用户名密码、检查状态、检查产品、检查成员关系、生成 Token),但分别独立实现。

  • 影响:当修复上述 H-3(成员状态检查)等问题时,需要同时修改两处代码,容易遗漏。未来任何登录逻辑的变更都需要双重维护。

  • 修复方案:将核心登录逻辑抽取为共享的 service 方法,HTTP handler 和 gRPC server 都调用同一个方法。


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


L-1:SyncPerms 接口无速率限制

  • 描述/api/perm/sync 端点无任何中间件保护(无 JWT、无 RateLimit),仅靠 appKey + appSecret 认证。虽然密钥泄漏的概率较低,但一旦泄漏,攻击者可以高频调用此接口触发大量数据库写操作和缓存清除。

  • 修复方案:为 SyncPerms 增加基于 appKey 的速率限制。


L-2:CreateProduct 响应暴露 AppSecret 明文

  • 描述createProductLogic.go 创建产品后将 AppSecret 明文返回给前端,且 AppSecret 在数据库中也以明文存储。
  return &types.CreateProductResp{
      AppSecret:     appSecret,       // 明文返回
      AdminPassword: adminPassword,   // 管理员初始密码明文返回
  }
  • 影响:AppSecret 相当于产品的 API 密钥,一旦通过网络被截获或日志记录,将可被用于调用 SyncPerms 等无需登录的接口。AdminPassword 同理。

  • 修复方案:这是一次性展示场景(创建后只展示一次),风险可控。建议:

    1. 确保传输层使用 HTTPS
    2. 确认日志中不会记录响应体(当前 response.go 的错误处理不会记录成功响应体,但需确认 go-zero 框架层面的 access log 配置)
    3. AppSecret 数据库存储可考虑改为哈希存储,验证时对比哈希值

L-3:UserDetailsLoader.Load 中 singleflight 的 panic 风险

  • 描述userDetailsLoader.go 第 106-118 行:
  v, _, _ := l.sf.Do(key, func() (interface{}, error) {
      ud, ok := l.loadFromDB(ctx, userId, productCode)
      if ok {
          // ... 缓存
      }
      return ud, nil
  })
  return v.(*UserDetails) // 如果 loadFromDB 的 ud 为 nil,此处会 panic

loadFromDBloadUser 失败时返回 (ud, false),此时 ud 不是 nil(是一个初始化过的 &UserDetails{}),所以实际上不会 panic。但如果未来重构时 loadFromDB 的返回值逻辑变化,此处的类型断言缺乏安全检查。

  • 修复方案:使用安全类型断言:
  ud, ok := v.(*UserDetails)
  if !ok || ud == nil {
      return &UserDetails{UserId: userId, ProductCode: productCode}
  }
  return ud

L-4:BindRolesLogic 绑定角色未检查目标用户是否为产品成员

  • 描述bindRolesLogic.go 在为用户绑定角色时,只检查用户是否存在和角色是否属于当前产品,但没有检查目标用户是否是当前产品的成员
  if _, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.UserId); err != nil {
      return response.ErrNotFound("用户不存在")
  }
  // 缺少:检查用户是否为当前产品成员
  • 影响:可以为非产品成员的用户绑定角色,这些角色在 loadRoles 时会被加载但因用户不是成员所以 loadMembership 不会设置 MemberType,权限不会实际生效。但数据库中会存在孤立的无效关联数据。

  • 修复方案:在绑定前校验目标用户是否为当前产品成员。


L-5:SetUserPerms 同理未校验产品成员关系

  • 描述:与 L-4 类似,setUserPermsLogic.go 在设置用户权限时没有检查目标用户是否为当前产品的成员,可以为非成员用户设置权限(虽然实际不会生效)。

  • 修复方案:在设置前校验目标用户是否为当前产品成员。


L-6:删除部门时忽略了 FindIdsByDeptId 的错误

  • 描述deleteDeptLogic.go 第 39 行使用 _ 忽略了查询错误:
  userIds, _ := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id)
  if len(userIds) > 0 {
      return response.ErrBadRequest("该部门下仍有关联用户,无法删除")
  }

如果数据库查询出错(比如连接异常),userIds 为 nil,len(userIds) 为 0,会误判为"无关联用户",导致在仍有用户关联时删除部门。

  • 影响:在数据库异常时可能导致误删有用户关联的部门,造成这些用户的 deptId 指向一个已不存在的部门。

  • 修复方案:处理错误并在查询失败时阻止删除:

  userIds, err := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id)
  if err != nil {
      return err
  }

📋 审计总结

级别 编号 问题摘要 影响面
🔴 High H-1 loadPerms 跨产品权限泄漏 数据安全
🔴 High H-2 UpdateUser 可绕过超管状态保护 权限控制
🔴 High H-3 禁用成员仍可登录 访问控制
🔴 High H-4 gRPC VerifyToken 不检查实时状态 访问控制
🔴 High H-5 gRPC Login 无速率限制 安全防护
🟡 Medium M-1 Rate Limiter IP 可伪造 安全防护
🟡 Medium M-2 RefreshToken 端点无速率限制 安全防护
🟡 Medium M-3 部门禁用不影响下属用户权限 逻辑一致性
🟡 Medium M-4 Redis SCAN 不兼容 Cluster 可部署性
🟡 Medium M-5 HTTP/gRPC 登录逻辑重复 可维护性
🟢 Low L-1 SyncPerms 无速率限制 安全防护
🟢 Low L-2 CreateProduct 暴露 AppSecret 信息泄漏
🟢 Low L-3 singleflight 类型断言无安全检查 健壮性
🟢 Low L-4 BindRoles 未校验产品成员关系 数据完整性
🟢 Low L-5 SetUserPerms 未校验产品成员关系 数据完整性
🟢 Low L-6 删除部门时忽略查询错误 数据完整性

建议优先级:H-1 > H-3 > H-2 > H-4 > H-5 > M-3 > M-1 > 其余

H-1(跨产品权限泄漏)是最高优先级,因为它会导致静默的权限污染且难以被人工发现。H-3(禁用成员仍可登录)次之,因为管理员执行禁用操作后会产生"已生效"的错觉。