audit-report.md 17 KB

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

审计时间:2026-04-17 审计范围:/internal 下所有非测试 Go 源文件(含 model、logic、handler、middleware、loaders、server 等)


🚩 核心逻辑漏洞 (High Risk)

H-1: BindRoles 全量删除导致跨产品角色绑定丢失

  • 文件internal/logic/user/bindRolesLogic.go:62
  • 描述:为用户绑定角色时,事务中调用 DeleteByUserIdTx 删除了该用户在所有产品下的角色关联,随后仅重新插入当前产品的角色。DeleteByUserIdTx 的 SQL 为 DELETE FROM sys_user_role WHERE userId = ?,不带产品过滤。
  • 影响:产品 A 的管理员为某用户绑定角色时,该用户在产品 B、C 等其他产品中已有的角色绑定会被一并清除,导致跨产品数据丢失,权限异常。这是一个静默数据破坏——操作者和被操作者均不会收到任何告警。
  • 修复方案:将 DeleteByUserIdTx 替换为已有的 DeleteByUserIdForProductTx,仅删除当前产品下的角色关联:
// bindRolesLogic.go 事务内
if err := l.svcCtx.SysUserRoleModel.DeleteByUserIdForProductTx(ctx, session, req.UserId, productCode); err != nil {
    return err
}

H-2: SetUserPerms 全量删除导致跨产品用户直授权丢失

  • 文件internal/logic/user/setUserPermsLogic.go:69
  • 描述:与 H-1 同理,DeleteByUserIdTx 的 SQL 为 DELETE FROM sys_user_perm WHERE userId = ?,会删除用户在所有产品下的直授权记录,但随后仅重新插入当前产品的权限。
  • 影响:产品 A 管理员设置用户权限时,用户在产品 B 的 ALLOW/DENY 直授权被清除。
  • 修复方案:替换为 DeleteByUserIdForProductTx
// setUserPermsLogic.go 事务内
if err := l.svcCtx.SysUserPermModel.DeleteByUserIdForProductTx(ctx, session, req.UserId, productCode); err != nil {
    return err
}

H-3: RequireProductAdmin 未校验目标产品归属,存在跨产品越权

  • 文件internal/logic/auth/access.go:88-99
  • 描述RequireProductAdmin 仅检查当前登录者的 MemberType 是否为 ADMIN不校验该身份是否属于被操作的目标产品。调用者的 MemberType 来源于 JWT 中的 productCode(登录时的产品),而 CreateRoleBindRolePermsDeleteRoleUpdateRole 等接口的 productCode/roleId 来自请求体,两者可能不一致。
  • 影响:产品 A 的 ADMIN 登录后,可以:
    • 在产品 B 中创建/修改/删除角色
    • 修改产品 B 角色的权限绑定
    • 为产品 B 创建用户
  • 修复方案:新增 RequireProductAdminFor(ctx, targetProductCode) 函数,或在 RequireProductAdmin 中接受 targetProductCode 参数,与 caller 的产品上下文做比对:
func RequireProductAdminFor(ctx context.Context, targetProductCode string) error {
    caller := middleware.GetUserDetails(ctx)
    if caller == nil {
        return response.ErrUnauthorized("未登录")
    }
    if caller.IsSuperAdmin {
        return nil
    }
    if caller.MemberType == consts.MemberTypeAdmin && caller.ProductCode == targetProductCode {
        return nil
    }
    return response.ErrForbidden("仅超级管理员或该产品的管理员可执行此操作")
}

然后在 CreateRoleBindRolePermsDeleteRoleUpdateRole 中使用目标产品的 productCode 调用此函数。


H-4: BindRolePerms 不验证权限项归属于角色所在产品

  • 文件internal/logic/role/bindRolePermsLogic.go:31-65
  • 描述:为角色绑定权限时,仅验证角色存在,未校验 req.PermIds 中的权限是否属于该角色所在的产品。对比 setUserPermsLogic.go 中有做此验证(检查 p.ProductCode != productCode)。
  • 影响:攻击者可将产品 B 的权限 ID 绑定到产品 A 的角色上。虽然最终 loadPerms 在计算用户权限时会按 productCode 过滤,不会直接导致越权执行,但会污染 sys_role_perm 表数据,导致角色详情返回不属于本产品的 permId,给前端和管理造成混乱。
  • 修复方案:在事务之前增加权限归属校验:
