audit-report.md 18 KB

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

审计范围:internal/ 下所有非测试源代码,包括 logic、model、middleware、handler、config、loader、server 层
审计时间:2026-04-17
排除范围:*_test.go*_mock_test.gotestutil/cli/pb/(生成代码)


🚩 核心逻辑漏洞 (High Risk)


H1. AdminLogin 未校验用户是否为超级管理员 —— 越权访问风险

  • 文件internal/logic/pub/adminLoginLogic.go:33-91
  • 描述AdminLogin 接口仅校验了 ManagementKey 和用户名密码,但没有校验用户的 isSuperAdmin 字段。任何普通用户只要知道 ManagementKey,就能通过管理后台登录接口获取一个 productCode="" 的 Token。
  • 影响
    • 拿到此 Token 后,用户通过 JWT 中间件校验,可以调用所有 JwtAuth 保护的接口。
    • 虽然 RequireSuperAdmin() 会在创建产品、管理部门等操作中拦截,但 UserDetailUserListRoleListProductListDeptTree 等查询接口没有额外权限校验,非超管用户可以通过此途径浏览所有系统数据。
    • ManagementKey 一旦泄露(如被抓包、配置文件泄露),整个系统对该用户门户大开。
  • 修复方案
// adminLoginLogic.go — 在密码验证通过后增加超管校验
if u.IsSuperAdmin != consts.IsSuperAdminYes {
    return nil, response.ErrForbidden("仅超级管理员可通过管理后台登录")
}

H2. 限流中间件 IP 提取逻辑有两个严重缺陷

  • 文件internal/middleware/ratelimitMiddleware.go:24-28
  • 描述
    1. r.RemoteAddr 包含端口号:Go 的 http.Request.RemoteAddr 格式为 IP:Port(如 192.168.1.1:54321)。由于每个 TCP 连接的临时端口不同,限流 Key 变成了每个连接独立计数,同一个客户端 IP 几乎不可能触发限流。
    2. 未处理反向代理场景:生产环境通常有 Nginx/Envoy 做反向代理,此时 RemoteAddr 是代理服务器的 IP,所有客户端共享同一个限流桶,导致少量正常请求就会触发全局限流。
  • 影响:登录接口(/auth/login/auth/adminLogin)的暴力破解防护形同虚设。攻击者可以不受限制地进行密码爆破。
  • 修复方案
func (m *RateLimitMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ip := extractClientIP(r)
        key := fmt.Sprintf("ip:%s", ip)
        code, _ := m.limiter.Take(key)
        if code == limit.OverQuota {
            httpx.ErrorCtx(r.Context(), w, response.ErrTooManyRequests("请求过于频繁,请稍后再试"))
            return
        }
        next(w, r)
    }
}

func extractClientIP(r *http.Request) string {
    // 优先从反代标准头提取
    if ip := r.Header.Get("X-Real-IP"); ip != "" {
        return ip
    }
    if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
        // 取第一个 IP(最靠近客户端的)
        if idx := strings.Index(forwarded, ","); idx != -1 {
            return strings.TrimSpace(forwarded[:idx])
        }
        return strings.TrimSpace(forwarded)
    }
    // 兜底:去掉端口
    host, _, err := net.SplitHostPort(r.RemoteAddr)
    if err != nil {
        return r.RemoteAddr
    }
    return host
}

H3. 多个查询接口存在水平越权 —— 无跨产品/无权限校验

  • 文件
    • internal/logic/user/userDetailLogic.go — 任意用户可查看任意用户详情
    • internal/logic/user/userListLogic.go — 任意用户可列出全系统所有用户
    • internal/logic/role/roleListLogic.go — 可传入任意 productCode 查看其他产品角色
    • internal/logic/role/roleDetailLogic.go — 可查看任意角色详情(含所绑定的权限 ID 列表)
    • internal/logic/perm/permListLogic.go — 可传入任意 productCode 查看其他产品权限
    • internal/logic/member/memberListLogic.go — 可传入任意 productCode 查看其他产品成员
  • 描述:上述接口只经过 JwtAuth 中间件校验了登录态,但没有校验调用者是否属于目标产品、是否有权访问该数据。一个产品 A 的普通成员(MEMBER),可以通过构造请求查看产品 B 的角色、权限、成员信息。
  • 影响信息泄露。权限系统本身的数据(角色名称、权限列表、用户列表、成员关系)被无差别暴露给所有已登录用户,违反了多产品之间的数据隔离原则。
  • 修复方案:对含 productCode 参数的查询接口,增加产品归属校验:
