audit-report.md 16 KB

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

审计范围:internal/logicinternal/modelinternal/middlewareinternal/loadersinternal/server 全部非测试业务代码 审计时间:2026-04-17


🚩 核心逻辑漏洞 (High Risk)

H1. UpdateUser 修改状态时未递增 tokenVersion,导致冻结用户可能仍持有有效令牌

  • 位置internal/logic/user/updateUserLogic.go 第 82-93 行
  • 描述UpdateUser 接口允许通过 req.Status 将用户冻结(status=2),但内部调用的是通用 SysUserModel.Update(),该方法不会递增 tokenVersion。而专用接口 UpdateUserStatus 正确调用了 SysUserModel.UpdateStatus(),其中执行了 tokenVersion = tokenVersion + 1。两个接口实现同一个业务操作(冻结用户),安全保障强度却不同。
  • 影响
    1. 通过 UpdateUser 冻结用户后,tokenVersion 不变,被冻结用户的 RefreshToken 在 Redis 缓存未命中的情况下(如 Redis 故障、缓存过期后重新加载前的竞态窗口)仍可用于换取新 AccessToken。
    2. 虽然 UserDetailsLoader.Clean() 提供了即时保护(清缓存后下次请求会从 DB 加载到 status=2),但若 Redis 操作失败(网络抖动等),旧缓存最长可存活 5 分钟(defaultCacheTTL=300),期间用户不受冻结影响。
    3. UpdateUserStatus 的行为不一致,容易让维护者产生误解。
  • 修复方案:在 UpdateUser 中,当 status 发生变更时,改用 UpdateStatus 或手动递增 tokenVersion
// updateUserLogic.go — 在 status 变更分支中
if req.Status == consts.StatusEnabled || req.Status == consts.StatusDisabled {
    if user.Status != req.Status {
        // status 发生了实际变更,走 UpdateStatus 以递增 tokenVersion
        if err := l.svcCtx.SysUserModel.UpdateStatus(l.ctx, req.Id, req.Status); err != nil {
            return err
        }
        user.Status = req.Status // 同步内存值,后续 Update 不会覆盖回旧值
    }
}

或者更彻底的方案:移除 UpdateUser 中的 status 字段支持,强制状态变更只能通过 updateUserStatus 接口。


H2. UserDetail 接口返回的 roleIds 未按产品隔离,存在跨产品信息泄露

  • 位置internal/logic/user/userDetailLogic.go 第 44 行
  • 描述FindRoleIdsByUserId 查询 sys_user_role 时没有关联产品过滤,返回了用户在所有产品下的全部角色 ID。非超管的产品 A 成员查看某用户详情时,能看到该用户在产品 B 下的角色 ID 列表。
  • 影响:虽然角色 ID 本身只是数字,但结合角色列表接口可反推出目标用户在其他产品中的角色配置,构成越权信息泄露。
  • 修复方案:在查询时加入产品维度的过滤:
productCode := middleware.GetProductCode(l.ctx)
var roleIds []int64
if productCode != "" {
    roleIds, err = l.svcCtx.SysUserRoleModel.FindRoleIdsByUserIdForProduct(l.ctx, user.Id, productCode)
} else {
    roleIds, err = l.svcCtx.SysUserRoleModel.FindRoleIdsByUserId(l.ctx, user.Id)
}

需要在 SysUserRoleModel 中新增 FindRoleIdsByUserIdForProduct 方法,关联 sys_role 表按 productCode 过滤。


H3. CreateUser 未校验 DeptId 是否存在,可写入不存在的部门关联

  • 位置internal/logic/user/createUserLogic.go 第 69-80 行
  • 描述CreateUser 直接将 req.DeptId 写入数据库,未校验该部门是否存在。而 UpdateUser 在修改 DeptId 时正确地进行了存在性校验。
  • 影响:创建用户时传入不存在的 deptId,用户记录会关联到一个"幽灵部门"。后续 UserDetailsLoader.loadDept 会查询失败并静默跳过(DeptPath 为空),导致该用户在部门层级校验 checkDeptHierarchy 中行为异常——具体表现为 caller.DeptPath == "",触发 "您的部门信息异常" 错误,无法管理任何其他用户。
  • 修复方案
