审计范围:
/internal下全部非测试生产代码(logic、model、middleware、handler、loaders、server、svc、config、consts、response、util)及入口文件perm.go、gRPC 客户端permclient/。 审计时间:2026-04-18
internal/logic/user/updateUserLogic.go 第 47-112 行UpdateUser 先通过 FindOne 读取完整用户行(包含 tokenVersion),在内存中修改字段后调用通用 Update() 回写全部字段。Update() 对应的 SQL 形如 UPDATE sys_user SET ... tokenVersion=?, ... WHERE id=?,会将内存中的 tokenVersion 值覆盖到数据库。然而 UpdatePassword 和 UpdateStatus 使用的是原子 SQL:
UPDATE sys_user SET tokenVersion = tokenVersion + 1 WHERE id = ?
如果在 UpdateUser 的 FindOne 和 Update 之间,另一个请求执行了 UpdatePassword 或 UpdateStatus,原子递增的 tokenVersion 会被 UpdateUser 的陈旧值静默覆盖回去。
影响:
修复方案:UpdateUser 应改为部分字段更新,不触碰 tokenVersion、password 等安全敏感字段。如果需要修改 status 并递增 tokenVersion,应使用与 UpdateStatus 相同的原子 SQL,或在事务中使用 SELECT ... FOR UPDATE 锁定行。
// 方案一:拆分为部分更新 SQL
func (m *customSysUserModel) UpdateProfile(ctx context.Context, id int64, fields map[string]interface{}) error {
// 动态构建 SET 子句,仅更新传入字段,不覆盖 tokenVersion/password
}
// 方案二:如果更新 status 需要递增 tokenVersion,单独调用 UpdateStatus
if req.Status != 0 && user.Status != req.Status {
if err := l.svcCtx.SysUserModel.UpdateStatus(l.ctx, req.Id, req.Status); err != nil {
return err
}
}
internal/logic/pub/adminLoginLogic.go、internal/handler/routes.go 第 174-188 行ValidateProductLogin 在 loginService.go 中使用了 UsernameLoginLimit(每用户名 300 秒 10 次),但管理后台登录 AdminLogin 只经过 IP 维度的 LoginRateLimit(每 IP 60 秒 20 次),没有按用户名限频。managementKey,实际风险有所降低,但密钥和密码的双重暴力破解仍然可行。AdminLogin 中复用或新增按用户名维度的频率限制器: func (l *AdminLoginLogic) AdminLogin(req *types.AdminLoginReq) (*types.LoginResp, error) {
if l.svcCtx.UsernameLoginLimit != nil {
code, _ := l.svcCtx.UsernameLoginLimit.Take(req.Username)
if code == limit.OverQuota {
return nil, response.ErrTooManyRequests("该账号登录尝试过于频繁,请5分钟后再试")
}
}
// ... 原有逻辑
}
internal/handler/routes.go 第 174-201 行描述:refreshToken 路由与 login、adminLogin 共用 LoginRateLimit 中间件(基于 IP,60 秒 20 次)。RefreshToken 是 access token 过期后的常规续签操作,调用频率远高于登录。
影响:
修复方案:为 refreshToken 使用独立的速率限制器,或直接移除其 IP 限流(因为 refreshToken 自身已有 JWT 签名验证和 tokenVersion 校验保护):
// servicecontext.go 中新增
refreshRlMiddleware := middleware.NewRateLimitMiddleware(
rds, 60, 60, c.CacheRedis.KeyPrefix+":rl:refresh", c.BehindProxy,
)
internal/logic/product/updateProductLogic.go 第 49-51 行描述:
if req.Status == consts.StatusEnabled || req.Status == consts.StatusDisabled {
product.Status = req.Status
}
当传入 status=3 或 status=-1 等无效值时,代码静默忽略,不更新也不报错。而 updateRoleLogic、updateUserLogic、updateDeptLogic、updateMemberLogic 对相同场景都会返回 400 错误。
影响:接口行为不一致。调用方传入非法状态值后收到成功响应,误以为状态已修改,实际上并未生效。在前端管理界面可能导致状态显示与实际不符。
修复方案:与其他更新接口保持一致,显式校验并报错:
if req.Status != 0 {
if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled {
return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(禁用)")
}
product.Status = req.Status
}
internal/logic/user/updateUserStatusLogic.goFindOne:
SysUserModel.FindOne —— 检查用户是否存在、是否超管CheckManageAccess → checkDeptHierarchy 内部又调用 SysUserModel.FindOne(第 115 行 access.go)SysUserModel.UpdateStatus 内部再次调用 FindOne(用于构建缓存 key)虽然 go-zero 的 model 层有 cache,第 2-3 次读取会命中缓存,但仍有序列化/反序列化开销和额外的网络往返(如果 Redis 部署在远端)。
UpdateStatus 接收 username 参数以跳过内部 FindOne。grpc.WithInsecure()permclient/permclient.go 第 17 行grpc.WithInsecure() 自 gRPC v1.53 起已被弃用,推荐使用 grpc.WithTransportCredentials(insecure.NewCredentials())。 import "google.golang.org/grpc/credentials/insecure"
conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials()))
etc/perm-api-dev.yaml、etc/perm-api-prod.yaml、etc/perm-api-test.yaml、etc/perm-api-xiaom.yaml.gitignore 中排除含真实密钥的配置文件,仅保留模板文件。internal/logic/pub/syncPermsService.go 第 42 行ExecuteSyncPerms 对传入的 perms 数组长度没有上限检查。产品客户端可以一次性发送极大的权限列表,导致:
if len(perms) > 5000 { return error }。GetUserPerms 函数及其参数internal/logic/auth/perms.goGetUserPerms 函数接收 deptId 和 isSuperAdmin 两个参数,但函数体内完全未使用这两个参数,仅转发给 UserDetailsLoader.Load()。且该函数在所有生产代码中从未被调用,仅在测试文件中使用。internal/middleware/jwtauthMiddleware.go 第 110-136 行GetUsername(ctx) —— 生产代码中直接使用 GetUserDetails(ctx).UsernameGetMemberType(ctx) —— 同上IsSuperAdmin(ctx) —— 生产代码中直接用 caller.IsSuperAdmin| 函数 | 所在包 | 说明 |
|------|--------|------|
| DeleteByUserId | userrole, userperm | 生产代码全部使用带 ForProduct 后缀的变体 |
| DeleteByUserIdForProduct(非 Tx 版本) | userrole, userperm | 生产代码全部使用 Tx 事务版本 |
| DeleteByRoleId(非 Tx 版本) | roleperm | 生产代码使用 DeleteByRoleIdTx |
| DisableNotInCodes(非 Tx 版本) | perm | 生产代码使用 DisableNotInCodesWithTx |
| FindAllByProductCode | perm | 生产代码使用 FindAllCodesByProductCode |
| FindListByDeptIds | user | 从未在任何 logic 中被调用 |
| FindByUserId | userperm, userrole, productmember | 仅测试中使用 |
| FindPermIdsByUserIdAndEffect(不含 ForProduct 后缀) | userperm | 生产代码使用 ForProduct 版本 |
| DeleteByUserIdTx(不含 ForProduct) | userrole, userperm | 生产代码使用 ForProductTx 版本 |
internal/logic/product/createProductLogic.go 第 44-55 行CreateUser 对 username 有严格的正则校验(^[a-zA-Z0-9_]{2,64}$),但 CreateProduct 对 code 仅校验了长度上限(64 字符),未校验格式。产品编码被广泛用作数据库 WHERE 条件和 Redis 缓存 key 的一部分。/、:)的产品编码,虽然不会导致 SQL 注入(参数化查询),但可能导致:
admin_{code} 不合法建议:增加正则校验:
var productCodeRegexp = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]{1,63}$`)
if !productCodeRegexp.MatchString(req.Code) {
return nil, response.ErrBadRequest("产品编码只能包含字母、数字、下划线和中划线,须以字母开头")
}
internal/logic/dept/updateDeptLogic.go 第 73-92 行status 为禁用时,仅清理了该部门直属用户的缓存。子部门用户的缓存清理逻辑被包裹在 if req.DeptType == "NORMAL" || req.DeptType == "DEV" 条件中,仅当请求中显式传入了 DeptType 字段才会执行。status=2 而不传入 deptType,该部门下所有直属用户的缓存会被清理,但子部门的用户缓存不会。由于 loadPerms 只检查用户自身部门的 DeptType 和 Status,子部门用户不受父部门状态影响,所以当前行为在逻辑上是正确的。但代码意图不够清晰,建议增加注释说明为什么子部门不需要级联清理。internal/logic/user/userListLogic.go 第 56-62 行productCode 时,执行 FindListByPage 查询全量用户。在用户量较大(如数千用户)时,虽然有分页,但 COUNT(*) 查询可能较慢(全表扫描,无 WHERE 条件)。COUNT(*) 增加缓存或近似计数。当前业务规模下此为低优先级。internal/logic/dept/deptTreeLogic.go 第 28 行FindAll 一次性查询所有部门记录,在内存中构建树结构。对于权限系统的典型规模(几十到几百个部门),这是完全合理的做法。internal/logic/user/bindRolesLogic.go、internal/logic/role/bindRolePermsLogic.goDELETE 该用户(或角色)在当前产品下的所有关联记录,再 BatchInsert 新记录。这种"先删后插"方式简洁可靠,适合关联数量不大(通常每用户 < 20 个角色,每角色 < 200 个权限)的场景。internal/logic/dept/createDeptLogic.go 第 68-93 行path 字段并 UPDATE。代码使用了事务保证两步操作的原子性,实现正确。permclient/permclient.go 第 58-63 行go
func (c *PermClient) GetUserPerms(ctx context.Context, userId int64, productCode string) (*pb.GetUserPermsResp, error) {
return c.cli.GetUserPerms(ctx, &pb.GetUserPermsReq{
UserId: userId,
ProductCode: productCode,
})
}
gRPC 服务端 GetUserPerms 需要 AppKey 和 AppSecret 进行产品身份验证,但客户端封装函数未传递这两个参数,请求必然失败(返回 Unauthenticated)。
permclient.GetUserPerms 发起的调用都无法通过服务端的认证校验。go
func (c *PermClient) GetUserPerms(ctx context.Context, appKey, appSecret string, userId int64, productCode string) (*pb.GetUserPermsResp, error) {
return c.cli.GetUserPerms(ctx, &pb.GetUserPermsReq{
AppKey: appKey,
AppSecret: appSecret,
UserId: userId,
ProductCode: productCode,
})
}
---
## 📋 审计总结
| 维度 | 评估 |
|------|------|
| 逻辑一致性 | 整体良好。UpdateProduct 状态校验是唯一的行为不一致点。 |
| 并发与竞态 | 发现 1 个严重的 Read-Modify-Write 竞态条件(H-1),涉及安全关键的 tokenVersion 字段。 |
| 资源管理 | 良好。所有数据库操作通过 go-zero 连接池管理,事务使用正确,无泄漏风险。 |
| 数据完整性 | 关键写操作(BindRoles、BindPerms、RemoveMember、DeleteRole、CreateProduct、SyncPerms)均在事务中执行,原子性有保障。 |
| 安全漏洞 | SQL 注入风险:无(全部参数化查询)。水平越权:已有完善的 CheckManageAccess 层级校验。发现管理后台暴力破解风险(H-2)和限流竞争问题(H-3)。 |
| 边界处理 | 对 nil、空值、可选字段的处理较为完善。UserDetails 的零值初始化合理。 |
| 数据库性能 | 存在可优化的冗余读取(M-1),但整体无 N+1 查询问题。列表接口批量查询+map 组装的模式正确。 |
| 僵尸代码 | 发现 1 个僵尸函数(M-5)、3 个未使用的 middleware helper(M-6)、10+ 个仅测试调用的 model 方法(M-7)。 |
| 接口契约 | gRPC 客户端 GetUserPerms 缺少必要参数(L-5),会导致调用方无法正常使用。 |