// 在 roleListLogic.go 等接口中增加
caller := middleware.GetUserDetails(l.ctx)
if caller == nil {
    return nil, response.ErrUnauthorized("未登录")
}
if !caller.IsSuperAdmin {
    if caller.ProductCode != req.ProductCode {
        return nil, response.ErrForbidden("无权访问该产品的数据")
    }
}

UserDetailUserList,应限制非超管用户只能查看自己所在产品的成员。


H4. UpdateUser 权限校验与同类接口不一致 —— 超管可被越权修改

  • 文件internal/logic/user/updateUserLogic.go:31-45
  • 描述UpdateUser 的权限逻辑为「只能改自己,或者超管改别人」。但对比 UpdateUserStatus(使用了 CheckManageAccess 检查部门层级和权限等级),UpdateUser 缺少以下校验:
    1. 超管 A 可以修改超管 B 的信息(包括部门、状态),没有类似 UpdateUserStatus 中 "不能修改超级管理员的状态" 的保护。
    2. 没有 CheckManageAccess:不校验部门层级关系和 permsLevel,超管可以直接修改任何用户的部门归属(DeptId),这可能绕过部门层级隔离的安全模型。
  • 影响:如果系统中有多个超级管理员,超管 A 可以将超管 B 的状态改为 StatusDisabled(冻结),或将其部门改为下级部门从而降低其管理范围。
  • 修复方案
// 对非自身操作增加更严格的校验
if caller.UserId != req.Id {
    // 仅超管可操作
    if !caller.IsSuperAdmin {
        return response.ErrForbidden("仅超管可修改其他用户信息")
    }
    // 不允许通过此接口修改其他超管
    if user.IsSuperAdmin == consts.IsSuperAdminYes {
        if req.Status != 0 || req.DeptId != nil {
            return response.ErrForbidden("不能修改其他超级管理员的状态和部门")
        }
    }
}

H5. BindRolePerms 未对 PermIds 去重 —— 重复 ID 导致数据库约束错误

  • 文件internal/logic/role/bindRolePermsLogic.go:41-54
  • 描述BindRoles 接口对 RoleIds 做了去重处理(第 47-57 行),但 BindRolePerms 没有对 PermIds 做同样的去重。当客户端传入重复的 PermIds(如 [1, 1, 2])时:
    • FindByIds 返回去重后的 2 条记录
    • len(perms) != len(req.PermIds)2 != 3 → 返回「包含无效的权限ID」
    • 错误信息具有误导性,实际上权限 ID 都是有效的,只是有重复
    • 如果绕过该检查(例如未来修改了校验逻辑),BatchInsertWithTx 会因 UNIQUE KEY uk_role_perm (roleId, permId) 约束而报错
  • 影响:前端传入重复数据时,用户收到令人困惑的错误提示,体验差且难以排查。
  • 修复方案
// 在 BindRolePerms 方法开头增加去重逻辑(同 BindRoles 的处理方式)
if len(req.PermIds) > 0 {
    seen := make(map[int64]bool, len(req.PermIds))
    uniqueIds := make([]int64, 0, len(req.PermIds))
    for _, id := range req.PermIds {
        if !seen[id] {
            seen[id] = true
            uniqueIds = append(uniqueIds, id)
        }
    }
    req.PermIds = uniqueIds
}

H6. SyncPerms 接口缺乏鉴权强度 —— 仅靠 LoginRateLimit 保护

  • 文件internal/handler/routes.go:176-188 + internal/logic/pub/syncPermsLogic.go
  • 描述/api/perm/sync 接口被分配到 LoginRateLimit 中间件组(而非 JwtAuth),且由于 H2 中的限流失效问题,该接口实际上几乎没有任何访问频率限制。虽然接口内部使用 appKey + appSecret 做认证,但:
    • appKeyappSecret 是长期有效的静态凭证
    • 没有 IP 白名单、签名时间戳、Nonce 等额外防重放机制
    • 攻击者获取凭证后可无限次调用,覆盖或禁用产品的所有权限
  • 影响:一旦 appKey/appSecret 泄露,攻击者可以:
    • 传入空的 Perms 列表,将目标产品所有权限禁用
    • 注入恶意权限 Code,污染权限数据
  • 修复方案
    1. 为 SyncPerms 接口增加独立的限流策略(区别于登录限流)
    2. 考虑增加请求签名(timestamp + nonce + HMAC(appSecret, body))防重放
    3. 在运维层面增加 IP 白名单

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