if len(req.PermIds) > 0 {
    perms, err := l.svcCtx.SysPermModel.FindByIds(l.ctx, req.PermIds)
    if err != nil {
        return err
    }
    if len(perms) != len(req.PermIds) {
        return response.ErrBadRequest("包含无效的权限ID")
    }
    for _, p := range perms {
        if p.ProductCode != role.ProductCode {
            return response.ErrBadRequest("不能绑定其他产品的权限")
        }
    }
}

H-5: Login/gRPC Login 不验证产品有效性和用户成员关系

  • 文件internal/logic/pub/loginLogic.go:32-90internal/server/permserver.go:112-159
  • 描述:HTTP 和 gRPC 的 Login 接口均未验证:
    1. productCode 对应的产品是否存在且状态为启用
    2. 登录用户是否是该产品的成员
  • 影响
    • 任何用户可用任意 productCode(甚至不存在的)获取有效的 JWT access token
    • 用户可为未加入的产品获取 token,虽然 perms 为空,但仍可访问仅需 JWT 认证的列表类接口(userList、roleList、permList、memberList 等),获得不应可见的数据
  • 修复方案:在密码验证通过后、生成 token 前,增加产品和成员校验:
product, err := l.svcCtx.SysProductModel.FindOneByCode(l.ctx, req.ProductCode)
if err != nil {
    return nil, response.ErrBadRequest("产品不存在")
}
if product.Status != consts.StatusEnabled {
    return nil, response.ErrForbidden("该产品已被禁用")
}

_, memberErr := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, req.ProductCode, u.Id)
if memberErr != nil {
    return nil, response.ErrForbidden("您不是该产品的成员")
}