if req.DeptId > 0 {
    if _, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, req.DeptId); err != nil {
        return nil, response.ErrBadRequest("部门不存在")
    }
}

H4. 成员类型管理层级与 SQL 设计文档不一致

  • 位置internal/logic/auth/access.go memberTypePriority 函数 vs perm.sql 第 151-155 行注释
  • 描述perm.sql 中明确标注管理层级顺序为 超级管理员 > DEVELOPER > ADMIN > MEMBER,但代码中 memberTypePriority 的实现为:
类型 代码优先级 SQL 文档预期
SUPER_ADMIN 0 (最高) 最高
ADMIN 1 2
DEVELOPER 2 1
MEMBER 3 (最低) 最低

代码中 ADMIN(1) > DEVELOPER(2),而文档描述 DEVELOPER > ADMIN。

  • 影响:如果文档是正确的设计意图,那么当前实现存在以下问题:
    • DEVELOPER 无法管理同产品下的 ADMIN 用户(被 checkPermLevel 拦截)
    • ADMIN 可以管理 DEVELOPER 用户(不应被允许)
    • CheckMemberTypeAssignment 中 DEVELOPER 无法分配 ADMIN 类型(若按文档应该可以)

如果代码是正确的、文档是过时的,则应更新 SQL 注释以避免后续维护者误解。

  • 修复方案:确认真实的业务层级意图,统一代码与文档。若 DEVELOPER 应高于 ADMIN:
func memberTypePriority(memberType string) int {
    switch memberType {
    case consts.MemberTypeSuperAdmin:
        return 0
    case consts.MemberTypeDeveloper:
        return 1
    case consts.MemberTypeAdmin:
        return 2
    case consts.MemberTypeMember:
        return 3
    default:
        return math.MaxInt32
    }
}

若代码实现是正确的(ADMIN > DEVELOPER),则更新 perm.sql 注释为:超级管理员 > ADMIN > DEVELOPER > MEMBER


H5. SyncPerms 传入空权限列表会禁用产品全部权限,缺乏防护

  • 位置internal/logic/pub/syncPermsLogic.gointernal/model/perm/sysPermModel.go DisableNotInCodesWithTx
  • 描述:当 SyncPerms 请求中 perms 数组为空时,codes 也为空,DisableNotInCodesWithTx 会执行:

    UPDATE sys_perm SET status=2 WHERE productCode=? AND status=1
    

    一次性禁用该产品下所有启用的权限

    • 影响:客户端代码 bug(如序列化异常导致 perms 为空数组)、网络问题(请求被截断)都可能触发全量权限禁用,影响该产品下所有用户的访问。这是一个潜在的可用性灾难。
    • 修复方案:在 SyncPerms 入口处增加空数组保护:
    if len(req.Perms) == 0 {
    return nil, response.ErrBadRequest("权限列表不能为空,如需禁用所有权限请使用专用接口")
    }
    

gRPC 端的 SyncPermissions 也需要同步添加此校验。


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

M1. RefreshToken 未实现轮转,被盗令牌在有效期内可无限复用

  • 位置internal/logic/pub/refreshTokenLogic.go 第 75 行
  • 级别:Medium
  • 描述RefreshToken 接口刷新后直接原样返回旧的 refreshTokenRefreshToken: tokenStr)。这意味着 refresh token 在整个有效期内是静态不变的,一旦泄露,攻击者可以持续用它换取新的 access token,直到 refresh token 过期或用户主动修改密码(触发 tokenVersion 递增)。
  • 建议:实现 Refresh Token Rotation:每次刷新时签发新的 refresh token 并使旧的失效(可通过在 Redis 中维护一个 token 黑名单或版本号实现)。

M2. HTTP 与 gRPC 的 SyncPermissions 逻辑重复,存在不同步风险

  • 位置internal/logic/pub/syncPermsLogic.go vs internal/server/permserver.go SyncPermissions 方法
  • 级别:Medium
  • 描述:HTTP 端走 SyncPermsLogic,gRPC 端在 permserver.go 中内联实现了几乎相同的逻辑。两处代码维护相同的业务语义(认证、去重、事务批量更新、缓存清理),但互相独立。
  • 建议:将核心逻辑抽取为共享的 service 函数(类似 ValidateProductLogin 的做法),让 HTTP Logic 和 gRPC Server 都调用同一份代码。