M1. 配置文件明文存储敏感信息

  • 文件etc/perm-api-dev.yaml(及其他环境配置)
  • 描述:MySQL 密码、Redis 密码、JWT Secret、ManagementKey 均以明文存储在 YAML 文件中。如果这些文件被提交到 Git 仓库,所有有仓库访问权限的人都能获取生产环境密钥。
  • 建议
    • 生产环境使用环境变量注入或密钥管理服务(如 Vault、AWS Secrets Manager)
    • 开发环境配置加入 .gitignore,仅保留 perm-api-example.yaml 模板

M2. CreateUser 不会自动关联产品成员 —— 业务流程断裂

  • 文件internal/logic/user/createUserLogic.go
  • 描述CreateUser 接口要求 RequireProductAdminFor(productCode) 校验调用者是产品管理员,但创建用户后并不自动将新用户加入该产品。调用方需要额外调用 AddMember 接口,形成两步操作。
  • 影响
    • 如果 CreateUser 成功但 AddMember 失败(网络中断、前端 Bug),系统中会出现"孤儿用户"——用户存在但不属于任何产品,无法登录任何产品
    • 增加了前端集成的复杂度和出错概率
  • 建议:在 CreateUser 事务中同时插入 sys_product_member 记录,或者至少返回一个明确的提示告知前端需要调用 AddMember。

M3. Model 初始化修改包级变量 —— 非线程安全

  • 文件internal/model/user/sysUserModel_gen.go:75-80(所有 model 的 _gen.go 均有此问题)
  • 描述newSysUserModel() 函数在初始化时会修改包级变量 cacheSysUserIdPrefixcacheSysUserUsernamePrefix。这些变量在包加载时已有初始值,被函数调用覆写。
    • 虽然当前代码只在 NewModels() 中调用一次,不会出现并发问题
    • 但作为生成代码模板,如果未来存在多实例或单测并行场景,会产生数据竞争
  • 建议:将 cache prefix 存储在 struct 实例中,而非修改包级变量。由于这是 goctl 生成代码,建议修改 cli/goctl/model/model-new.tpl 模板。

M4. gRPC Login 的限流器可能为 nil

  • 文件internal/server/permserver.go:116-125
  • 描述Login 方法中有 if s.svcCtx.GrpcLoginLimiter != nil 的判断,说明设计上允许 limiter 为 nil。但在 servicecontext.go:30 中 limiter 总是被创建。如果未来配置变更导致 Redis 不可用,limiter 创建会 panic(redis.MustNewRedis),而非优雅降级。
  • 建议:与当前实现保持一致,确保 GrpcLoginLimiter 始终非 nil,或在 NewServiceContext 中做容错处理。

M5. UserDetailsLoader.loadPerms 中研发部门判定可能不符合预期

  • 文件internal/loaders/userDetailsLoader.go:312-316
  • 描述
