审计时间:2026-04-17
审计范围:perms-system-server全部业务源码(测试代码除外)
审计维度:逻辑一致性、并发与竞态、资源管理、数据完整性、安全漏洞、边界崩溃
internal/logic/user/userListLogic.go 第 45 行caller.ProductCode == req.ProductCode,但底层查询 SysUserModel.FindListByPage 执行的是 全表分页查询(无任何 WHERE 条件),返回了系统中所有用户,而非仅当前产品的成员用户。memberMap 只是在返回结果上附加了 memberType 信息,并不过滤非成员用户。sys_product_member 获取当前产品的成员 userId 列表,再用这些 userId 进行分页查询。示例:// userListLogic.go - 非超管场景
if req.ProductCode != "" && !caller.IsSuperAdmin {
list, total, err := l.svcCtx.SysUserModel.FindListByProductMembers(
l.ctx, req.ProductCode, page, pageSize,
)
// ...
}
需要在 Model 层新增 FindListByProductMembers 方法,JOIN sys_product_member 表过滤。
internal/logic/user/bindRolesLogic.go 第 33-101 行BindRoles 仅通过 CheckManageAccess 验证操作者对目标用户当前状态的管理权限,但未校验所绑定角色的 permsLevel 是否在操作者自身权限范围内。攻击路径:
CheckManageAccess 通过(50 < 100,A 的权限高于 B)// bindRolesLogic.go - 在遍历 roles 时增加
caller := middleware.GetUserDetails(l.ctx)
for _, r := range roles {
if r.ProductCode != productCode {
return response.ErrBadRequest("不能绑定其他产品的角色")
}
if r.Status != consts.StatusEnabled {
return response.ErrBadRequest("不能绑定已禁用的角色")
}
// 非超管不能分配超出自身权限级别的角色
if !caller.IsSuperAdmin && r.PermsLevel < caller.MinPermsLevel {
return response.ErrForbidden("不能分配权限级别高于自身的角色")
}
}
perm.sql 第 15 行;internal/logic/pub/syncPermsLogic.go 第 37 行;internal/server/permserver.go 第 40 行sys_product.appSecret 以明文存储在数据库中。当前仅用 subtle.ConstantTimeCompare 进行比对(防时序攻击)。若数据库被拖库或备份泄漏,所有产品的 appSecret 直接暴露。SyncPerms 接口,篡改该产品的全部权限定义,影响所有用户的权限体系。CreateProduct 时仅一次性返回原文,之后只存哈希值。验证时改用 bcrypt.CompareHashAndPassword:// syncPermsLogic.go / permserver.go
if err := bcrypt.CompareHashAndPassword(
[]byte(product.AppSecretHash), []byte(req.AppSecret),
); err != nil {
return nil, response.ErrUnauthorized("appSecret验证失败")
}
internal/logic/user/updateUserLogic.go 第 37-45 行UpdateUser 的权限判断逻辑为"只有超管或用户自身可修改",而系统中 UpdateUserStatus、BindRoles、SetUserPerms 等接口均使用 CheckManageAccess(支持产品管理员和部门层级管理)。这导致产品管理员可以创建用户(CreateUser)、冻结用户(UpdateUserStatus)、绑定角色(BindRoles),却无法修改用户的昵称、邮箱、部门等基本信息。CheckManageAccess,并保留对自身修改的限制(不能改自己的部门和状态):func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
caller := middleware.GetUserDetails(l.ctx)
if caller == nil {
return response.ErrUnauthorized("未登录")
}
if caller.UserId == req.Id {
if req.DeptId != nil || req.Status != 0 {
return response.ErrForbidden("不允许修改自己的部门和状态")
}
} else {
productCode := middleware.GetProductCode(l.ctx)
if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.Id, productCode); err != nil {
return err
}
}
// ... 后续逻辑不变
}
internal/logic/user/createUserLogic.goCreateUser 要求 RequireProductAdminFor(productCode) 校验产品管理员身份,但创建用户后并未将其加入当前产品的成员列表。新建用户需要管理员再单独调用 AddMember 才能登录和使用产品。memberType,当传入时在同一事务中自动创建产品成员记录;或在文档/前端层面明确引导管理员完成两步操作。*Model_gen.go(如 sysUserModel_gen.go 第 26-27 行,sysPermModel_gen.go 第 26-27 行)cacheSysUserIdPrefix、cacheSysPermIdPrefix 等缓存前缀在 newSys*Model 函数中被直接修改(cacheSysUserIdPrefix = cachePrefix + ":cache:sysUser:id:")。这些是 var 级别的全局变量。cachePrefix 创建同类 Model 实例(当前不存在此情况),会产生竞态条件。虽然当前启动时仅初始化一次,但这种模式在代码演进中存在隐患。internal/logic/member/addMemberLogic.go 第 52-55 行uk_product_user),但此错误未被捕获转换为友好提示,而是返回原始的 500 错误。result, err := l.svcCtx.SysProductMemberModel.Insert(l.ctx, &productmember.SysProductMember{...})
if err != nil {
if strings.Contains(err.Error(), "1062") || strings.Contains(err.Error(), "Duplicate entry") {
return nil, response.ErrConflict("该用户已是该产品成员")
}
return nil, err
}
internal/logic/auth/jwt.go 第 23-40 行;internal/middleware/jwtauthMiddleware.go Claims 结构Claims.Perms 字段将用户的所有权限 code 列表嵌入 access token。对于拥有数百个权限的用户,Token 体积会显著增长(可能超过 4KB),导致每次 HTTP 请求的 Header 过大。UserDetailsLoader(已有 Redis 缓存)在需要时加载。当前中间件已经通过 loader.Load 重新加载了完整用户信息,Token 中的 Perms 字段实际上未被中间件使用,仅用于前端展示。可以考虑在登录响应中单独返回 perms,从 Token 中移除。internal/logic/pub/refreshTokenLogic.go;internal/server/permserver.go 第 162-200 行RefreshToken 接口在签发新的 AccessToken 后,直接将原 RefreshToken 原样返回给客户端。RefreshToken 在有效期内可以被无限次调用,每次都会生成新的 AccessToken。tokenVersion 机制可以在修改密码/冻结用户时使所有 token 失效,这提供了基本保障。若需更严格的安全性,可实现 Refresh Token Rotation(每次刷新签发新 RefreshToken),或在 Redis 中维护一个已使用 RefreshToken 的黑名单。internal/middleware/jwtauthMiddleware.go 第 74-78 行;internal/loaders/userDetailsLoader.go 第 129-133 行UserDetailsLoader.Load 返回一个默认的 UserDetails(Status=0)。中间件判断 ud.Status != StatusEnabled 后返回"账号已被冻结"。但实际上用户是不存在/已被删除。ud := m.loader.Load(r.Context(), claims.UserId, claims.ProductCode)
if ud.Username == "" {
httpx.ErrorCtx(r.Context(), w, response.NewCodeError(401, "用户不存在或已被删除"))
return
}
if ud.Status != consts.StatusEnabled {
httpx.ErrorCtx(r.Context(), w, response.NewCodeError(403, "账号已被冻结"))
return
}
sys_audit_log 表,记录 operator_id、action、target_type、target_id、detail、ip、timestamp 等字段。在关键业务逻辑中通过异步方式写入审计记录,避免影响主流程性能。internal/logic/dept/deptTreeLogic.go;internal/logic/product/productListLogic.goDeptTree 接口无任何权限过滤,所有已登录用户可看到整个组织架构。ProductList 也对所有用户返回全部产品列表(AppKey 已对非超管隐藏)。internal/logic/dept/updateDeptLogic.goDEV(研发)改为 NORMAL,该部门下所有用户会立即失去"全权限"特权(因 loadPerms 中 DEV 部门自动获取全量权限的逻辑不再生效)。代码正确清除了缓存,但未在响应中提示此操作的影响范围。| 级别 | 编号 | 问题 | 类型 |
|---|---|---|---|
| 🚩 High | #1 | UserList 数据越权泄漏 | 安全漏洞 |
| 🚩 High | #2 | BindRoles 提权漏洞 | 安全漏洞 |
| 🚩 High | #3 | appSecret 明文存储 | 安全漏洞 |
| 🚩 High | #4 | UpdateUser 权限模型不一致 | 逻辑一致性 |
| ⚠️ Medium | #5 | CreateUser 未加入产品成员 | 数据完整性 |
| ⚠️ Medium | #6 | 缓存前缀全局可变状态 | 并发安全 |
| ⚠️ Medium | #7 | AddMember 并发错误信息不友好 | 边界处理 |
| ⚠️ Medium | #8 | JWT Token 体积过大风险 | 性能 |
| ⚠️ Medium | #9 | RefreshToken 可无限复用 | 安全加固 |
| ⚠️ Low | #10 | 用户不存在时错误信息误导 | 边界崩溃 |
| ⚠️ Low | #11 | 缺少操作审计日志 | 安全合规 |
| ⚠️ Low | #12 | DeptTree/ProductList 过度暴露 | 安全加固 |
| ⚠️ Low | #13 | UpdateDept 修改类型缺少影响提示 | 健壮性 |
优先修复建议:#1 和 #2 为最高优先级,直接影响数据安全和权限体系的可信度;#3 和 #4 建议在下一个迭代中修复。