Files
full/docs/audit/module-base-mgt.md
yanweidong 63aeedc2fe docs: add per-module audit reports (18 modules)
Add static security/quality audit reports for all 18 Go service modules under module/, plus a consolidated index (docs/audit/README.md) with per-module statistics, top risks, cross-module systemic defects and a phased TODO list (T1-T19). No production code is modified.
2026-09-14 22:16:27 +08:00

62 KiB
Raw Permalink Blame History

审计报告module/base/mgt

1. 模块概览

module/base/mgt 是后管基础模块(服务名 mgt),承载后台管理的登录、用户、应用、角色、权限、部门六大子域,对外暴露 Gin REST 接口,前缀 /rest/mgtcmd/main/main.go:25 + internal/routers/register.go:21)。

  • 技术栈Gin v1.12.0 + GORM v1.31.2MySQL/PostgreSQL+ Redis + gin-contrib/sessions cookie store + etcdgo.mod:5-14)。
  • 鉴权模型JWTHS256Authorization 头)+ SDK middleware.JwtAuth(true)(校验过期)+ 本模块自定义的 mgtmw.RequireAdmin()仅要求「root 账号或名为『超级管理员』的角色」)。没有基于权限点permission code的接口级鉴权
  • 分层:cmd/main(进程入口)→ internal/routers(路由)→ internal/logic/*(业务)→ internal/modelsGORM 模型 + 自动迁移 + root 种子数据)→ internal/implRedis/DB/etcd/go-cache 全局单例)。
  • 3 个 etc/*.yaml 配置dev/test/prodprod 的 DB/Redis/SecretKey 均为 CHANGE_ME 占位符。
  • 规模78 个非 pb Go 文件、约 5269 行(与任务描述一致);internal/logic 下 7 个子包,internal/models 下 12 个模型 + 8 张关联表。

关键风险画像:默认口令 + 默认 JWT 密钥构成「开箱即被接管」的完整链路;写接口只做「超级管理员」粗粒度校验,任何拥有该角色的人可对任意用户/角色/部门全量赋权且无审计;三处 Updates+Select 会把请求中省略的字段写成零值(含 account/phone/code)。

2. 审计范围与方法

2.1 覆盖方式

先侦察全部非 pb Go 文件、etc 配置、cmd/maininternal/routers/register.go 的完整路由表,再以「路由 → 中间件 → logic 写操作」为主线逐条核对,最后核对 SDK 侧依赖(D:\work\bsm-sdk\corego.mod:84 的 replace 指向该本地目录)、internal/models/init_db.go 种子数据、GORM v1.31.2 更新语义(读源码 + 隔离环境 DryRun 实测)。

2.2 命令执行结果

命令 结果
gofmt -l .(在 module/base/mgt 无输出exit 0格式干净
go vet ./...(在 module/base/mgt 无输出exit 0无 vet 告警)
GORM 零值更新语义实测 在仓库外临时目录 D:\work\bsm-infra\full\.builds\_tmp_gorm_probe(已删除,未触碰任何被审计文件)对 gorm v1.31.2 打桩 DryRun 实测,用于问题 3 / 问题 4TODO 编号 P1-3 / P1-4的判定
GORM 删除钩子语义核对 直接阅读本地 module cache 的 gorm.io/gorm@v1.31.2\callbacks\{delete.go,callmethod.go,update.go}statement.go 源码,用于 P2-17 与 P1-3/P1-4 的判定(含 SelectAndOmitColumnsConvertToAssignments 的零值分支)
网关/gRPC 适用性核对 `Select-String "internal/server
命名返回值/裸 return 核对 逐函数核对本模块命名返回值与裸 return(唯一命中 internal/models/impl.go:37,47,已确认不会吞错),见 2.5 附注

未执行 go build/go test(会写 go.sum/构建缓存,且非本任务要求)。

2.3 已覆盖子域

  • pub(登录/刷新/重置密码):login.gorefresh.goforget.go 全文。
  • middlewarerbac.go 全文(RequireAdmin/IsSuperAdmin/EnsureSelfOrAdmin)。
  • modelsimpl.goinit_db.gouser.gorole.gopermission.goapplication.godepartment.go、全部 8 张 link_*.goquery.go(空文件)。
  • usercreate.godel.godetail.gofetch.golist.gomodify.goset_role.goset_pmn.gofetch_app.gofetch_pmn.gofetch_role.go 全文。
  • rolecreate.godel.godetail.gofetch.gofetch_other.gomodify.goset.go 全文。
  • permissioncreate.godel.godetail.gofetch.gofetch_other.gomodify.gosort.go 全文。
  • departmentcreate.godel.godetail.gofetch.gofetch_pmn.gofetch_pmn_tree.gofetch_tree.golist.gomodify.goset_pmn.gouser.go 全文。
  • applicationcreate.godel.godetail.gofetch.gofetch_other.gomodify.go 全文。
  • 其他cmd/main/main.gointernal/config/config.gointernal/impl/{impl,with}.gointernal/libs/validator.gointernal/types/{req,resp,types,vars}.gointernal/logic/{hello,tool}service/{expose,dependencies}.goetc/mgt_{dev,test,prod}.yamldoc/*.md7 篇)、test/*.http54 个)、internal/routers/register_test.go

2.4 未覆盖 / 无法验证

  • 未做动态验证:无可用 DB/Redis 实例,所有越权、爆破、数据损坏结论均为静态代码推演GORM 零值语义除外(已 DryRun 实测)。
  • 未审计 SDK 全量:仅按需审计了 core/middleware/{jwt,cors}.gocore/crypto/token/jwt.gocore/env/env.gocore/service/meta.gocore/types/db.gocore/vars/{jwt,status}.go,未覆盖 SDK 的 DB/Redis 连接、infra.Response 序列化、printer 日志落盘等实现细节。
  • 未审计跨模块调用方module/all 如何挂载 service.Expose(是否叠加网关级限流/鉴权)、module/base/sender 如何写入短信验证码键(仅确认共用前缀 /SMS/Code/)。
  • 未逐条核对 54 个 test/*.http:仅确认其为请求样例、无断言。
  • 前端/网关bsm-infra/gateway 是否对 /rest/mgt 做额外保护、是否强依赖 BSM_JwtSecretKey 环境变量,未验证(无法从本仓库确认部署态环境变量)。

2.5 关于「internal/server 只注册 gRPC 导致 HTTP 网关不可用」这一跨模块模板缺陷:本模块不适用

该缺陷不适用于 mgt,无需为本模块计入风险。依据:

  1. mgt 是纯 Gin HTTP 模块,没有任何 gRPC/网关/protobuf 代码——全模块 Select-String "internal/server|gwRuntime|NewServeMux|RegisterHandlerServer|Gateway" 零命中,且 module/base/mgt不存在任何 .pb.go.proto 文件
  2. 独立部署入口自建 *gin.Engine 并直接注册路由:cmd/main/main.go:32-42app := gin.Default()routers.Register(ServiceKey, app)),全程不经过 gRPC Mux。
  3. service/expose.go:17-23func Expose(options ExposeOptions) error 在本仓库内无任何调用方(全模块仅此定义处命中),属于供外部宿主使用的可选入口;它同样只是把 options.Engine 交给 routers.Register,不涉及 gRPC 网关。因此该全局模板缺陷对本模块既不影响部署可用性,也不构成独立风险。

附:本模块未发现「命名返回值未赋值 → 恒返回 nil」类缺陷。唯一使用命名返回值 + 裸 return 的位置是 internal/models/impl.go:37func New(...) (err error))的 :47,其 errswitch 各分支已被 :42/:44 赋值,且 :46log.Fatalln 不会返回,故裸 return 不会吞错;其余函数均为显式返回值(gofmt -l/go vet ./... 亦无告警)。

3. 问题清单

P0

1. root 默认口令 123456 硬编码并在启动时自动播种README 审计记录已证实仍存在)

  • 位置module/base/mgt/internal/models/init_db.go:15-17:60;配置开关 module/base/mgt/etc/mgt_dev.yamlInitRootUser: true);触发点 module/base/mgt/internal/models/impl.go:69-71;文档 module/base/mgt/README.md:168-172module/base/mgt/test/login.http:5-6
  • 证据
    // internal/models/init_db.go:14-17
    salt := utils.UUID()
    account := "root"
    password := "123456"
    hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password+salt), bcrypt.DefaultCost)
    
    // internal/models/impl.go:69-71  —— AutoMigrate 后紧跟 root 播种
    if config.InitRootUserEnabled() {
        InitRootUserData()
    
    # etc/mgt_dev.yaml
    InitRootUser: true
    
    <!-- README.md:169-172 -->
    - **账号**: `root`
    - **密码**: `123456`
    > ⚠️ **安全提示**: 生产环境请务必修改默认密码!
    
  • 影响:任何能访问 /rest/mgt/login 的人知道口令即可登录 rootIsSuperAdminaccount == "root" 直接返回 trueinternal/middleware/rbac.go:24-26),因此默认口令一条请求即等于全系统最高权限(可改任意用户密码、赋任意权限、删除任意应用/部门)。etc/mgt_dev.yaml 已将开关打开若该文件被用于任何可达网络的环境README 的快速开始正是让开发者照此配置),即为可被直接利用的接管漏洞。
  • 建议:删除硬编码口令,改为「首次启动从环境变量/secret 读取初始口令,缺失则随机生成 24 位并一次性打印到 stdout 且标记强制改密」;InitRootUserData 增加「已初始化则跳过」之外的二次确认;把 README.md/test/login.http 中的明文口令改为占位符。

2. JWT 密钥存在硬编码默认值 Cblocksmesh2022C,可离线伪造任意身份(含 root

  • 位置SDK D:\work\bsm-sdk\core\env\env.go:19;本模块签发与校验均取该值(module/base/mgt/internal/logic/pub/login.go:90internal/logic/pub/refresh.go:28go.mod:84 将 SDK 指向本地目录 ../../../../../bsm-sdk/core
  • 证据
    // D:\work\bsm-sdk\core\env\env.go:17-22
    Runtime = &types.RuntimeEnv{
        Workspace:    GetEnvDefault("BSM_Workspace", "default"),
        JwtSecretKey: GetEnvDefault("BSM_JwtSecretKey", "Cblocksmesh2022C"),
    
    // internal/logic/pub/login.go:90
    resp.Token, err = token.New(env.Runtime.JwtSecretKey).GenerateJwt(uint(user.ID), user.Identity, c.ClientIP(), "", nil, extend)
    
    默认值长度恰为 16 字节,通过 core/crypto/token/jwt.go:34-37keyLen == 16 || 24 || 32 校验,因此在未设置 BSM_JwtSecretKey 的部署里签发与验签都会静默使用这个公开字符串
  • 影响:攻击者用公开默认密钥自行 HS256 签名一个 {"id":1,...} 的 token 即可通过 JwtAuth(true) 的过期校验与验签,被 RequireAdmin 认定为 rootID=1etc/*.yaml 里的 SecretKey: CHANGE_ME 与 JWT 无关(仅用于 session key 拼接),运维极易忽略该环境变量。此外 SDK 解析器不校验 alg 白名单,一旦密钥泄露也无法通过轮换密钥快速止损(无 kid/版本)。
  • 建议SDK 侧改为「未显式配置即 log.Fatal 退出」;mgtconfig.New 中显式校验 env.Runtime.JwtSecretKey 长度 ≥32 且不等于内置默认值,否则拒绝启动;签发时使用 jwt.WithValidMethods([]string{"HS256"})

P1

3. user/modifySelect+结构体更新,请求中省略的字段被写成零值(禁用账号 + 擦除 account/phone/email/avatar

  • 位置module/base/mgt/internal/logic/user/modify.go:57-72(路由 POST /rest/mgt/user/modifyinternal/routers/register.go:60
  • 证据
    // user/modify.go:62-69
    userModel := models.MgtUser{ Name: request.Name, Phone: request.Phone, Email: request.Email,
        Avatar: request.Avatar, Std_IICUDS: coreTypes.Std_IICUDS{Status: request.Status} }
    updateFields := []string{"name", "phone", "email", "avatar", "status"}
    ...
    impl.DBService.Model(&models.MgtUser{}).Where("id = ?", request.ID).Select(updateFields).Updates(&userModel)
    
    隔离环境对 gorm v1.31.2 的 DryRun 实测(同构结构体 + 同 Select 列表):
    B with-select SQL: UPDATE t SET status=?,name=?,title=?,desc=? WHERE id = ?
    B with-select VARS: [0 0   t3 t3   1 1]     # 未提供的列仍被写入零值
    A no-select   SQL: UPDATE t SET title=? WHERE id = ?   # 对照组:仅更新非零字段
    
    Select 显式列名会强制写入零值。internal/types/req.go:9Status int8 无默认值,请求体不带 status 时写入 0core/types/db.go:340=未定义,-1 禁止1 正常)。
  • 影响:任何超管调用一次「只改姓名」的 POST /user/modify,就会把该用户的 accountphoneemailavatar 清空、status 置 0由于 login 只在 stat == vars.DisabledStatus(-1) 时拒绝(pub/login.go:118-123),账号不会被锁死但已无法用 account 登录、无法按手机号找回密码,且 accountuniqueIndex,第二个被清空的用户会触发唯一冲突导致更新失败。属于确定性数据损坏。
  • 建议:改用 map[string]interface{} 只装请求中显式提供的字段(配合 *int8/指针类型区分「未提供」与「零值」),或先 First 取原记录再局部赋值;同时为 modify 增加「不允许修改 root 账号」的保护。

4. permission/modify 同样以零值覆盖 code / parent_id / title / menu_path / component 等字段

  • 位置module/base/mgt/internal/logic/permission/modify.go:123-144(路由 POST /rest/mgt/pmn/modifyregister.go:109);重复分支 :67-89
  • 证据
    // permission/modify.go:140-141
    updateFields := []string{"status", "code", "title", "parent_id", "menu_path", "menu_icon", "type",
        "component", "description", "is_web_page", "is_new_tab", "is_full", "hide_menu", "web_url"}
    impl.DBService.Model(&models.MgtPermission{}).Where("id = ?", request.ID).Select(updateFields).Updates(&dataModel)
    
    request.Code 等来自 types.PermissionRequestinternal/types/req.go:57-76),全部 omitempty,省略即为零值。
  • 影响:只改标题的一次调用会把该权限的 code 清空、parent_id 归 0菜单从二级掉到顶级menu_path/component 清空,导致前端菜单整体错乱;codeuniqueIndex:idx_mgt_pmn_app_code 的联合唯一键(internal/models/permission.go:10-12),清空后同应用第二条修改即唯一冲突失败。与 P1-3 同类,合并修复。
  • 建议:同 P1-3或至少对 Code/Title 做「空则沿用原值」的显式回填。注意 role/modify.go:62application/modify.go:66department/modify.go:107 未使用 Select,实测不会写零值(对照组 A无需改动——这一点已用 DryRun 验证,勿被同类代码误导。

5. 赋权接口不校验权限与应用的归属,且放任权限跨应用/自引用成环(越权与树污染)

  • 位置module/base/mgt/internal/logic/user/set_pmn.go:33-63:90-125internal/logic/role/set.go:33-66:92-126;反例(做对了的写法)internal/logic/department/set_pmn.go:42-64
  • 证据
    // user/set_pmn.go:40-47  —— 只校验 v != 0未校验权限是否属于该 appId
    for _, v := range request.List {
        if v == 0 { ... }
        pmnData = append(pmnData, models.MgtLinkUserPmn{AppId: appId, PmnId: v, UserId: request.Id})
    
    部门侧有归属校验(可对照),用户/角色侧缺失:
    // department/set_pmn.go:51-55
    if dpt.AppID != appId {
        printer.Error("部门不属于当前应用: dpt_id=%d", request.Id)
        infra.Response.Error(c, errcode.ErrInvalidArgument)
    
    另一个环/跨应用来源:permission/create.go:71permission/modify.go:72,127 直接落库 ParentID: request.ParentID,既不校验父权限是否属于同一 appId,也不做自引用/后代检查。
  • 影响:写入 mgt_link_user_pmn{app_id=A, pmn_id=<B 应用的权限>} 这类跨应用脏关联后,user/fetch_pmn.go:210-217 的权限树按 app_id = apps[i].ID AND id IN ? 查询会查不到该权限却仍把它算作「已授权」,前端展示与后端判定长期不一致;父级成环会让 department/fetch_pmn_tree.go:109-130buildPermTreeFilter 递归不收敛(栈溢出 DoS与 P1-11 联动。
  • 建议:所有 set_pmn/modify_pmn 在写关联前用一条 SELECT id FROM mgt_permission WHERE id IN ? AND app_id = ? 校验集合完整性,数量不符即拒绝;权限创建/修改时校验 parent_id 属于同应用且不等于自身、且不在自身后代集合内(复用 department/del.go:17 的递归工具)。

6. 重置密码接口匿名可调,无验证码尝试次数限制 / 无限流 / 无验证码,且账号手机号可枚举

  • 位置module/base/mgt/internal/routers/register.go:36anonymous.POST("/reset", pub.ForgetPwdBySms));实现 internal/logic/pub/forget.go:22-99
  • 证据
    // register.go:27-38  —— 整个 anonymous 组的 JwtAuth 被注释掉
    func registerAnonymous(base string, engine *gin.Engine) {
        anonymous := engine.Group(base)
        {
            // anonymous.Use(middleware.JwtAuth())
            anonymous.GET("/ping", hello.Ping)
            anonymous.GET("/session", hello.SessionDemo)
            anonymous.POST("/login", pub.Login)
            anonymous.POST("/refresh", pub.Refresh)
            anonymous.POST("/reset", pub.ForgetPwdBySms) // 重置密码
    
    // pub/forget.go:57-73  —— 比对通过即改密;全程无尝试计数、无失败锁定、无图形验证码
    smsKey := types.KeyPrefix + request.Phone
    storedCode, err := impl.RedisService.Client.Get(impl.RedisService.Ctx, smsKey).Result()
    ...
    if storedCode != request.Code { ... infra.Response.Error(c, errcode.ErrInvalidArgument); return }
    
    账号/手机号比对失败与验证码错误的响应码不同(forget.go:51-55:69-73),可用于枚举。
    // pub/login.go:64-71  账号不存在返回 ErrRecordNotFound密码错返回 ErrPassword —— 可直接枚举账号
    
  • 影响:短信验证码为 6 位数字(doc/pub.md:123-127),本模块对同一手机号不限制尝试次数,验证码 TTL 内可爆破验证码 + 已知/枚举出的 account+phone 组合即可匿名重置任意用户(含 root的密码并接管账号/login/reset 的错误码差异使攻击者可以先枚举出合法账号与绑定手机号。注意 test/reset.http:7 使用 "code": "123456" 暗示测试环境验证码固定。边界说明验证码的生成、TTL 与下发不在本模块内(本模块只读取 types.KeyPrefix = "/SMS/Code/",与 module/base/sender/internal/logic/sms/const.go:5 同前缀,写入方为 sender因此 TTL 长度与「同一验证码可尝试次数」无法从本模块源码确证,属推测;可确证的是本模块侧没有任何尝试计数与限流代码。
  • 建议/reset/login 加 IP+账号维度限流Redis 计数 + 指数退避)、验证码错误计数(如 5 次即作废该验证码)、统一「账号/验证码错误」响应码与响应耗时;为 /reset 加图形/滑块验证码;对 root 等高权限账号强制二次校验(邮箱/OTP

7. 全部管理端接口仅以「root 或角色名为『超级管理员』」做粗粒度授权,无接口级权限点校验

  • 位置module/base/mgt/internal/middleware/rbac.go:19-54;路由挂载 internal/routers/register.go:42-43,56,72,86,104,118
  • 证据
    // rbac.go:14-16, 30-36
    const ( superAdminRoleName = "超级管理员"; rootAccount = "root" )
    ...
    for _, role := range user.Roles {
        if role.Name == superAdminRoleName { return true }
    }
    
    // rbac.go:47-51  —— 唯一判定;路由表里没有任何 per-route 权限点
    if !IsSuperAdmin(auth.ID) {
        infra.Response.Error(c, errcode.ErrPermissionDenied)
    
    internal/types/vars.go:5-6 定义了 Admin = "SYSADMIN"Staff = "STAFF",但全模块无任何引用(仅此一处定义)。
  • 影响:权限体系(mgt_permission.codemgt_link_role_pmnmgt_link_user_pmn、部门权限)只用于「界面展示哪些菜单」,不参与任何接口鉴权。任何被授予「超级管理员」角色的账号可越权访问全部 50 个管理端接口,包括给任意用户/角色赋任意权限P1-5、改任意用户密码。同时系统只支持「超管」一档权限无法实现「管理员只能管本应用/本部门」的垂直隔离。RequireAdmin 判定依赖角色名称字符串,重命名角色即失效(role/modify.go 允许改名)。
  • 建议:建立路由→权限点(permission.Code)映射中间件(如 RequirePmn("mgt:user:modify")),逐个接口挂载;将「超管」判定改为不可变标识(role.Identity 或专用 flag而非 name;补齐 Admin/Staff 或删除死常量。

8. role/del(user_id, pmn_id) 笛卡尔积删除用户直接权限 → 误撤销经其他角色获得的权限

  • 位置module/base/mgt/internal/logic/role/del.go:40-61(路由 POST /rest/mgt/role/del
  • 证据
    // role/del.go:55-61
    if len(pmnId) > 0 && len(userIds) > 0 {
        if err := tx.Where("user_id in ? and pmn_id in ?", userIds, pmnId).
            Delete(&models.MgtLinkUserPmn{}).Error; err != nil {
    
    对比 internal/logic/user/set_role.go:120-181DelRole)——那里做了「该权限是否由用户其他角色提供」的差集判断(otherPmnSet),可证明此处是遗漏而非设计。
  • 影响:删除角色 R 时,只要某用户同时拥有另一角色 R2R2 也含权限 P该用户的直接授权 (user, P) 会被一并删除,导致「不应失去的权限」丢失;同时该操作不清理部门维度(mgt_link_dpt_role)之外的应用关联,而 mgt_link_user_app 本应由 MgtLinkUserPmn.AfterDelete 钩子在「该应用下再无任何权限」时清理——该钩子在批量删除场景下以全零接收者执行、恒为空操作(详见 P2-17internal/models/link_user_pmn.go:26link_role_pmn.go:26),因此用户/角色与应用的关联会残留成孤儿,前端「我的应用」出现无权限的空应用。
  • 建议role/del 复刻 user/set_role.go 的差集逻辑,只删除「不再由任何剩余角色提供的」直接授权;把应用关联清理从 AfterDelete 钩子改为事务内显式 SQL或在批量删除后统一重算

9. /refresh 匿名可调且不校验过期时间与账号状态,配合超长有效期形成可无限续期会话

  • 位置module/base/mgt/internal/routers/register.go:35;实现 internal/logic/pub/refresh.go:21-34SDK 解析器 D:\work\bsm-sdk\core\service\meta.go:19-49core/crypto/token/jwt.go:76-98
  • 证据
    // pub/refresh.go:22-28
    claims, err := service.ParseMetaCtx(c, nil)   // opts==nil → 不做角色校验、不做 mustPrivate 校验
    ...
    token, err := token.New(env.Runtime.JwtSecretKey).GenerateJwt(claims.ID, claims.Identity, claims.Client, claims.Role, nil, claims.Extend)
    
    ParseMetaCtx 只调用 ParseJwt(签名校验),不调用 IsExpiredJwtAuth 里的过期校验SDK middleware/jwt.go:32-47)在 /refresh 上因未挂载而完全不生效。
  • 影响:持有一个已过期但签名合法的 token 即可换取新的 24 小时 token会话实际上永不过期token 被盗后无法通过「等它过期」止损,也无法通过改密失效(无 SaveToken/黑名单调用,pub/login.go:108-116SaveToken 是 dead code。token 内还携带手机号之外的 extendid/Identity/status/name边界说明ParseMetaCtx 仍会调用 ParseJwt 做 HS256 验签(core/service/meta.go:31core/crypto/token/jwt.go:64-73),因此该缺陷只放大到「过期 token 可续期」,并不等于可伪造 token(伪造能力来自 P0-2 的默认密钥)。
  • 建议/refreshJwtAuth(true)(强制过期校验),并在 handler 内回落数据库复核账号状态与是否存在,再加 pwd_version/token_version 声明实现改密即失效;补齐登出接口与 Redis 黑名单。

10. 登录与重置无失败计数/锁定/限流(可无限次猜口令)

  • 位置module/base/mgt/internal/logic/pub/login.go:35-106checkPwdAndStatus :118-145internal/logic/pub/forget.go:22-99cmd/main/main.go:32-42 未挂任何限流中间件
  • 证据
    // login.go:125-141  —— 失败仅返回错误,无失败计数与延迟
    err := bcrypt.CompareHashAndPassword([]byte(userPwd), []byte(inPwd+salt))
    if err == nil { return nil }
    ...
    if userPwd == md5Hash { return nil }
    return errcode.ErrPassword
    
    // main.go:38-42  —— 全局中间件只有 Mode 与 Cors
    middleware.Mode(app)
    app.Use(middleware.Cors())
    
  • 影响:结合 P0-1 的弱默认口令(123456min=6 无复杂度)与 P1-6 的账号枚举,攻击者可对 root 账号做在线字典爆破bcrypt 的 CPU 成本也构成单请求 CPU 放大,可用于资源耗尽。
  • 建议:引入 Redis 计数限流(同 IP/账号 5 次失败锁定 15 分钟 + 递增延迟),并为弱口令建立拒绝清单。

11. 部门树/权限树构建无环检测,脏数据可致无限递归(进程崩溃)

  • 位置module/base/mgt/internal/logic/department/del.go:17-37internal/logic/department/list.go:71-83internal/logic/department/fetch_pmn_tree.go:109-130
  • 证据
    // department/del.go:29-36  —— 无 visited 集合,环即无限递归
    var collectChildren func(uint)
    collectChildren = func(deptId uint) {
        result = append(result, deptId)
        for _, childId := range childrenMap[deptId] { collectChildren(childId) }
    }
    
    // department/list.go:71-83
    func buildDeptTree(list []models.MgtDepartment, parentId uint) []models.MgtDepartment {
        var node []models.MgtDepartment
        for _, d := range list { if d.ParentID != parentId { continue }; children := buildDeptTree(list, d.ID) ...
    
    环数据的来源:department/modify.go:51-99 虽校验了自引用与「新父节点不是自身后代」,但该检查是先读后写、非原子(并发两次互为父级的修改可绕过),且 permission.modify.go:72,127 完全没有环/同应用校验。
  • 影响:一旦 parent_id 成环(并发写入或直接改库/历史数据),POST /rest/mgt/dpt/del/dpt/list?tree=true/dpt/pmn_tree 会无限递归导致 goroutine 栈溢出,进程崩溃且每次调用都必然崩溃(持久性 DoS
  • 建议:所有递归遍历加 visited map[uint]struct{} 并在检测到环时返回错误(同时记录脏数据告警);把树操作收敛到一个带环检测的公共工具包。

P2

12. 创建用户/角色/权限/应用时物理删除同名的软删除记录,留下多表孤儿数据

  • 位置internal/logic/user/create.go:57-72internal/logic/role/create.go:49-56internal/logic/permission/create.go:52-58internal/logic/application/create.go:46-69
  • 证据
    // user/create.go:57-64
    var deletedData models.MgtUser
    if err = impl.DBService.Unscoped().Where("phone = ?", request.Phone).First(&deletedData).Error; err == nil {
        if err = impl.DBService.Unscoped().Delete(&deletedData).Error; err != nil { ... }
        printer.Info("user deleted: id=%d, phone=%s", deletedData.ID, deletedData.Phone)
    
  • 影响:为绕开 uniqueIndex物理删除历史用户,但 mgt_link_user_role / mgt_link_user_app / mgt_link_user_pmn / mgt_link_user_dpt 中该 user_id 的关联行仍在(无外键约束,模型里没有 constraint 标签)。当新用户复用到同一自增 ID 时,会继承前一个用户的角色/权限/部门/应用关联,造成越权与数据错乱;应用被物理删除同理会让 mgt_permission / mgt_link_* 大面积悬空。
  • 建议:不要物理删除;改为「复用并覆盖」或「先事务内清理全部关联再物理删除」;为关联表补外键或定期孤儿清理任务。

13. 部门/角色/权限/应用列表查询在循环内访问数据库N+1

  • 位置internal/logic/department/fetch_pmn.go:74-94internal/logic/department/fetch_pmn_tree.go:82-89buildPermTreeFilter 每层递归重扫全量 list)、internal/logic/user/fetch_pmn.go:88-994 层嵌套 Preload
  • 证据
    // department/fetch_pmn_tree.go:82-89  —— 每个部门都对同一份 perms 全量重扫并建树
    for i := range data {
        ids := dptToPmn[data[i].ID]
        idSet := make(map[uint]struct{})
        for _, id := range ids { idSet[id] = struct{}{} }
        data[i].Permissions = buildPermTreeFilter(perms, 0, idSet)
    }
    
    // user/fetch_pmn.go:88-99  —— 用户→应用/直接权限/角色权限/部门权限/部门角色权限
    Preload("Permissions", ...).Preload("Roles", func(db){ ...Preload("Permissions") }).
    Preload("Departments", func(db){ ...Preload("Permissions")...Preload("Roles", ...Preload("Permissions")) })
    
  • 影响O(部门数 × 权限数^深度) 的 CPU 开销;Preload 链一次请求触发约 7-8 条 SQL。数据量上千后这两个接口前端菜单渲染必调成为明显瓶颈。
  • 建议:一次取全部权限后按 parent_id 建索引字典,各层 O(1) 查找并共享 children 缓存;Preload 改为一次性 join/IN 查询后在内存组装。

14. 列表分页排序不确定、size 无上限、Order 缺省

  • 位置internal/logic/user/fetch.go:44-48internal/logic/application/fetch.go:44-48internal/logic/role/fetch.go:46-50internal/logic/department/fetch.go:63-67(均无 Orderinternal/logic/tool/tool.go:5-14(仅默认值,无上限)
  • 证据
    // tool/tool.go:5-14
    func GetSizeAndPage(page, size int) (int, int) {
        if page <= 0 { page = types.DefaultPage }
        if size <= 0 { size = types.DefaultSize }
        return page, size   // 没有最大值截断
    }
    
    // user/fetch.go:45  —— 无 Order跨页结果不稳定
    if err := db.Count(&count).Limit(size).Offset((page - 1) * size).Find(&data).Error; err != nil {
    
  • 影响size=100000 可被匿名/超管用于拉全表(内存与带宽放大);无 ORDER BY 时 PostgreSQL/MySQL 不保证行序,翻页会重复/漏数据;role/fetch_other.goUserFetch/ApplicationsFetch/PermissionsFetchpmn/userpmn/roledpt/user 完全无分页。
  • 建议size 上限(如 200并在超限时 400所有列表补 Order("id asc") 之类确定性排序;无分页的关联查询补 Limit/Offset

15. 无结构化日志与审计日志:高危操作均只写标准库 log/printer,不含操作者与变更前后值

  • 位置:全模块 printer.Info/Errorlog.Printf(示例:internal/logic/user/set_pmn.go:70internal/logic/role/del.go:94internal/logic/user/del.go:76internal/logic/pub/forget.go:98
  • 证据
    // user/set_pmn.go:70
    printer.Info("用户权限设置成功: userId=%d", request.Id)
    
    // pub/forget.go:98
    printer.Info("密码重置成功: account=%s, phone=%s", request.Account, request.Phone)
    
  • 影响:赋权、改密、删除用户/角色/应用这些高危动作无法追溯操作者、来源 IP、变更内容RequireAdmin 放行后也没有留下「谁给谁授权」的记录。发生权限滥用时无法定责,也无法比对变更。相反 pub/login.go:58,80,121,133,143 会打印失败原因(含账号),日志中还有 PII。
  • 建议:新增 mgt_audit_log(操作者 ID/IP/路由/请求摘要/前后值/结果)并在所有写 handler 落库日志改为结构化zap并脱敏账号/手机号。

16. 三处查询引用了不存在的列名(code like ?)或 ? 与参数不匹配,接口直接报错

  • 位置internal/logic/application/fetch.go:36-39internal/logic/permission/fetch.go:37-40
  • 证据
    // application/fetch.go:36-39  —— mgt_application 没有 code 列;两个 ? 只传一个参数
    if request.Keyword != "" {
        likeStr := "%" + request.Keyword + "%"
        db = db.Where("title like ? or code like ? ", likeStr)
    }
    
    // permission/fetch.go:37-40同样的 2 个占位符 + 1 个参数)
    db = db.Where("title like ? or code like ? ", likeStr)
    
  • 影响mgt_application 表结构只有 title/workspace/descriptioninternal/models/application.go:6-17),引用 code 列会直接 SQL 报错 → POST /rest/mgt/app/fetchkeyword 必然 500permission/fetch 同样带 keyword 必然失败。属于「接口文档宣传可用、实际不可用」的功能性缺陷。
  • 建议application/fetch 改为 title like ? or workspace like ?(或删除 codepermission/fetch 补第二个参数。

17. MgtLinkUserPmn / MgtLinkRolePmnAfterDelete 钩子在批量删除场景下以全零字段执行,清理逻辑实际失效

  • 位置internal/models/link_user_pmn.go:25-39internal/models/link_role_pmn.go:25-39;调用方均为以零值空结构体为 Dest 的批量 Where(...).Delete(&models.MgtLinkUserPmn{})user/set_pmn.go:108,165role/set.go:110,114,164role/del.go:56,68user/del.go:59department/del.go:85application/del.go:67,73
  • 证据
    // link_user_pmn.go:25-38  注释明确该钩子用于清理用户-应用关联
    // AfterDelete钩子仅当该用户在该应用下不再拥有任何权限时移除用户-应用关联
    func (l *MgtLinkUserPmn) AfterDelete(tx *gorm.DB) error {
        var count int64
        if err := tx.Model(&MgtLinkUserPmn{}).Where("app_id = ? AND user_id = ?", l.AppId, l.UserId).Count(&count).Error; ...
    
    // user/set_pmn.go:108  —— Dest 是零值空结构体,钩子接收者 l 的 AppId/UserId 全为 0
    if err := tx.Where("app_id = ? and user_id = ?", appId, request.Id).Delete(&models.MgtLinkUserPmn{}).Error; err != nil {
    
    GORM v1.31.2 源码(callbacks/delete.go:190-199callbacks/callmethod.go:9-31AfterDelete 会以 db.Statement.ReflectValue.Interface() 调用钩子,批量删除时该值是零值 MgtLinkUserPmn{},因此 l.AppId == 0 && l.UserId == 0Count 与随后的 Delete 都作用在 app_id=0 AND user_id=0 上——钩子确实被调用,但恒为空操作(与「批删不触发钩子」的常见说法不同,此处是传参全零)。
  • 影响mgt_link_user_app / mgt_link_role_app 中的「0 权限应用关联」长期残留,前端出现无权限的空应用;mgt_link_dpt_pmn 侧则完全没有等价清理机制。即模型层注释所声明的级联维护语义从未真正生效
  • 建议:删除关联后在同一事务内显式执行「清理 0 权限的应用关联」SQL不要依赖钩子或在服务层统一封装同时修正模型注释避免误导。

18. 部门/角色/权限/应用的「先查后建」无唯一索引兜底,并发下可产生重复数据

  • 位置internal/logic/department/create.go:60-70internal/logic/role/create.go:44-60internal/logic/permission/create.go:47-62
  • 证据
    // department/create.go:60-70  —— 只靠 SELECT 判重DB 层无 (app_id,name) 唯一约束
    if err = impl.DBService.Model(&models.MgtDepartment{}).
        Where("app_id = ? and name = ?", appId, request.Name).First(&data).Error; err != nil { ... }
    else { infra.Response.Error(c, errcode.ErrAlreadyExists); return }
    
    internal/models/department.go:6-18NameuniqueIndexrole.Name 有全局唯一索引(role.go:8),但 role/create.go:49-56 会在冲突时物理删除重建(见 P2-12并发下仍可删掉他人正在使用的角色。
  • 影响:同一应用下可存在多个同名部门;角色创建在并发/软删除场景下会「删旧建新」,使既有 mgt_link_user_role/mgt_link_dpt_role 指向已删除角色 ID。
  • 建议:为 mgt_department(app_id, name) 加复合唯一索引并捕获唯一冲突错误;角色创建改为「已存在则直接报冲突」,不做物理删除。

19. 自定义分页/校验/状态语义不一致,魔法数字散落

  • 位置internal/types/vars.go:3-9DefaultPage/DefaultSize、死常量 Admin/Staff)、internal/types/req.go:9Status int8oneof)、core/types/db.go:340/1/-1 语义)、internal/types/req.go:16min=6
  • 证据
    // internal/types/vars.go:4-9
    KeyPrefix = "/SMS/Code/"
    Admin     = "SYSADMIN"   // 全模块无引用
    Staff     = "STAFF"      // 全模块无引用
    DefaultPage = 1
    DefaultSize = 10
    
    // internal/types/req.go:16
    Password string `json:"password,omitempty" validate:"required,min=6"` // 密码最少6位
    
  • 影响:状态值 0/1/-1 在代码中以字面量出现(Std_IICUDS{Status: 1}if request.Status != 0),无统一常量;Admin/Staff 从未使用;DefaultSize=10 与文档中展示的示例不一致;密码策略仅 6 位且无复杂度/常见弱口令校验。
  • 建议:集中定义状态常量与权限点常量;删除或启用死常量;密码策略提升到 8-12 位 + 复杂度 + 弱口令清单(与 P1-10 一起做)。

20. 登录 MD5 向后兼容分支缺迁移路径,旧哈希不升级、无标记可观测

  • 位置internal/logic/pub/login.go:130-141(写于登录路径);无任何「登录成功后重哈希为 bcrypt」的代码
  • 证据
    // login.go:130-141
    // 如果bcrypt失败尝试MD5验证向后兼容旧用户
    isBcrypt := len(userPwd) > 4 && (userPwd[0:4] == "$2a$" || ...)
    if isBcrypt { ...; return errcode.ErrPassword }
    hash := md5.Sum([]byte(inPwd + salt))
    md5Hash := hex.EncodeToString(hash[:])
    if userPwd == md5Hash { return nil }
    
  • 影响MD5+salt 的历史口令可永久继续登录无迁移强制一旦数据库泄露MD5 哈希可被彩虹表/GPU 秒破;代码里也没有「旧哈希计数」指标来评估迁移进度。test/reset.httptest/user_create.http 使用 123456 佐证弱口令长期存在。
  • 建议MD5 校验成功后立即在事务内把该用户口令重写为 bcrypt透明升级统计并告警剩余 MD5 账号数;设定下线时间表后移除分支。
  • 位置cmd/main/main.go:34-45
  • 证据
    // main.go:34-45
    sessionKey := config.Spec.SecretKey + "-session"
    store := cookie.NewStore([]byte(sessionKey))
    app.Use(sessions.Sessions("mysession", store))
    ...
    srv := &http.Server{Addr: addr, Handler: app}   // 无 Read/Write/Idle/Header 超时
    
  • 影响http.Server 无任何超时(慢速攻击/连接耗尽风险);cookie.NewStore 未设置 HttpOnly/Secure/SameSite/MaxAgesessionKey 直接等于配置文件里的 SecretKeyetc/mgt_prod.yaml:16 为占位符 CHANGE_ME,弱且可预测),而 /rest/mgt/session匿名可写接口(register.go:32internal/logic/hello/ping.go:25-35),任何人均可让服务端下发并签名 cookie。目前 session 未被用于鉴权JWT 才是),因此风险为「不必要的攻击面」而非直接绕过。
  • 建议:删除 SessionDemo 与 session 中间件(或至少改为仅内网);http.ServerReadHeaderTimeout/ReadTimeout/WriteTimeout/IdleTimeout;如保留 cookie设置 HttpOnly+Secure+SameSite=Lax 并使用独立强随机 key。

22. CORS 允许所有来源,且允许携带 Authorization

  • 位置SDK D:\work\bsm-sdk\core\middleware\cors.go:9-17;挂载点 cmd/main/main.go:39
  • 证据
    // D:\work\bsm-sdk\core\middleware\cors.go:9-17
    return cors.New(cors.Config{
        AllowAllOrigins: true,
        AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Workspace",
            "Request-Id", "Authorization", "Token"},
    
  • 影响:任意站点可对 /rest/mgt/** 发起跨域请求并读取响应;若前端把 token 存在 localStorage 并由 JS 读取(常见做法),恶意页面可借用户浏览器直接调用全部管理接口。超管在浏览器登录后访问任意恶意页面即可被借用权限。
  • 建议:改为白名单 AllowOrigins(按环境配置),并明确 AllowCredentials 策略;管理端接口建议叠加 CSRF/自定义头校验。

23. /app/user 返回手机号、邮箱等 PII且分页参数放在 Preload 内不生效

  • 位置internal/logic/application/fetch_other.go:46-53
  • 证据
    // application/fetch_other.go:47-49
    if err := db.Where("workspace = ?", request.Workspace).Preload("Users", func(db *gorm.DB) *gorm.DB {
        return db.Select("id", "identity", "name", "account", "phone", "email", "avatar").Count(&count).
            Limit(size).Offset((page - 1) * size)
    }).Find(&data).Error; err != nil {
    
  • 影响POST /rest/mgt/app/user 一次返回整个应用的全体用户手机号/邮箱,无有效分页(Limit/Offset/Count 作用在 preload 关联查询上,Count 结果被写入外层 count 变量但外层查询本身不受限),total 语义也不正确。属于 PII 批量泄露 + 分页失效。
  • 建议:拆成两次查询(先分页取 user_id 再取明细),或对手机号/邮箱做脱敏(仅返回掩码),并限制单次返回量。

24. 全局可变基础设施单例 + 配置缺校验导致不安全默认值

  • 位置internal/impl/impl.go:13-26internal/impl/with.go:13-16internal/config/config.go:31-49
  • 证据
    // impl.go:13-18  —— 包级可变单例,被 logic 层直接读取
    var (
        RedisService *redis.RedisClient
        EtcdService  *clientv3.Client
        MemorySerice *cache.Cache
        DBService    *gorm.DB
    )
    
    // config.go:49  —— 只校验 Service/Cache 非空,未校验 DB/JWT/端口冲突
    conf.NotNil(Spec.Service, Spec.Cache)
    
  • 影响DBService/RedisService 可在运行期被 service.applyDependencies 覆盖(service/dependencies.go:19-32logic 层直接引用全局变量(分层违规、无法注入 mock、单测困难config.New 不校验 Databases,缺失时由 with.go:14 直接 panic(启动期崩溃而非友好报错),也不校验 JWT 密钥(见 P0-2MemorySerice/EtcdService 在本模块从未被使用(仅赋值),属无效基础设施成本。
  • 建议:改为显式依赖注入(构造函数传 *gorm.DB/Redis或至少提供只读访问器config.New 增加必填项与非默认密钥校验;移除未使用的 go-cache/etcd 依赖。

25. 端口与时间/时区相关配置隐患

  • 位置internal/config/config.go:36etc/mgt_prod.yaml:2,7
  • 证据
    // config.go:35-36
    // 配置校验 服务端口如果不合规,则随机分配端口
    Spec.Port = conf.CheckPort(Spec.Port)
    
    # etc/mgt_prod.yaml:1-7
    Service: mgt
    Port: 18001
    Databases:
      Driver: postgres
      Source:
        - host=127.0.0.1 user=ec_dev password=CHANGE_ME dbname=factor_prod port=5432 sslmode=disable TimeZone=Asia/Shanghai
    
  • 影响:非法端口被静默改为随机端口,容器/网关按固定端口探活会失败启动「成功」但不可达DSN 用 TimeZone=Asia/Shanghai 而模型用 time.Time + DB TIMESTAMPcore/types/db.go:31-33),跨时区部署时 created_at/last_login_at 语义易错;sslmode=disable 使 DB 连接明文(内网可接受,生产需确认)。
  • 建议:非法端口直接 Fatal 并输出明确原因;统一使用 UTC 存储、展示层转换;生产 DSN 启用 TLS 或明确豁免记录。

26. 大量写操作缺少 context 传递与超时控制

  • 位置:全模块 logic 层(示例 internal/logic/user/create.go:108internal/logic/role/del.go:54internal/logic/department/del.go:78
  • 证据
    // user/create.go:108  —— 全部使用 impl.DBService 全局句柄,无 WithContext(c.Request.Context())
    if err = impl.DBService.Transaction(func(tx *gorm.DB) error {
    
    impl.RedisService.Client.Get/Setpub/forget.go:58pub/login.go:110)同样不传 context。
  • 影响客户端断开或请求超时后DB/Redis 调用仍会继续执行到结束(长事务、慢查询无法被取消),故障时连接池被长时间占用;也无法做链路追踪。
  • 建议:统一改为 impl.DBService.WithContext(c.Request.Context())Redis 调用传 ctx,并为关键写操作设置语句级超时。

27. 无健康检查的依赖探测、启动与迁移/播种强耦合

  • 位置cmd/main/main.go:41app.HEAD("/", infra.Health))、internal/models/impl.go:57-74
  • 证据
    // models/impl.go:57-74  —— 进程启动路径内串行执行 setupRegister + AutoMigrate + 播种
    setupRegister()
    log.Println("自定义连接表注册成功")
    err = DBService.AutoMigrate(migrateTables...)
    ...
    if config.InitRootUserEnabled() { InitRootUserData() }
    
  • 影响AutoMigrate 与 root 播种在启动路径上同步执行DB 不可用/迁移变慢会让进程迟迟不监听端口,且 log.Fatallnimpl.go:46,52,64)在迁移失败时直接退出——配合无健康检查探活语义,滚动发布易出现「启动即崩」的雪崩;infra.Health 只回固定内容,不探测 DB/Redis。
  • 建议:迁移拆为独立命令/initContainer健康检查区分 liveness/readiness 并探测 DB+Redis启动失败给出可读错误并做有限重试。

P3

28. 生成的校验错误信息被丢弃,接口只返回通用 ErrInvalidArgument

  • 位置internal/libs/validator.go:78-92:95-122
  • 证据
    // validator.go:79-91
    func formatValidationError(err error) error {
        if validationErrors, ok := err.(validator.ValidationErrors); ok {
            var errMsg strings.Builder
            errMsg.WriteString("参数验证失败: ")
            for i, validationError := range validationErrors { ... errMsg.WriteString(getFieldErrorMsg(validationError)) }
            return errcode.ErrInvalidArgument   // 精心拼装的 errMsg 从未被使用
        }
        return errcode.ErrInvalidArgument
    }
    
  • 影响getFieldErrorMsg11 个分支)构造的字段级提示全部丢弃,前端只能看到「参数错误」,排障与体验受损,同时产生一段 dead code。
  • 建议:返回 errcode.String(errcode.ErrInvalidArgument, errMsg.String()),或至少把字段错误写入结构化日志。

29. 无 Go 单元测试,仅有 1 个路由注册测试与 54 个无断言的 .http 样例

  • 位置internal/routers/register_test.go:10-35(唯一 _test.gotest/*.http54 个,纯请求样例)
  • 证据
    // register_test.go:15-20  —— 只断言 4 条路由存在
    expected := map[string]bool{
        "GET /rest/mgt/ping": false, "POST /rest/mgt/login": false,
        "POST /rest/mgt/user/create": false, "POST /rest/mgt/app/fetch": false,
    }
    
  • 影响:登录(含 MD5 兼容分支)、RequireAdmin 越权、EnsureSelfOrAdmin 水平越权、init_db 播种幂等、赋权差集(DelRole)、部门树环检测这些高风险逻辑全部零覆盖本次审计发现的问题P1-3 零值覆盖、P1-5 越权赋权、P1-8 误删权限)都没有任何自动化防线。
  • 建议:补齐关键缺失用例(见第 5 节 TODO

30. 文档与实现多处不一致

  • 位置doc/README.md:3-4doc/pub.md:3,81-83doc/user.md:3doc/pmn.mddoc/dpt.mdREADME.md:217
  • 证据
    <!-- doc/README.md:3-4 -->
    接口基础地址:`http(s)://{host}:{port}/mgt/`
    除公开接口外,需在请求头携带:`Authorization: Bearer {token}`
    实际路由前缀为 /rest/mgtregister.go:21register_test.go:26-28 还专门断言不存在 /mgt/* 旧前缀——文档与测试相互矛盾。
    <!-- doc/pub.md:83 -->
    **说明**:基于当前请求中的 JWT 签发新 token。通常需在 Header 中带有效 Authorization。
    
    实际匿名可调且不校验过期P1-9。全部文档都未说明管理端接口需要「超级管理员」角色
  • 影响:接入方按文档用 /mgt/login 会 404调用方无法预知哪些接口需要超管容易误判 403 为 bug权限点体系与文档完全脱节。
  • 建议:统一为 /rest/mgt/**,逐接口补「所需角色/权限点」,并在 CI 中用 register_test.go 的方式校验文档路由集合与实际路由一致。

31. README/测试样例硬编码真实默认口令,死代码残留

  • 位置README.md:168-172test/login.http:5-6test/reset.http:7test/user_create.http:8internal/models/query.go(仅 1 行 package modelspub/login.go:108-116SaveToken 无调用方);internal/types/types.go:1-9310 个结构体全无引用);internal/types/vars.go:5-6Admin/Staffconfig.go:18 + impl.go:15,25SecretKey 与 etcd 实际未用于 JWT
  • 证据
    // pub/login.go:108-116  —— 零调用方(全模块仅此定义处出现)
    func SaveToken(id int64, role, token string) error {
        tokenKey := fmt.Sprintf("%d-%s-%s", uint(id), role, "token")
        status := impl.RedisService.Client.Set(impl.RedisService.Ctx, tokenKey, token, 0)
    
  • 影响:文档与样例即攻击字典;至少 6 处 dead code 增加维护成本,并让读者误以为存在 token 存储/登出机制、类型别名约定、Admin/Staff 角色体系。
  • 建议:样例口令改占位符;删除无引用代码或补齐实现(SaveToken 若要用于登出需同时接 JwtAuth 校验)。

4. 推荐优化方案

按「先止血、再补结构、后做工程化」三层推进:

第一层止血1-3 天内可完成,对应 P0/P1-6/P1-9/P1-10

  1. 鉴权基础设施硬化SDK 侧要求显式配置 JWT 密钥(缺失即 Fatalmgt 启动时校验密钥强度;/refreshJwtAuth(true) 并复核账号状态;/login/reset 加 Redis 限流IP+账号)与验证码错误计数。
  2. 口令治理:移除 init_db.go123456,改为「环境变量初始口令 / 随机生成 + 强制首登改密」;保留 MD5 兼容分支但在登录成功后透明升级为 bcrypt并加迁移进度指标。
  3. 数据损坏修复:把 user/modify.gopermission/modify.goSelect+结构体 改成 map[string]interface{}(只装显式字段);为 /user/modify 增加 root 保护。

第二层补结构1-2 周,对应 P1-5/P1-7/P1-8 与 P2 主体)

  1. 真正的接口级授权:建立 路由 → permission.Code 映射中间件,逐个接口挂载权限点;把超管判定从「角色名」改为不可变标识;清理或启用 Admin/Staff
  2. 赋权链路收敛:抽出 grantPermissions(scope, id, appId, pmnIds) 统一入口,强制「权限必须属于 appId」校验 + 事务内「先删后插」+ 变更审计;role/del 复用 user/set_role.go 的差集算法;应用关联清理由钩子改为事务内显式 SQL。
  3. 树与递归安全:所有父子遍历加 visited 环检测,统一到 internal/logic/tree 工具包;权限/部门的 parent_id 写入前校验同应用 + 非自身 + 非后代。
  4. 审计日志:新增 mgt_audit_log 表,所有写 handler 记录操作者/路由/关键参数/前后值/IP日志脱敏并结构化zap
  5. 查询与索引:为 mgt_department(app_id,name) 加复合唯一索引;补 Order 确定性与 size 上限;把 department/fetch_pmn* 的 O(n²) 建树改为字典;application/fetch_other.go 的分页重写。

第三层:工程化(持续,对应 P2 剩余与 P3

  1. 依赖注入替换全局单例;config.New 增加必填项/非默认密钥/端口校验;移除未使用的 go-cache/etcd。
  2. http.Server 补超时CORS 改白名单;删除 SessionDemo 或迁到内网。
  3. context 贯通DB/Redis 全量 WithContext+ 关键写操作语句超时。
  4. 测试与文档:补齐登录/越权/初始化/赋权差集/树环检测的单元与集成测试CI 中校验文档路由集合与实际路由一致;密码策略与错误信息改进。

5. TODO 清单

  • P0-1 移除 root 硬编码默认口令,改为环境变量或随机生成 + 强制首登改密|验收:grep -rn "123456" module/base/mgt 无源码命中,首次启动日志不含可复用明文口令,InitRootUserData 输出的仅是一次性随机口令|涉及:internal/models/init_db.go:16README.md:170test/login.http:6
  • P0-2 JWT 密钥禁止默认值,启动即校验长度与黑名单|验收:未设 BSM_JwtSecretKey 时进程启动失败并输出可读错误;密钥长度 <32 或等于 Cblocksmesh2022C 时拒绝启动|涉及:D:\work\bsm-sdk\core\env\env.go:19internal/config/config.go:39
  • P1-3 user/modify 改为按显式字段更新,禁止零值覆盖|验收:只传 {"id":1,"name":"x"}account/phone/email/avatar/status 均保持不变(集成测试断言)|涉及:internal/logic/user/modify.go:57-72
  • P1-4 permission/modify 改为按显式字段更新|验收:只传 {"id":1,"title":"x"}code/parent_id/menu_path/component 不变|涉及:internal/logic/permission/modify.go:67-89,123-144
  • P1-5 赋权前校验权限集合全部属于目标 app_id;权限 parent_id 校验同应用、非自身、非后代|验收:跨应用权限 ID 或自引用 parent_id 请求返回 400库中不留脏关联涉及internal/logic/user/set_pmn.go:40-47internal/logic/role/set.go:40-48internal/logic/permission/create.go:71internal/logic/permission/modify.go:72,127
  • P1-6 /reset/login 加限流、验证码错误计数、统一错误码|验收:同手机号 5 次验证码错误后验证码失效;同 IP 高频请求返回 429账号不存在与密码错误响应一致涉及internal/routers/register.go:34-36internal/logic/pub/forget.go:57-73internal/logic/pub/login.go:64-71
  • P1-7 建立路由→权限点鉴权中间件并逐接口挂载,超管判定改用不可变标识|验收:非超管账号调用 /rest/mgt/user/create 返回 403重命名「超级管理员」角色后 root 仍为超管|涉及:internal/middleware/rbac.go:19-54internal/routers/register.go:45-135
  • P1-8 role/del 按用户其他角色做权限差集,应用关联清理改为事务内显式 SQL验收删除角色后用户经其他角色仍持有的权限不被删除mgt_link_user_app 无 0 权限残留行|涉及:internal/logic/role/del.go:40-88internal/models/link_user_pmn.go:26internal/models/link_role_pmn.go:26
  • P1-9 /refreshJwtAuth(true) 并复核账号状态,引入 token 版本/黑名单与登出接口|验收:过期 token 调用 /refresh 返回 401改密后旧 token 失效|涉及:internal/routers/register.go:35internal/logic/pub/refresh.go:21-34
  • P1-10 登录失败限流与弱口令清单,密码策略提升至 ≥8 位含复杂度|验收:连续 5 次失败锁定 15 分钟;123456/password 等被拒|涉及:internal/logic/pub/login.go:118-145internal/types/req.go:16
  • P1-11 部门/权限树遍历加环检测,脏数据返回错误而非崩溃|验收:构造 parent_id 环后 /dpt/del/dpt/list?tree=true 返回错误且进程存活|涉及:internal/logic/department/del.go:17-37internal/logic/department/list.go:71-83internal/logic/department/fetch_pmn_tree.go:109-130
  • P2-12 创建接口不再物理删除软删除记录,或事务内先清理全部关联|验收:重建同名账号后不继承任何历史角色/权限/部门关联|涉及:internal/logic/user/create.go:57-72internal/logic/role/create.go:49-56internal/logic/permission/create.go:52-58internal/logic/application/create.go:46-69
  • P2-13 消除 N+1部门权限建树改字典一次成型user/fetch_pmn 预加载合并|验收:/dpt/pmn_tree 权限数 1000 时 SQL 数 ≤5耗时线性涉及internal/logic/department/fetch_pmn.go:74-94internal/logic/department/fetch_pmn_tree.go:82-89internal/logic/user/fetch_pmn.go:88-99
  • P2-14 列表补确定性排序与 size 上限,无分页接口补分页|验收:size=100000 返回 400同参数重复翻页结果稳定涉及internal/logic/tool/tool.go:5-14internal/logic/{user,application,role,department}/fetch.go
  • P2-15 新增审计日志表并覆盖所有写 handler验收每次赋权/改密/删除均在 mgt_audit_log 留下操作者 ID、路由、参数与结果涉及internal/logic/user/set_pmn.go:70internal/logic/role/del.go:94internal/logic/pub/forget.go:98
  • P2-16 修正 code like ? 无效列与 ? 参数不匹配|验收:/rest/mgt/app/fetch?keyword=x/rest/mgt/pmn/fetch?keyword=x 返回 200涉及internal/logic/application/fetch.go:36-39internal/logic/permission/fetch.go:37-40
  • P2-17 用事务内显式 SQL 替代以全零接收者执行、恒为空操作的 AfterDelete 钩子路径|验收:用户/角色权限清空后 mgt_link_user_app/mgt_link_role_app 无 0 权限残留行(集成测试直接断言关联表)|涉及:internal/models/link_user_pmn.go:25-39internal/models/link_role_pmn.go:25-39internal/logic/user/set_pmn.go:108,165internal/logic/role/set.go:110,114,164
  • P2-18mgt_department(app_id,name) 加复合唯一索引,角色创建不做物理删除|验收:并发同名部门只成功一条;角色创建冲突时返回 409 且不删除既有角色|涉及:internal/models/department.go:9internal/logic/department/create.go:60-70internal/logic/role/create.go:49-60
  • P2-20 MD5 兼容分支透明升级为 bcrypt 并加迁移指标|验收:旧 MD5 用户首次登录成功后库中口令变为 $2a$/$2b$ 前缀|涉及:internal/logic/pub/login.go:130-141
  • P2-21 http.Server 补超时session 中间件与 /session 移除或改内网|验收:/rest/mgt/session 不再对外可用;配置含 Read/Write/Idle 超时|涉及:cmd/main/main.go:34-45internal/logic/hello/ping.go:25-35
  • P2-22 CORS 改来源白名单|验收:非白名单 Origin 的预检请求被拒|涉及:D:\work\bsm-sdk\core\middleware\cors.go:9-17
  • P2-23 /app/user 分页重写并对手机号/邮箱脱敏|验收:单次返回 ≤ 上限条数,total 正确,手机号以掩码返回|涉及:internal/logic/application/fetch_other.go:46-53
  • P2-24 依赖注入替换全局单例;配置增加必填与非默认密钥校验;移除未使用的 go-cache/etcd验收缺失 Databases 时启动报可读错误而非 panicMemorySerice/EtcdService 引用清零|涉及:internal/impl/impl.go:13-26internal/config/config.go:31-49
  • P2-26 DB/Redis 调用贯通 context 与超时|验收:客户端断开后长查询可被取消(日志可见 context canceled涉及全 logic 层事务与 internal/logic/pub/forget.go:58internal/logic/pub/login.go:110
  • P2-27 迁移与播种从启动路径剥离,健康检查探测 DB/Redis验收DB 不可用时进程仍能启动并给出 readiness 失败;迁移由独立命令执行|涉及:internal/models/impl.go:57-74cmd/main/main.go:41
  • P3-28 校验错误信息回传前端|验收:字段校验失败响应包含具体字段与原因|涉及:internal/libs/validator.go:78-92
  • P3-29 补关键单元/集成测试(登录含 MD5 分支、RequireAdmin/EnsureSelfOrAdmin 越权、init_db 幂等、DelRole 差集、树环检测)|验收:上述场景用例全绿,覆盖率覆盖 6 个 logic 子包的写路径|涉及:internal/routers/register_test.go:10-35、新增 internal/logic/**/*_test.go
  • P3-30 文档路径统一为 /rest/mgt/** 并补「所需角色/权限点」|验收:文档路由集合与 engine.Routes() 一致CI 校验);每接口标注权限点|涉及:doc/README.md:3-4doc/pub.md:83doc/*.md
  • P3-31 清理死代码与样例口令|验收:SaveToken/types/types.go 结构体/Admin/Staff/models/query.go 有引用或删除;样例口令为占位符|涉及:internal/logic/pub/login.go:108-116internal/types/types.gointernal/types/vars.go:5-6internal/models/query.go

6. 审计摘要(供汇总使用)

  • 问题数P0=2 P1=9 P2=15 P3=4编号 1-31全文连续
  • 最高风险一句话root 默认口令 123456 硬编码并自动播种(internal/models/init_db.go:16),叠加 JWT 密钥默认值 Cblocksmesh2022CSDK env.go:19),使未显式配置环境变量的部署可被一条登录请求或一枚伪造 token 直接接管最高权限,而全部 50 个管理端接口只校验「是否超管」、赋权无归属校验且无审计日志。
  • 最优先 3 个动作1) 移除 root 默认口令并强制 JWT 密钥显式配置P0-1、P0-22) 修复 user/modifypermission/modifySelect+结构体 零值覆盖P1-3、P1-4并给赋权接口补 app_id 归属校验P1-53) 为 /reset/login 加限流与验证码尝试限制、为 /refresh 恢复过期校验P1-6、P1-9、P1-10
  • 未能覆盖/无法验证的部分:无 DB/Redis 实例,越权、爆破、数据损坏类结论均为静态推演(仅 GORM 零值更新语义经隔离环境 DryRun 实测确认);未审计 SDK 的 DB/Redis/日志/响应序列化实现细节;未验证 module/allgateway 是否在 mgt 之外叠加了限流或鉴权;未确认部署态是否真的设置了 BSM_JwtSecretKey(这决定 P0-2 是否已被实际利用54 个 test/*.http 未逐条核对;未运行 go build/go testgofmt -lgo vet ./... 均通过,无输出)。