if ud.IsSuperAdmin ||
    ud.MemberType == consts.MemberTypeAdmin ||
    ud.MemberType == consts.MemberTypeDeveloper ||
    (ud.DeptType == consts.DeptTypeDev && ud.DeptStatus == consts.StatusEnabled) {

研发部门(DeptType == "DEV")的判定不与 productCode 关联——只要用户所在部门类型是 DEV 且部门启用,该用户在所有产品下都自动拥有全量权限。这意味着一个被拉进产品 A 的 MEMBER 类型成员,如果碰巧在研发部门,他在产品 A 下拥有的权限和 ADMIN 一样。

  • 影响:研发部门成员的权限范围可能超出业务预期,与成员类型(MEMBER)赋予的权限不匹配。
  • 建议:确认此行为是否为设计意图。如果研发部门全量权限仅应作用于特定产品,需增加产品关联判断。

M6. RefreshToken 不会续签 refreshToken 本身

  • 文件internal/logic/pub/refreshTokenLogic.go:67-68
  • 描述RefreshToken 接口返回新的 accessToken,但原样返回旧的 refreshToken。随着时间推移,refreshToken 会过期(7 天),用户被迫重新登录。
  • 影响:对于需要长期保持登录状态的场景(如桌面客户端、后台管理系统),用户体验不佳——每 7 天必须重新输入密码。
  • 建议:根据业务需求决定是否在每次刷新时签发新的 refreshToken(滑动过期策略)。如果不续签,应在 API 文档中明确说明 refreshToken 有效期为固定 7 天。

M7. UserDetailsLoader 缓存清理使用 SCAN —— 性能与兼容性风险

  • 文件internal/loaders/userDetailsLoader.go:162-180
  • 描述cleanByPattern 使用 SCAN 命令按 pattern 匹配并删除缓存 key。代码注释中已标注此方法不兼容 Redis Cluster。此外:
    • CleanByProduct 使用 pattern *:ud:*:{productCode},在产品成员较多时可能扫描大量 key
    • UpdateDept 中对每个子部门的每个用户逐个调用 Clean,如果部门内有几十个用户,会产生多次 SCAN 操作
  • 建议
    • 如果确定使用单节点 Redis,当前实现可接受
    • 若考虑未来迁移到 Redis Cluster,建议使用 Hash Tag(如 {productCode}:ud:userId)或维护一个 Set 记录某个产品下的所有缓存 key,以支持批量删除

M8. 部分接口缺少输入长度校验

  • 文件:各 createXxxLogic.goupdateXxxLogic.go
  • 描述:以下字段没有长度校验,但数据库有 varchar 长度限制:
    • username(最大 64)、nickname(最大 64)、email(最大 64)
    • productCode(最大 64)、productName(最大 64)
    • roleName(最大 64)、remark(最大 255)
    • dept.name(最大 64)、dept.path(最大 512)

当前未做前端/后端长度校验,超长输入会直接触发 MySQL 的 Data too long 错误(1406),返回不友好的 500 错误。

  • 建议:在 Logic 层统一增加关键字段的长度校验,返回可读的 400 错误信息。

💡 低风险优化建议 (Low)


L1. JWT Claims 中存储完整权限列表可能导致 Token 膨胀

  • 文件internal/logic/auth/jwt.go:22-38
  • 描述Claims.Perms[]string,权限 Code 字符串数组被完整编码进 JWT。如果某个产品配置了数百个权限,Token 可能达到数 KB 甚至超过 HTTP Header 限制(通常 8KB)。
  • 建议:考虑只在 JWT 中存储必要标识(userId、productCode、memberType),权限列表由服务端通过 UserDetailsLoader 实时获取(当前中间件已经在这样做)。

L2. DeptTree 返回所有部门包含已禁用的

  • 文件internal/logic/dept/deptTreeLogic.go:27
  • 描述FindAll 查询所有部门不区分状态,禁用的部门也会出现在树中。
  • 建议:根据业务需求,可增加参数控制是否过滤禁用部门,或在返回中标注状态供前端处理。

L3. CreateProduct 返回明文 AdminPassword

  • 文件internal/logic/product/createProductLogic.go:116-124
  • 描述:创建产品时自动生成的管理员密码在 HTTP 响应中明文返回。若响应被日志系统记录(如 access log、网关日志),密码可能泄露。
  • 建议:确保 API 网关/日志系统不记录响应体,或改为邮件/消息通知的方式下发初始密码。

L4. 错误处理中 errors.As== 混用

  • 文件internal/logic/pub/loginService.go:33 使用 == user.ErrNotFound,而 response.go:47 使用 errors.As
  • 描述ErrNotFound 比较使用 ==(值比较),如果未来 ErrNotFound 被 fmt.Errorf("%w", ...) 包装,== 会失效。
  • 建议:统一使用 errors.Is(err, user.ErrNotFound) 进行哨兵错误判断。

L5. ChangePassword 成功后不会使旧 Token 失效

  • 文件internal/logic/auth/changePasswordLogic.go
  • 描述:修改密码后清理了 UserDetails 缓存,但已签发的 Access Token 和 Refresh Token 仍然有效(最长可达 7 天)。如果用户因密码泄露而修改密码,攻击者持有的旧 Token 仍可正常使用。
  • 建议:引入 Token 版本号(存储在用户记录中),修改密码时递增版本号,中间件校验时比对版本号。

📊 审计总结

级别 数量 关键词
🚩 High 6 越权登录、限流失效、水平越权、权限校验不一致、数据校验缺失、接口防护不足
⚠️ Medium 8 明文密钥、流程断裂、线程安全、缓存一致性、权限范围、输入校验
💡 Low 5 Token 膨胀、状态过滤、明文密码返回、错误处理、Token 吊销

优先修复建议:H1(管理后台越权)→ H2(限流失效)→ H3(水平越权)→ H4(权限不一致)→ M1(密钥管理)→ H5/H6


本报告基于静态代码审计,未涉及运行时测试和渗透测试。建议在修复后进行集成测试验证。