审计范围:
/internal下全部非测试生产代码(logic、model、middleware、handler、loaders、server、svc、consts、response、util)及入口文件perm.go。 审计时间:2026-04-18 审计重点:业务逻辑闭环、跨接口一致性、权限绕过、缓存一致性、并发竞态、资源与性能、僵尸代码、接口契约完整性。 相对上一轮:H-1(BindRoles 误拦截 ADMIN)、H-2(GetUserPerms 未校验状态)、H-3(DEV 部门绕过)、M-2(批量 DELETE)、M-3/M-4(roleIds 语义)、M-5(UpdateDept 级联)、M-6(Claims.Perms)、M-11(DeleteDept TOCTOU)、L-3(UpdateDept 乐观锁)均已修复。本报告聚焦残留问题与本轮新发现。
internal/logic/product/updateProductLogic.go:30-63internal/middleware/jwtauthMiddleware.go:45-89internal/loaders/userDetailsLoader.go:280-290(loadProduct)、348-364(loadPerms)internal/server/permserver.go:157-222(VerifyToken / GetUserPerms)描述:
UpdateProduct 在将 sys_product.status 置为 Disabled 之后,只做了 UserDetailsLoader.CleanByProduct(product.Code)。但:
loadProduct 从 DB 只取 ProductName,没有把 product.Status 写入 UserDetails。loadPerms 的"全量权限"短路条件里完全没有引用产品状态,因此哪怕产品被禁用,IsSuperAdmin / ADMIN / DEVELOPER / DEV 部门 四类用户重新 Load 后仍会拿到完整 perms。jwtauthMiddleware.Handle 只校验 ud.Username / ud.Status / claims.TokenVersion,没有校验产品状态。gRPC VerifyToken 和 gRPC GetUserPerms 也没有校验产品状态。ValidateProductLogin 是唯一校验了 product.Status != Enabled 的点;但这仅影响新登录,对已经签发的 access / refresh token 无任何影响。也就是说:管理员把一个产品"冻结"之后,该产品的所有在线用户在整个 AccessExpire(甚至通过 RefreshToken 可以一直续期到 RefreshExpire)窗口内都能继续访问产品端的所有接口,并且接入方通过 gRPC GetUserPerms / VerifyToken 拿到的权限和"有效"状态也依然是放行的。
影响:
修复方案:
userDetailsLoader 的 UserDetails 增加 ProductStatus int64 字段,loadProduct 赋值。loadPerms 在所有"自动给全量权限"的分支上叠加 ud.ProductStatus == StatusEnabled 前置;或者在 loadPerms 入口直接:if ud.ProductCode != "" && ud.ProductStatus != consts.StatusEnabled {
ud.Perms = nil
return
}
jwtauthMiddleware.Handle 与 RefreshToken / VerifyToken / GetUserPerms 在 claims.ProductCode != "" 时统一校验 ud.ProductStatus == StatusEnabled,非启用直接 403 "该产品已被禁用"。UpdateProduct 在置 Disabled 时,同步把该产品所有成员的 tokenVersion+1(或引入一个 product_token_epoch),从而让所有既有 token 立即作废。推荐后者:新增 sys_product.tokenEpoch,access token 里带 productEpoch,中间件对比。internal/middleware/jwtauthMiddleware.go:73-87RefreshToken(refreshTokenLogic.go:53、permserver.go:125)、VerifyToken(permserver.go:174)、GetUserPerms(permserver.go:214)都会在 productCode != "" && !IsSuperAdmin 时校验 ud.MemberType != ""——这正是 loadMembership 在成员不存在或 status != Enabled 时的返回值。但是 HTTP 主流量入口 JwtAuth 中间件却没有这个校验,只看用户自身 status。结果:
UpdateMember.Status=Disabled 后,loader.Del 清理缓存,但旧 access token 未作废(tokenVersion 未变)。/api/dept/tree、/api/perm/list、/api/user/detail?id=self 等"只 JwtAuth 不做业务校验"的接口,全部放行。loadPerms:MemberType == "" 时会跳过"全量权限"分支,但普通成员分支仍会基于 sys_user_role / sys_user_perm 返回权限集。也就是被踢出的成员在中间件层不被拒,随后其 ud.Perms 还被填充(如果前端仍按 /api/perm/list 来做菜单,依然能看到"这个产品下自己之前拥有的权限")。loadPerms 的 DEV 部门短路已经加了 ud.MemberType != "" 这层护栏(第 358 行),但角色/用户权限并没有这层保护,导致普通成员分支依然会生效。
一致性问题:RefreshToken 拒绝了已禁用成员,但 HTTP 业务接口不拒,用户可能看到"业务继续可用 / 但 refresh 已失败"的分裂状态。
修复方案:在 jwtauthMiddleware.Handle 的 403 校验中对齐 RefreshToken:
if claims.ProductCode != "" && !ud.IsSuperAdmin && ud.MemberType == "" {
httpx.ErrorCtx(r.Context(), w, response.NewCodeError(403, "您已不是该产品的有效成员"))
return
}
同时 loadPerms 的普通成员分支也应当在入口加:
if ud.ProductCode != "" && !ud.IsSuperAdmin && ud.MemberType == "" {
return // 非有效成员,权限置空
}
(这条在第 353-358 行的"自动全量"里已有,但需要抽出作用到整函数)
UsernameLoginLimit 在 ManagementKey 校验之前计数,允许无凭据 DoS 超管账号internal/logic/pub/adminLoginLogic.go:35-45描述:
if l.svcCtx.UsernameLoginLimit != nil {
code, _ := l.svcCtx.UsernameLoginLimit.Take(req.Username)
if code == limit.OverQuota {
return nil, response.NewCodeError(429, "该账号登录尝试过于频繁,请5分钟后再试")
}
}
if subtle.ConstantTimeCompare([]byte(req.ManagementKey), []byte(l.svcCtx.Config.Auth.ManagementKey)) != 1 {
return nil, response.ErrUnauthorized("managementKey无效")
}
UsernameLoginLimit 的 key 维度是纯 username(svcCtx.UsernameLoginLimit = limit.NewPeriodLimit(300, 10, rds, ":rl:user"),没有叠加 IP),5 分钟内全局 10 次。由于 Take 发生在 ManagementKey 校验之前,攻击者不需要任何凭据就能消耗配额。
攻击场景:
/auth/adminLogin 以任意 managementKey="x" 但 username=admin 打 10 次(单 IP 一分钟内即可完成,AdminLoginRateLimit 是 IP 60s/20)。429。admin、admin_{code})可被无凭据持续 DoS 锁登录入口;应急响应、事件处置期间超管无法登录管理后台。攻击成本极低,IP 级限流(20/min)足以完成锁定。
修复方案:
把 UsernameLoginLimit.Take 移动到 ManagementKey 校验之后、用户名/密码校验之前:
if subtle.ConstantTimeCompare([]byte(req.ManagementKey), []byte(l.svcCtx.Config.Auth.ManagementKey)) != 1 {
return nil, response.ErrUnauthorized("managementKey无效")
}
if l.svcCtx.UsernameLoginLimit != nil {
code, _ := l.svcCtx.UsernameLoginLimit.Take(req.Username)
...
}
ip+username 混合维度(推荐独立新桶),避免单个攻击者永久封锁真实用户。ValidateProductLogin(loginService.go:33-38)存在同样的账号锁定 DoS(无凭据即可锁任意用户名),推荐也改用 ip+username 混合 key 或增加可绕过的 CAPTCHA 机制。internal/logic/member/removeMemberLogic.go:29-53internal/logic/member/updateMemberLogic.go:30-64RemoveMember 的访问控制只有 CheckManageAccess(caller, member.UserId, member.ProductCode);CheckManageAccess 里 if caller.UserId == targetUserId { return nil } 允许自删除,if caller.MemberType == ADMIN { return nil }(checkDeptHierarchy)后续权限级别比较又用 callerPri < targetPri 放行——也就是 任意一个 ADMIN 可以把另一个 ADMIN(或者自己)移出产品;UpdateMember 可以把 ADMIN 降级为 MEMBER,同样没有"最后一个 ADMIN"检查。假设产品 P 最初由 CreateProduct 自动生成 admin_P(ADMIN)加入。这个 admin_P 之后通过 AddMember 邀请了 admin_Q(ADMIN)。两人随后:
UpdateMember(admin_P → MEMBER) 或直接 RemoveMember(admin_P);RemoveMember(admin_Q)。产品 P 从此没有任何 ADMIN。前端路径无法再新增管理员(AddMember 需要 ADMIN 或 SUPER_ADMIN 操作;CheckMemberTypeAssignment 对新 ADMIN 又要求操作者是更高级别)。虽然超管可以通过 AddMember 介入,但该场景下产品运营已经 必须依赖平台管理员介入,违背了"产品自治"的设计意图。
类似的,admin_P 可以不小心把自己降级为 MEMBER(UpdateMember 允许),产品立刻失去管理员。
影响:
修复方案:在 RemoveMember 与 UpdateMember(降级时)增加"最后 ADMIN"保护:
// 伪代码
if member.MemberType == consts.MemberTypeAdmin &&
(operation == Remove || (operation == Update && req.MemberType != ADMIN)) {
adminCount, _ := svcCtx.SysProductMemberModel.CountActiveAdmins(ctx, member.ProductCode)
if adminCount <= 1 {
return response.ErrBadRequest("不能移除/降级该产品的最后一个管理员")
}
}
需要在 model 层新增 CountByProductCodeAndMemberType(productCode, MemberTypeAdmin, StatusEnabled)。同时禁止管理员对自己的 MemberType 做降级(对比 UpdateUserStatus 已有的"不能修改自己的状态")。
internal/handler/routes.go 路由清单、internal/middleware/jwtauthMiddleware.go/auth/login / /auth/adminLogin)、刷新 (/auth/refreshToken)、改密 (/auth/changePassword) 接口。没有任何一个接口会主动 tokenVersion+1(除 UpdatePassword / UpdateStatus),也没有 /auth/logout 路由。后果:
用户怀疑 token 被盗时,唯一的自救手段是修改密码(ChangePassword 会 tokenVersion+1 并 Clean 缓存)。对使用 SSO、或不自己设密码(例如 OAuth 接入)的场景不可用。
建议:
/auth/logout 接口:鉴权后将该用户的 tokenVersion+1(或者维护一个"签出黑名单"集合,带 TTL 到 RefreshExpire)。RevokeTokens(userId, productCode)。internal/logic/pub/refreshTokenLogic.go:70-77、internal/server/permserver.go:141-148GenerateRefreshTokenWithExpiry 的行为是"基于 claims 里原 token 的 ExpiresAt 重新签一张新 token"——此时 tokenVersion 并未递增,新旧两张 refreshToken 都用同一个 tokenVersion 命中 tokenVersion == ud.TokenVersion 的校验。如果攻击者偷到 refreshToken 的同时、真实用户也在使用:
tokenVersion 相同,两边都能继续无限续期到 refreshExpire。标准的 refresh token rotation 语义应当是"已使用的 refresh token 立即一次性失效"(通过 jti 黑名单、或者每次刷新都让 tokenVersion 递增)。当前实现不具备这个能力。
影响:refreshToken 泄露后几乎无法挽回,直到 refreshExpire 自然过期,或用户主动改密。
建议:
sys_user.tokenVersion 递增,使老 refresh token 立即失效。缺点是多端登录无法共存。refreshJti → userId 的一次性 map,RefreshToken 时 GETDEL,不存在即失败。jti 存入 RegisteredClaims。internal/logic/product/createProductLogic.go:80、157-163描述:
func generateRandomHex(length int) (string, error) {
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf(...)
}
return hex.EncodeToString(b)[:length], nil
}
rand.Read 填充 length 字节,hex.EncodeToString 产生 2*length 个 hex 字符,随后截断取前 length 个字符——也就是实际只保留了前 length/2 字节的随机性,相当于 4*length bits。
generateRandomHex(32) appKey:实际 128 bits,OK。generateRandomHex(64) appSecret:实际 256 bits,OK。generateRandomHex(8) adminPassword:只有 32 bits ≈ 4e9 种可能。虽然 MustChangePassword=Yes 会强制首登改密,但这个一次性密码在超管拿到之后到管理员首次登录之前的窗口内暴力破解可达。依赖外层 UsernameLoginLimit(5min/10 次)间接保护并不健壮。
建议:修正截断边界或直接提高长度:
func generateRandomHex(length int) (string, error) {
byteLen := (length + 1) / 2
b := make([]byte, byteLen)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b)[:length], nil
}
同时 adminPassword 改为 generateRandomHex(16)(64 bits)起步。
DeptTree 对所有登录用户开放,暴露完整组织架构internal/logic/dept/deptTreeLogic.go、internal/handler/routes.go:42-69/api/dept/tree 只挂了 JwtAuth。DeptTreeLogic.DeptTree 自身完全不做权限过滤,任意 JWT 通过的用户(包括任意产品的普通 MEMBER)都能拉到全公司组织架构(包括 deptType=DEV、部门名称、层级结构、备注)。strings.HasPrefix(d.Path, caller.DeptPath))。ProductList / ProductDetail 对所有登录用户返回产品元数据internal/logic/product/productListLogic.go、internal/logic/product/productDetailLogic.gocode、name、status、remark、createTime)。虽然 AppKey 只对超管返回,但产品清单本身对所有成员可见。跨产品用户可以探测出系统内其他产品的存在(例如"内部管理后台"、"支付中心")。sys_product_member 过滤)。ProductDetail 增加同样的归属校验:非超管只能看自己所在产品。internal/logic/pub/loginService.go:33-38UsernameLoginLimit.Take(username) 在无任何前置鉴权的情况下被消费,5min/10 次、纯 username 维度。任何外部攻击者只需知道目标用户名,即可以 10 次失败请求锁定该账号 5 分钟;通过定时重放可达到长时间账号级 DoS。ip:username(或对同一 IP 的失败次数独立设桶);对成功登录重置该 username 的计数。X-Real-IP 信任策略过简,未支持 X-Forwarded-Forinternal/middleware/ratelimitMiddleware.go:41-52X-Real-IP,没有兼容多数 ingress / ELB 默认设置的 X-Forwarded-For。behindProxy=true,任何 X-Real-IP 无条件被信任;如果反向代理没有覆盖客户端原有 header,攻击者可以通过伪造 header 分散限流桶。RemoteAddr 在可信网段内时才信任 header。XFF(可信时)> X-Real-IP(可信时)> RemoteAddr。Clean/Del/BatchDel 失败时静默吞错UpdateUser、UpdateUserStatus、ChangePassword、BindRoles、SetUserPerms、UpdateMember、RemoveMember、DeleteRole、BindRolePerms、UpdateRole、UpdateProduct、ExecuteSyncPerms、UpdateDeptinternal/loaders/userDetailsLoader.go:138-173Del / Clean / BatchDel 内部的 Redis 错误只打日志。Redis 瞬时抖动期间,DB 已提交但缓存未失效,在 defaultCacheTTL=300s 之内其他请求命中旧缓存,包括 tokenVersion / MemberType / Perms 等安全关键字段。
UpdateUserStatus 冻结、ChangePassword、RemoveMember、UpdateMember Status=Disabled),Clean 失败必须返回 5xx,或把这类 key 的 TTL 收紧到 60s。BindRolePerms / UpdateRole / DeleteRole 的"受影响用户查询"发生在事务之外,存在漏清缓存的窗口internal/logic/role/bindRolePermsLogic.go:126-127internal/logic/role/deleteRoleLogic.go:39-53internal/logic/role/updateRoleLogic.go:66-67FindUserIdsByRoleId → 事务内写 DB → 事务外 BatchDel"。在事务开始后、事务提交前,若有另一个 goroutine 通过 BindRoles 把新用户加进这个角色(sys_user_role 插入并已提交),当前 goroutine 计算 affectedUserIds 时没有包含这些新用户。
BindRoles 流程会对它自己绑的用户 Clean,但不会感知到本流程对角色权限的改动。虽然是低概率双写,但对"删除角色"这种一次性收紧操作,未清掉的那一批用户仍会基于"已删除角色下的权限集"工作(实际上,一旦事务提交,sys_role_perm 清空,loadPerms 的 role path 自然会走 FindPermIdsByRoleIds 得空——但缓存是上次的;只有下次 miss 才会触发)。本质是缓存永远滞后于 5 分钟。
FindUserIdsByRoleId 放进事务内,并使用 SELECT ... FOR UPDATE 锁住这些用户的绑定关系,避免并发新增;或在事务提交后再 FindUserIdsByRoleId 一次(更简单)——这样保证看到的是最新的用户集: // 事务提交成功之后
affectedUserIds, _ := l.svcCtx.SysUserRoleModel.FindUserIdsByRoleId(l.ctx, req.RoleId)
l.svcCtx.UserDetailsLoader.BatchDel(l.ctx, affectedUserIds, role.ProductCode)
当前是在事务之前拿的,移到事务之后即可显著减少竞态。
FindByPathPrefix LIKE 转义依赖 MySQL 默认 \ 转义internal/model/dept/sysDeptModel.go:56-64strings.NewReplacer("%", "\\%", "_", "\\_").Replace(pathPrefix) 产出的是 /xxx/\%yyy/,在 SQL WHERE path LIKE ? 下默认依赖 MySQL 的 \ 作为 LIKE 转义符。当 sql_mode 含 NO_BACKSLASH_ESCAPES 时,\% 会被当作两个字符,匹配失败或命中预期外数据。此函数在当前产线代码中已无调用方(见 M-11 僵尸代码),但为了避免将来复用踩坑,建议显式 LIKE ? ESCAPE '!' 并在应用层用 ! 作为转义符。
建议:
escaped := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_").Replace(pathPrefix)
query := fmt.Sprintf("SELECT ... WHERE `path` LIKE ? ESCAPE '!' ORDER BY ...", ...)
FindByPathPrefix / FindByParentId / FindRoleIdsByUserId 仅测试引用internal/model/dept/sysDeptModel.go:47-64(FindByParentId、FindByPathPrefix)internal/model/userrole/sysUserRoleModel.go:37-44(FindRoleIdsByUserId,UserDetailLogic.userDetailLogic.go:55 理论上会调用,但看下面)FindByPathPrefix 和 FindByParentId:自上一轮 DeleteDept 改为行锁 + 子查询后,两者在生产代码中没有任何调用方,仅测试/mock 保留。FindRoleIdsByUserId(全产品汇总):在 userDetailLogic.go:55 的 else 分支调用("没有 productCode 上下文时")。该分支仅在超管登录管理后台且未带产品上下文时进入;但前端在"用户详情"页面一般会带产品上下文。调用路径存在但极少。建议清理 FindByPathPrefix / FindByParentId(或保留但加注释,避免重复发明轮子)。FindRoleIdsByUserId 仍有路径保留。
FindByPathPrefix / FindByParentId,或至少加 // Deprecated 注释。FindRoleIdsByUserId 保留。UpdateUserStatus 的 productCode 归属校验与超管路径重复internal/logic/user/updateUserStatusLogic.go:49-60go
if productCode != "" {
caller := middleware.GetUserDetails(l.ctx)
if caller != nil && !caller.IsSuperAdmin {
if _, err := svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(...); err != nil {
return response.ErrBadRequest("目标用户不是当前产品的成员")
}
}
}
if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.Id, productCode); err != nil {
return err
}
CheckManageAccess 内部已经对"非超管且同一产品下非成员"做了 checkPermLevel 的目标成员检查(会返回 "目标用户不是当前产品的成员,无法执行管理操作")。外层这段手动检查在非超管场景下和内部检查重复,且多了一次 FindOneByProductCodeUserId DB 查询。
功能上没问题,但多一次 DB 查询且逻辑位置分散。
建议:删掉外层的重复校验,全部交给 CheckManageAccess。
UpdateUser 自修改时能"显式传 Status=0 + 非 nil DeptId"通过第一层但被 FindOne 后再补判internal/logic/user/updateUserLogic.go:39-59go
if caller.UserId == req.Id {
if req.DeptId != nil || req.Status != 0 {
return 403
}
} else {
if err := CheckManageAccess(...); err != nil { return err }
}
user, _ := FindOne(req.Id) // 再查一次
if caller.UserId != req.Id && user.IsSuperAdmin == Yes {
if req.Status != 0 || req.DeptId != nil { return 403 }
}
- 自修改场景下:如果传 DeptId=&0(指针非 nil,值为 0),第一层 req.DeptId != nil 直接拦下,正确。
- 他人修改超管场景下:第二道防线阻断 Status != 0 || DeptId != nil。OK。
但是一个隐含风险:他人修改普通用户场景下,如果 caller 通过 UpdateUser 改对方 DeptId,没有校验 caller 对"新目标部门"的权限。例如普通 ADMIN(部门 A)可以把用户 U 从部门 X 挪到部门 Y,哪怕 Y 不在 ADMIN 的管辖范围。CheckManageAccess 只校验 caller 能否管理 U(在 caller 自己的部门子树内),不校验新 DeptId 是否合法。
对 ADMIN 的判定:ADMIN 在 checkDeptHierarchy 里直接放行(第 101-103 行),所以 ADMIN 可以跨部门挪用户,这是设计意图。但 MemberType=DEVELOPER 或无角色的 MEMBER 不会走到这里(会被更早拦下)。
结论:目前行为是 ADMIN 可跨部门分配,正常;MEMBER/DEVELOPER 不会触发这个路径。但如果未来放宽 checkDeptHierarchy,需要补这层目标部门校验。
- 建议(低优):把"变更 DeptId 时,校验目标部门在 caller 的可管理范围或 caller 是超管/ADMIN"独立出来,避免后续逻辑回归。
---
### M-14. setUserPermsLogic 对已被禁用的权限直接拒绝,但缺少对"产品已被禁用"的校验
- 位置:internal/logic/user/setUserPermsLogic.go
- 描述:只校验 perm.ProductCode == productCode && perm.Status == Enabled,没有校验该产品本身是否被禁用。结合 H-1,管理员在产品被禁用后依然能对该产品的成员设置权限。
- 影响:与 H-1 同源,一旦 H-1 修复(loadPerms 感知产品状态),这里的写入仍然合法;建议一并在管理面增加 product.Status == Enabled 的前置校验,作为防御纵深。
---
### M-15. BindRoles 对"重复绑定同一角色"请求,toAdd/toRemove 都为空时直接 return nil,不同步缓存
- 位置:internal/logic/user/bindRolesLogic.go:114-116
- 描述:当请求的 req.RoleIds 与数据库现状完全相等时,直接 return。这是正确的优化,但它意味着:
- 如果由于缓存异常,UserDetails.Roles 在 Redis 中是"失效但未清"的错值(例如上一次写入失败),调用 BindRoles 做一次"无改动 upsert"不会触发缓存清理。
- 影响:极低,仅在"上次 Clean 失败 + 当前调用无 diff"的联合场景下出现,属于灰度 / 降级运维场景。
- 建议:保留优化,但在 return nil 之前仍然做一次 UserDetailsLoader.Clean(l.ctx, req.UserId),确保本次调用语义是"写读一致"。
---
### M-16. loadPerms 普通成员分支对"用户附加 ALLOW / DENY"未过滤权限启用状态
- 位置:internal/loaders/userDetailsLoader.go:380-405
- 描述:FindPermIdsByUserIdAndEffectForProduct 仅按 sys_user_perm.effect + productCode 过滤,未过滤 sys_perm.status = 1。最终在第 412-422 行通过 FindByIds + p.Status == Enabled 过滤已禁用的 perm code,再投入 ud.Perms。
- 功能上正确:禁用的 perm 不会进入最终 codes。
- 但每次 Load 都白白查询禁用的 permId,多传一趟到 FindByIds。
- 建议:FindPermIdsByUserIdAndEffectForProduct 的 SQL 加 AND p.status = 1(对齐 FindRoleIdsByUserIdForProduct 已加的 r.status = 1)。同时 FindPermIdsByRoleIds 也可以加 INNER JOIN sys_perm p ON rp.permId = p.id AND p.status = 1。
---
## 📝 低风险 / 遗留问题 (Low)
### L-1. 响应永远 HTTP 200,业务错误通过 body.code 区分
- 位置:internal/response/response.go:45-52
- 描述:所有业务错误返回 HTTP 200 + body.code=4xx/5xx。这让部分 WAF、CDN、监控工具(期望基于 HTTP 状态码告警)失效。属于已知的 API 契约选择,保留一致性即可,但建议在对外文档中明确。
---
### L-2. 敏感配置明文提交到仓库
- 位置:etc/perm-api-*.yaml
- 描述:MySQL/Redis 密码、AccessSecret、RefreshSecret、ManagementKey 等明文存在;即便后续轮换,历史 commit 仍可追溯。建议改用环境变量或密钥管理服务注入。
---
### L-3. UpdateRole 允许产品 ADMIN 任意下调 permsLevel 到 1
- 位置:internal/logic/role/updateRoleLogic.go:47-48
- 描述:产品 ADMIN 可以把一个原本 permsLevel=500 的角色改为 permsLevel=1,然后把它绑给普通 MEMBER,使其 MinPermsLevel=1,进而绕过 checkPermLevel 对"同级 MEMBER"的级别约束。由于 ADMIN 本身已是产品最高级别,这并没有扩展其能力范围;但它让普通 MEMBER 能"管理与自己同 MemberType 的更多用户"。
- 建议:要求 permsLevel 的修改必须是超管;或要求新 permsLevel >= 原 permsLevel(单调不递减)。
---
### L-4. CreateRole 未校验 req.ProductCode 是否存在 / 启用
- 位置:internal/logic/role/createRoleLogic.go:33-50
- 描述:仅通过 RequireProductAdminFor(req.ProductCode) 间接校验(超管或该产品 ADMIN)。但如果超管误传 productCode="not_exist",会插入一条挂在无效产品下的角色;该角色因产品不存在不会被任何人使用,但也不会报错。
- 建议:插入前 FindOneByCode(req.ProductCode) 校验存在且 Status == Enabled。
---
### L-5. AddMember 未校验产品启用状态
- 位置:internal/logic/member/addMemberLogic.go:33-34
- 描述:只校验产品存在,不校验 Status == Enabled。管理员可以在产品已被禁用的情况下继续加成员。虽然被禁用的产品理论上也不应再有新成员流入,但当前不阻断。
- 建议:增加 if product.Status != consts.StatusEnabled { return 400 "产品已被禁用" }。
---
### L-6. UserDetailLogic 对"非超管 + productCode==='' + 查他人"的校验语义模糊
- 位置:internal/logic/user/userDetailLogic.go:33-43
- 描述:逻辑上 "非超管 + 无 productCode + 查自己 → OK / 查他人 → 拒绝"。但对 ADMIN/DEVELOPER 而言,他们的 JWT productCode 不会为空;此分支只可能被"非超管 + 无 productCode"触发,当前系统里几乎只有"超管通过 adminLogin 登录"这一路径。
- 换言之这段 if caller.ProductCode == "" 分支只对"超管自身"有意义,但条件已显式排除超管。形同死代码分支——超管走不到,其它用户也不会 ProductCode==""。
- 建议:要么完全删除这段分支,要么明确写成 if !caller.IsSuperAdmin && caller.ProductCode == "" { return 401 "会话缺少产品上下文" },表达"产品端一定有 productCode"的不变式。
---
### L-7. singleflight 在 Load 失败时返回零值 UserDetails 而非 nil
- 位置:internal/loaders/userDetailsLoader.go:116-134
- 描述:sf.Do 回调在 loadFromDB 返回 ok=false 时返回 (nil, nil),外层做了兜底 return &UserDetails{UserId, ProductCode}。调用方靠 ud.Username == "" 判断"用户不存在"。
- 语义上"查不到用户"和"DB 报错"无法区分;
- 所有 caller 都按 ud.Username == "" 判定,耦合在这个不变式上。
- 建议:保留现有接口,但内部把 "DB error" 与 "不存在" 分开传递,对 5xx 让上层正确返回 500;同时在 LoadE(新增)接口里返回 (*UserDetails, error),旧 Load 保持兼容。
---
### L-8. gRPC Login 的 IP 提取依赖 peer.Addr,不识别 XFF
- 位置:internal/server/permserver.go:60-72
- 描述:GrpcLoginLimiter 的 key 用 peer.Addr。如果 gRPC 入口前面有 gateway/proxy,所有请求的 peer.Addr 都是 gateway IP,限流变成"全局 20/min"。不过一般 gRPC 不会直接对外暴露,风险低。
- 建议:若 gRPC 会走网关,应在 metadata 中带上真实客户端 IP(如 x-real-ip),取 metadata 作为 key。
---
### L-9. BindRoles 对 req.RoleIds = [] 的语义是"清空所有绑定",但缺少显式确认
- 位置:internal/logic/user/bindRolesLogic.go:48-58
- 描述:传空数组时,toAdd=[]、toRemove=existing,流程会在事务里删光用户在该产品下的所有角色绑定。该语义合理(前端做全量覆盖),但没有任何二次确认或显式参数(clearAll bool),容易在前端误传 [] 时造成"误删"。
- 建议:在请求体中增加 Intent: "replace" | "append" 区分,或前端传 null/omitempty 时禁止清空。可选的 API 契约强化。
---
### L-10. productmember 的 FindOneByProductCodeUserId 没有按 status 过滤
- 位置:model 层(sys_product_member 的 cached 查询)
- 描述:loadMembership 在拿到 member 后校验 member.Status == Enabled;bindRolesLogic.go:44、setUserPermsLogic.go:44、updateUserStatusLogic.go:53 等业务层都只检查"是否存在成员记录",没有再检查成员状态。
- bindRoles 给"已被禁用的成员"重新绑角色:数据上写入,但由于 jwtauthMiddleware 不校验 MemberType(见 H-2),会和 H-2 联动放大影响。
- setUserPerms 同理。
- 建议:在这些业务校验处把 FindOneByProductCodeUserId 的结果加 member.Status == Enabled 判断,明确拒绝对已禁用成员的权限操作。
---
## 📋 审计总结
| 维度 | 评估 |
|------|------|
| 逻辑一致性 | 新发现:产品禁用未联动 token 失效(H-1)、HTTP 中间件不校验禁用成员(H-2)、最后 ADMIN 保护缺失(H-4)。 |
| 并发与竞态 | BindRolePerms/DeleteRole/UpdateRole 的"受影响用户"查询发生在事务外,存在并发缺漏(M-9)。其它关键写入已有乐观锁或 FOR UPDATE。 |
| 资源管理 | go-zero TransactCtx 使用规范,sqlx 与 Redis 连接由池管理;未见泄漏。 |
| 数据完整性 | 核心写路径(createProduct、bindRoles、bindRolePerms、removeMember、deleteRole、syncPerms)均在事务内,缓存失效为 fire-and-forget(M-8)。 |
| 安全漏洞 | 产品禁用失效(H-1)、成员禁用不生效于 HTTP 路径(H-2)、管理后台账号锁定 DoS(H-3)、最后 ADMIN 失守(H-4)、产品端账号 DoS(M-6)、adminPassword 熵不足(M-3)、DeptTree / ProductList 暴露过度(M-4、M-5)。 |
| 边界处理 | nil / 空串 / 可选字段(指针)处理普遍得当;UserDetails 零值语义仍依赖 Username == "" 约定(L-7)。 |
| DB 性能 | BindRoles / BindRolePerms / role 删除路径已批量化;其它列表接口采用"批量 IN + map 拼装",无 N+1。loadPerms 的 role/user perm 查询可加 p.status=1 减少无效数据(M-16)。 |
| 僵尸代码 | SysDeptModel.FindByPathPrefix / FindByParentId 仅测试引用(M-11)。Claims.Perms 已清理、FindRoleIdsByUserId 仍有调用路径。 |
| 接口契约与对象完整性 | UserDetails 缺 ProductStatus 字段(H-1 所需);UpdateUserStatus 有重复归属校验(M-12)。 |
### 修复优先级建议
1. 立即修复(P0)
- H-1 产品禁用不生效:加 ProductStatus 字段,loadPerms / JwtAuth / GetUserPerms / VerifyToken 统一校验,必要时在 UpdateProduct 递增成员的 tokenVersion。
- H-2 中间件不校验 MemberType:与 RefreshToken 对齐。
- H-3 AdminLogin DoS:ManagementKey 校验前置,rate limit key 加 IP 维度。
- H-4 最后 ADMIN 保护:RemoveMember / UpdateMember 增加 adminCount 前置校验。
2. 短期修复(P1)
- M-1 无注销接口 → 补 /auth/logout。
- M-2 refreshToken 轮转应让旧 token 失效。
- M-3 generateRandomHex 截断 bug + adminPassword 长度提升。
- M-4 DeptTree 权限过滤。
- M-5 ProductList/Detail 权限过滤。
- M-6 产品登录账号锁定 DoS(与 H-3 同步)。
- M-8 缓存失效原子性补偿(最高优先保障"收紧"类安全操作)。
- M-9 事务外用户集查询移到事务后。
3. 中期修复(P2)
- 其它 M/L 级条目(XFF 支持、LIKE 转义、僵尸代码清理、校验冗余去重、权限查询 status=1 过滤)。