M3. RequireProductAdmin 函数未绑定具体产品,存在跨产品越权隐患

  • 位置internal/logic/auth/access.go RequireProductAdmin 函数
  • 级别:Medium
  • 描述RequireProductAdmin 只检查 caller.MemberType == ADMIN,不验证操作者是否是目标产品的管理员。如果产品 A 的 ADMIN 调用了使用此函数鉴权的接口来操作产品 B 的数据,会被错误放行。
  • 现状:当前所有业务代码使用的是带产品校验的 RequireProductAdminForRequireProductAdmin 实际未被引用。
  • 建议:删除 RequireProductAdmin 函数或标记为 deprecated,避免后续开发者误用。

M4. CreateProduct 事务中管理员用户名可能冲突导致整体回滚

  • 位置internal/logic/product/createProductLogic.go 第 87 行
  • 级别:Medium
  • 描述:创建产品时自动生成管理员用户名为 admin_{productCode}。如果系统中已存在同名用户(如手动创建或之前产品删除后遗留),InsertWithTx 会因唯一索引冲突报错,导致整个事务回滚——产品也不会被创建,且错误信息为底层数据库错误,对调用方不友好。
  • 建议:在事务开始前先检查用户名是否已存在,给出明确的业务错误提示:
if _, err := l.svcCtx.SysUserModel.FindOneByUsername(l.ctx, adminUsername); err == nil {
    return nil, response.ErrConflict(fmt.Sprintf("用户名 %s 已存在,无法自动创建管理员账号", adminUsername))
}

M5. BindRolePerms / SetUserPerms 未校验权限的启用状态

  • 位置internal/logic/role/bindRolePermsLogic.go 第 58-64 行、internal/logic/user/setUserPermsLogic.go 第 65-74 行
  • 级别:Low
  • 描述:绑定权限时只校验了权限 ID 存在且属于同一产品,但未检查权限是否处于启用状态(status=1)。已被 SyncPerms 禁用的权限仍可被绑定到角色或用户。
  • 实际影响有限loadPerms 在计算最终权限时会过滤掉 status != 1 的权限,所以被禁用的权限不会生效。但绑定关系的存在可能造成管理界面上的困惑。
  • 建议:在校验循环中增加状态检查:
for _, p := range perms {
    if p.ProductCode != role.ProductCode {
        return response.ErrBadRequest("不能绑定其他产品的权限")
    }
    if p.Status != consts.StatusEnabled {
        return response.ErrBadRequest(fmt.Sprintf("权限 %s 已被禁用,无法绑定", p.Code))
    }
}

M6. ProductDetail / ProductList 缺少产品维度的访问控制

  • 位置internal/logic/product/productDetailLogic.goproductListLogic.go
  • 级别:Low
  • 描述:任何已登录用户(包括非超管的普通产品成员)都可以查看系统中所有产品的列表和详情(除 AppKey 外)。虽然敏感字段 AppKey 仅对超管可见、AppSecret 不返回,但产品编码、名称等信息对非本产品成员可见。
  • 建议:评估业务需求。如果产品信息确实应该对所有登录用户可见(如产品选择页面),则当前实现合理。否则应增加 caller.ProductCode 校验或只返回用户所属产品的列表。

M7. DeptTree 接口无任何权限过滤

  • 位置internal/logic/dept/deptTreeLogic.go
  • 级别:Low
  • 描述DeptTree 加载并返回系统中全部部门的树形结构,无论调用者的身份和所属产品。任何已登录用户都能看到完整的组织架构。
  • 建议:如果部门树只应由超管可见,增加 RequireSuperAdmin 校验。如果需要按层级裁剪(只看本部门及子部门),应根据 caller.DeptPath 过滤。