H-6: 敏感凭据明文提交到版本控制

  • 文件etc/perm-api-dev.yamletc/perm-api-test.yamletc/perm-api-prod.yaml
  • 描述:三个环境的配置文件均包含以下明文敏感信息并已提交到 Git:
    • MySQL 数据库密码(NsDmWyM@312
    • Redis 密码(NsDmWyM@312
    • JWT AccessSecret / RefreshSecret
    • ManagementKey
    • 且三个环境共用同一套 MySQL 和 Redis 密码
  • 影响
    • 任何有代码仓库读权限的人均可获取全部环境的数据库和 Redis 凭据
    • 密码一旦泄露,三个环境同时受影响
    • JWT secret 泄露后可伪造任意用户的 token
  • 修复方案
    1. etc/*.yaml 加入 .gitignore,从 Git 历史中清除(git filter-branch 或 BFG)
    2. 使用环境变量或密钥管理服务(如 Vault、AWS Secrets Manager)注入敏感配置
    3. 立即轮换所有已泄露的密码和 secret
    4. 为 dev/test/prod 使用不同的凭据

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

M-1: CreateRole/UpdateRole 未校验 PermsLevel 范围

  • 文件internal/logic/role/createRoleLogic.go:38updateRoleLogic.go:42
  • 描述PermsLevel 字段无范围校验,可设为 0、负数或任意大整数。checkPermLevel 中通过 MinPermsLevel 数值比较来判定管理权限(数值越小权限越高),若不限制范围可能导致权限比较逻辑出现意外行为。
  • 建议:增加 PermsLevel 的合法范围校验(如 1-999),确保符合业务预期:
if req.PermsLevel < 1 || req.PermsLevel > 999 {
    return nil, response.ErrBadRequest("权限级别必须在 1-999 之间")
}

M-2: UpdateUser 不验证 DeptId 有效性

  • 文件internal/logic/user/updateUserLogic.go:70-72
  • 描述:超级管理员修改用户部门时,不检查 DeptId 是否对应一个存在且启用的部门。若设置了不存在的 DeptId,后续 checkDeptHierarchy 中查询 SysDeptModel.FindOne(target.DeptId) 会失败,返回「无权操作」的误导性错误。
  • 建议
if req.DeptId != nil && *req.DeptId > 0 {
    if _, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, *req.DeptId); err != nil {
        return response.ErrBadRequest("部门不存在")
    }
}

M-3: UserDetailsLoader.loadFromDB 静默吞错误,可能缓存不完整数据

  • 文件internal/loaders/userDetailsLoader.go:175-189
  • 描述loadFromDB 内部各 loadXxx 方法在查询失败时仅打印日志,不返回错误。Load 方法通过 singleflight 调用 loadFromDB,始终返回 (ud, nil),即使用户不存在也会将不完整的 UserDetails(只有 userId 和 productCode,其余字段为零值)缓存到 Redis 并存活 5 分钟。
  • 影响
    • 若 DB 瞬时故障,所有并发请求将获取并缓存空数据,在 TTL 内该用户无法正常使用
    • 中间件 Handle 中通过 ud.Status != consts.StatusEnabled 检查(StatusEnabled=1),而默认 Status=0,会导致用户被判定为「账号已被冻结」
  • 建议:在 loadUser 失败时(特别是非 ErrNotFound 的错误)不应缓存结果,或设置更短的 TTL/不缓存:
v, _, _ := l.sf.Do(key, func() (interface{}, error) {
    ud, ok := l.loadFromDB(ctx, userId, productCode)
    if ok {
        // 正常缓存
        if val, err := json.Marshal(ud); err == nil {
            l.rds.SetexCtx(ctx, key, string(val), l.ttl)
        }
    }
    // 加载失败时不缓存,让下次请求重试
    return ud, nil
})

M-4: AdminLogin 不限制仅超管用户登录

  • 文件internal/logic/pub/adminLoginLogic.go:33-91
  • 描述adminLogin 接口仅验证 managementKey 后即允许任何用户登录管理后台,包括普通用户。虽然 managementKey 本身是一道屏障,但从设计意图来看管理后台应仅面向超管。
  • 建议:如管理后台仅面向超管,可在密码验证后增加:
if u.IsSuperAdmin != consts.IsSuperAdminYes {
    return nil, response.ErrForbidden("管理后台仅限超级管理员登录")
}

M-5: CreateUser 用户名唯一性存在 TOCTOU 窗口

  • 文件internal/logic/user/createUserLogic.go:53-56
  • 描述:先通过 FindOneByUsername 检查用户名是否存在,再执行 Insert。在两个操作之间,其他并发请求可能已经插入了同名用户。虽然代码在 Insert 失败时通过检查 "1062" / "Duplicate entry" 做了补偿处理,但这种双重检查模式产生了不必要的复杂度。
  • 建议:可以去掉前置的 FindOneByUsername 查询,直接依赖数据库唯一索引约束 + Duplicate entry 错误判断即可,减少一次 DB 查询。

M-6: gRPC Login 可用于暴力枚举用户密码

  • 文件internal/server/permserver.go:112-159
  • 描述:gRPC Login 接口无速率限制。在内部网络中,攻击者可对 gRPC 端口发起高速暴力破解(bcrypt 有计算成本,但大量并发仍可造成 CPU 压力和逐步猜测)。HTTP 侧通常有 Nginx/WAF 保护,但 gRPC 端口可能缺少此层防护。
  • 建议
    • 在 gRPC server 添加速率限制 interceptor
    • 或确保 gRPC 端口不对外暴露,仅内部服务间调用

M-7: RefreshToken 不验证用户是否仍为产品成员

  • 文件internal/logic/pub/refreshTokenLogic.gointernal/server/permserver.go:161-191
  • 描述:RefreshToken 接口仅检查用户状态是否启用,不检查用户是否仍为该产品的成员。若用户被移除出产品成员(RemoveMember),其已有的 refresh token 仍然可以续签新的 access token(最长 7 天 refresh token 有效期内)。
  • 建议:在 refresh 时增加成员关系检查,或在 RemoveMember 时将用户的 refresh token 加入黑名单。

M-8: BindRoles 中角色 ID 去重缺失

  • 文件internal/logic/user/bindRolesLogic.go:69-78
  • 描述req.RoleIds 未做去重处理。若前端传入 [1, 1, 1],会在 sys_user_role 表中插入三条相同的记录(除非表有唯一联合索引)。
  • 建议:在插入前对 req.RoleIds 去重:
seen := make(map[int64]bool, len(req.RoleIds))
uniqueRoleIds := make([]int64, 0, len(req.RoleIds))
for _, id := range req.RoleIds {
    if !seen[id] {
        seen[id] = true
        uniqueRoleIds = append(uniqueRoleIds, id)
    }
}

M-9: SetUserPerms 中 PermId 去重缺失

  • 文件internal/logic/user/setUserPermsLogic.go:49-66
  • 描述:同 M-8,req.Perms 中若存在重复的 PermId,会插入多条记录。此外,对同一 PermId 若传入了不同 Effect(一个 ALLOW 一个 DENY),语义冲突但代码不会拦截。
  • 建议:对 PermId 去重,并检查同一 PermId 不能同时为 ALLOW 和 DENY。

L-1: UserList/MemberList/RoleList 等列表接口缺少产品级权限控制

  • 文件internal/logic/user/userListLogic.gointernal/logic/member/memberListLogic.gointernal/logic/role/roleListLogic.go
  • 描述:所有列表类接口仅需 JWT 认证即可访问,无产品成员身份校验。已登录的任何用户可查看任意产品的角色列表、权限列表、成员列表。结合 H-5(Login 不验证成员关系),用户只需注册并使用任意 productCode 登录,即可遍历所有产品的内部数据。
  • 建议:对需要 productCode 参数的列表接口,校验调用者是否为该产品的成员或超管。

L-2: UserDetail/RoleDetail 未校验调用者是否有权查看

  • 文件internal/logic/user/userDetailLogic.gointernal/logic/role/roleDetailLogic.go
  • 描述:任何已认证用户可通过 ID 查看任意用户或角色的详情,包括用户的邮箱、手机号等个人信息。
  • 建议:根据业务需求,考虑限制用户仅能查看本产品范围内的数据,或对敏感字段脱敏。

L-3: 部门删除缺少事务保护

  • 文件internal/logic/dept/deleteDeptLogic.go:33-47
  • 描述:删除部门前先查子部门和关联用户,确认为空后再删除。但在检查和删除之间(虽然是极短窗口),理论上可能有新数据插入。在实际业务中,部门操作频率极低且限超管,此风险可接受。
  • 建议:如追求严格一致性,可将检查与删除放入同一事务中,并在 SQL 层面做最终校验。

L-4: DeptType 枚举校验不充分

  • 文件internal/logic/dept/createDeptLogic.go:50-53
  • 描述:创建部门时,若 req.DeptType 为非空但非法值(如 "INVALID"),代码会直接使用该值(不经过枚举校验)。仅 updateDeptLogic.go 做了枚举校验。
  • 建议:创建时也增加枚举校验:
if deptType != consts.DeptTypeNormal && deptType != consts.DeptTypeDev {
    return nil, response.ErrBadRequest("无效的部门类型")
}

L-5: cleanByPattern 使用 SCAN 在 Redis Cluster 下可能遗漏 key

  • 文件internal/loaders/userDetailsLoader.go:153-171
  • 描述CleanByProductClean 方法通过 Redis SCAN + pattern 匹配来批量删除缓存。在 Redis Cluster 模式下,SCAN 只扫描当前连接的节点,可能遗漏其他节点上的 key。当前配置为单节点 (Type: node),暂不受影响,但若未来迁移到 Cluster 需要注意。
  • 建议:预留 Cluster 兼容方案,或在 key 设计中使用 hash tag 确保同一用户的 key 落在同一 slot。

📋 审计总结

级别 编号 问题 影响面
🔴 High H-1 BindRoles 跨产品角色删除 数据丢失
🔴 High H-2 SetUserPerms 跨产品权限删除 数据丢失
🔴 High H-3 RequireProductAdmin 跨产品越权 越权操作
🔴 High H-4 BindRolePerms 未校验权限产品归属 数据污染
🔴 High H-5 Login 不验证产品/成员关系 未授权访问
🔴 High H-6 敏感凭据明文提交 Git 凭据泄露
🟡 Medium M-1 PermsLevel 无范围校验 逻辑异常
🟡 Medium M-2 UpdateUser 不验证 DeptId 数据不一致
🟡 Medium M-3 UserDetailsLoader 缓存不完整数据 用户被误冻结
🟡 Medium M-4 AdminLogin 不限制超管 权限边界模糊
🟡 Medium M-5 CreateUser TOCTOU 代码冗余
🟡 Medium M-6 gRPC Login 无速率限制 暴力破解风险
🟡 Medium M-7 RefreshToken 不验证成员关系 token 续期漏洞
🟡 Medium M-8 BindRoles 未去重 数据重复
🟡 Medium M-9 SetUserPerms 未去重 语义冲突
🟢 Low L-1 列表接口缺产品级权限控制 信息泄露
🟢 Low L-2 Detail 接口无访问控制 信息泄露
🟢 Low L-3 部门删除无事务 理论竞态
🟢 Low L-4 DeptType 枚举校验不充分 脏数据
🟢 Low L-5 SCAN 在 Cluster 下的兼容性 缓存残留

优先建议修复顺序:H-1 → H-2 → H-3 → H-5 → H-6 → H-4 → M-3 → M-7 → 其余 Medium → Low