M8. loadRoles 全量加载后内存过滤,存在轻微效率损失

  • 位置internal/loaders/userDetailsLoader.go loadRoles 方法
  • 级别:Low
  • 描述FindRoleIdsByUserId 返回用户在所有产品下的全部角色 ID,再通过 FindByIds 批量查询角色详情,最后在内存中按 ProductCodeStatus 过滤。对于只加入了 1-2 个产品的普通用户不会有问题,但逻辑上可以在 SQL 层面就做好过滤。
  • 建议:新增按产品过滤的查询方法:
func FindRoleIdsByUserIdForProduct(ctx context.Context, userId int64, productCode string) ([]int64, error)

通过 JOIN sys_role 表在查询时过滤 productCode,减少不必要的数据传输和内存开销。


M9. DeleteRole 缓存失效存在极小时间窗口遗漏

  • 位置internal/logic/role/deleteRoleLogic.go 第 40-42 行
  • 级别:Low
  • 描述affectedUserIds 在事务执行之前查询。如果在查询之后、事务执行之前有新的用户被绑定到该角色,这些用户的缓存不会被主动清理(但会在 5 分钟后自然过期)。
  • 实际影响极低:这个时间窗口极短(微秒级),且角色删除事务内已清除所有 user-role 绑定关系,新绑定的用户在缓存过期后会自动生效。
  • 建议:可接受当前实现。如需极致一致性,可在事务内查询 affectedUserIds。

M10. CreateUser 缺少用户名格式校验

  • 位置internal/logic/user/createUserLogic.go
  • 级别:Low
  • 描述CreateUser 仅校验了用户名长度(最大 64 字符),未校验格式。用户名可以包含空格、特殊字符、中文等,可能导致:
    • 与自动生成的 admin_{code} 格式冲突
    • 登录时的编码问题
    • 日志可读性降低
  • 建议:增加用户名格式校验(如只允许字母数字下划线):
if !regexp.MustCompile(`^[a-zA-Z0-9_]{2,64}$`).MatchString(req.Username) {
    return nil, response.ErrBadRequest("用户名只能包含字母、数字和下划线,长度2-64个字符")
}

M11. gRPC GetUserPerms 无鉴权保护

  • 位置internal/server/permserver.go GetUserPerms 方法
  • 级别:Low(取决于部署方式)
  • 描述:gRPC 端的 GetUserPerms 接口没有任何认证或授权校验,任何能访问 gRPC 端口的客户端都可以查询任意用户的权限列表。
  • 现状评估:如果 gRPC 仅在内网(如 K8s 集群内部)暴露给可信的下游服务,这是合理的设计。但如果 gRPC 端口意外暴露到公网,则构成严重的信息泄露。
  • 建议:确保 gRPC 端口不暴露到外部网络。如有需要,可增加 gRPC 拦截器进行 mTLS 或 token 校验。

M12. UpdateMember / UpdateRole / UpdateDept 对无效 status 值静默忽略

  • 位置updateMemberLogic.goupdateRoleLogic.goupdateDeptLogic.go 多处
  • 级别:Low
  • 描述:这些接口在处理 status 字段时采用"白名单匹配"模式——只有 1 和 2 才会赋值,其他值(如 3、-1)被静默忽略。调用方传入非法值时不会收到任何错误提示,可能导致前端 bug 难以排查。
  • 建议:对非 0 的非法 status 值返回明确错误:
if req.Status != 0 {
    if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled {
        return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(冻结)")
    }
    role.Status = req.Status
}

总结

级别 数量 关键发现
🚩 High 5 token 失效不一致、跨产品信息泄露、层级定义矛盾、数据完整性、无保护的全量禁用
⚠️ Medium 4 RefreshToken 无轮转、gRPC/HTTP 逻辑重复、函数越权隐患、用户名冲突
💡 Low 8 无效权限绑定、产品访问控制、部门树全量暴露、效率优化、格式校验等

整体评价:项目的架构设计合理,go-zero 框架的使用规范,核心安全机制(JWT tokenVersion、bcrypt、参数化 SQL、限流)实现到位。主要风险集中在同一业务操作的多个入口未保持一致性(如 UpdateUser vs UpdateUserStatus)以及数据隔离在产品边界上的不完整。建议优先修复 H1(token 失效不一致)和 H5(空列表全量禁用),这两个问题在生产环境中最有可能造成实际影响。