# 审计报告:module/base/passport ## 1. 模块概览 `module/base/passport` 是 BSM 的「通行证/会员账号」微服务,绝对路径 `D:\work\bsm-infra\full\module\base\passport`,同时以 gRPC(默认 `:12426`)与 gRPC-gateway(`Enable: true`,`:12425`)对外暴露。 代码规模(排除 `pb/` 生成物):41 个 Go 文件、约 1.8k 行;其中业务逻辑仅 21 个文件、约 1.1k 行。结构: | 目录 | 内容 | | --- | --- | | `cmd/main`、`cmd/cli` | 服务入口(`cmd/main/main.go:18`)、配置打印 CLI | | `etc/` | `passport_dev.yaml` / `passport_test.yaml` / `passport_prod.yaml` | | `internal/config` | `SrvConfig`(含 `Token`、`Kyc`、`WeChatConf`)、`New()` | | `internal/logic/{account,login,register,forget,verify,common}` | 全部业务逻辑 | | `internal/models` | 10 张表模型 + `cache.go` 缓存封装 + `query.go` | | `internal/server` | protoc-gen-slc 生成的薄封装,直接调用 logic | | `internal/impl`、`internal/vars` | 依赖注入全局变量、常量 | | `service/` | 供聚合模块 `Expose()` 嵌入注册 | | `proto/` | 5 个服务定义:`Account` / `Login` / `Register` / `Forget` / `Verify` | 对外接口共 15 个 RPC:账号 6(`Get`/`SetData`/`SetPassword`/`TagCreate`/`TagRemove`/`Statistics`)、登录 3(`Pwd`/`Code`/`Quick`)、注册 2(`Pwd`/`Code`)、找回 2(`Verify`/`Reset`)、实名 2(`Request`/`JumioCallback`)。 **核心结论:模块整体处于「未完成骨架」状态——安全机制(验证码校验、登录限流、回调签名、JWT 密钥校验)只有函数定义或注释,没有接线;同时存在可直接接管任意账号的两条无鉴权链路。** README 中宣称的 "Rate Limiting / CORS / Health Checks / Comprehensive request validation" 均未在代码中出现(`README.md:278-293`)。 ## 2. 审计范围与方法 **范围**:`module/base/passport` 全部非生成代码(`cmd/`、`etc/`、`internal/`、`service/`、`proto/`、`test/`)+ 生成物 `pb/*.gw.go` 的路由与鉴权语义。为判定鉴权边界,交叉阅读了外部依赖 `git.apinb.com/bsm-sdk/core@v0.2.1`(`go.mod:73` 的 `replace` 指向的本地路径在工作区外,改用 Go module 缓存中的同版本源码),以及聚合层 `pkgs/all/internal/config/config.go`、`module/base/mgt/...` 作横向对比。 **方法**:全量 `read` 41 个非生成 Go 文件 + 6 个 proto + 3 个 yaml + `*.pb.gw.go` 的路由/注册片段;用 `grep` 做交叉验证(死代码、MD5、TODO、`_ =`、缓存调用点);静态检查 `gofmt -l .`(无输出,通过)与 `GOWORK=off go vet ./internal/...`(无输出,通过)。 **关键判定依据**: - gRPC 与 gateway 均**不做**入口鉴权:`service/expose.go:30-44` 使用 `RegisterXxxHandlerServer`,生成物自述 "GRPC interceptors will not work for this type of registration"(`pb/verify.pb.gw.go:95-96`)。因此每个 RPC 的鉴权完全取决于 logic 内是否调用 `service.ParseMetaCtx`。 - `ParseMetaCtx` 只校验 JWT 签名与过期,**不查 Redis、不查黑名单**(`bsm-sdk/core@v0.2.1/service/meta.go:19-49`)。 - 各 RPC 鉴权覆盖:有 = `Account.Get/SetData/SetPassword/TagCreate/TagRemove/Statistics`(6 个)、`Verify.Request`(1 个);**无 = `Login.Pwd/Code/Quick`、`Register.Pwd/Code`、`Forget.Verify/Reset`、`Verify.JumioCallback`(9 个)**,其中 8 个属设计上的公开接口,`JumioCallback` 属例外。 **未能覆盖/无法验证的部分**(不臆断): 1. `bsm-sdk/core` 的 `replace` 目标 `../../../../../bsm-sdk/core` 在工作区外,审计使用的是缓存中 `v0.2.1` 源码;`go.mod:73` 声明的本地版本若被改动,结论可能偏移。 2. `Database.Configure`、`with.Databases` 等 SDK 装配层未读,**建表 DDL 是否由外部迁移脚本补齐唯一索引无法确认**(模型侧确认无 unique/index tag,见 P1-7)。 3. 短信验证码的**发送侧**不在本模块(无短信 RPC、无 `sender` 调用),"验证码由谁生成、谁存储"无法确认;因此验证码随机性/有效期/一次性只能就本模块的证据判定为「完全没有校验环节」。 4. 未做运行时验证(无可用环境与凭据),全部结论为静态代码分析;网关层(`D:\work\bsm-infra\gateway`、`proxy`)的额外鉴权策略未展开。 5. `utils.Md5`、`utils.UUID`、`RedisService.BuildKey` 的具体实现未读(属 SDK 内部),但用法本身已足以定性。 ## 3. 问题清单 ### P0 #### 1. 找回密码接口无任何身份校验,可任意重置他人密码;且改用 MD5 存入导致账号彻底不可登录 - **位置**:`module/base/passport/internal/logic/forget/reset.go:17`、`module/base/passport/internal/logic/forget/verify.go:14`、`proto/forget.proto:12`、`proto/forget.proto:21` - **证据**: ```go // logic/forget/reset.go:17-31 func Reset(ctx context.Context, in *pb.ForgetResetRequest) (reply *pb.StatusReply, err error) { if in.Identity == "" || in.Password == "" { return nil, errcode.ErrInvalidArgument } pa, err := models.GetPassportAccountByField("identity", in.Identity) ... err = impl.DBService.Model(&models.PassportAccount{}).Where("identity = ? ", pa.Identity).Update("password", utils.Md5(in.Password)).Error ``` ```go // logic/forget/verify.go:14-22 —— 声明「验证手机号和验证码」,实际只看手机号是否存在 if in.Phone == "" || in.Code == "" { return nil, errcode.ErrInvalidArgument } pa, err := models.GetPassportAccountByField("phone", in.Phone) ... // in.Code 从未被读取,函数直接返回成功 ``` ```proto // proto/forget.proto:12,21-24 rpc Reset(ForgetResetRequest) returns (base_passport_blocks.StatusReply) {} message ForgetResetRequest { string identity = 1; //唯一码 string password = 2; // 密码 } ``` - **影响**:**P0 账号接管**。`Reset` 无 `ParseMetaCtx`、无 `code` 参数、无一次性 reset token,只要知道(或从任何接口/分享链接/日志中取得)目标用户 `identity`,即可把其密码改成任意值并立即用 `Login.Pwd` 登录;`Verify` 完全不校验 `in.Code`,无法充当门禁(其返回值 `StatusReply` 也不含任何 token,客户端拿不到可回传的凭据,链路本身不可闭合)。**叠加第 2 重破坏**:重置写入的是 `utils.Md5(password)`(无盐),而登录校验是 bcrypt(`login/pwd.go:27`),二者格式互斥——`Reset` 之后**该用户密码登录永久失效**,同时把凭据降级为可彩虹表秒破的裸 MD5(mgt 模块正是靠 `isBcrypt` 前缀判断兼容 MD5,见 `module/base/mgt/internal/logic/pub/login.go:131`,说明该模式在本仓库已被当作技术债)。 - **建议**:`Reset` 入参改为 `{phone, code, new_password}` 或一次性 `reset_token`(由 `Verify` 校验验证码后签发、Redis 5 分钟 TTL、用后即删);`Verify` 必须真正比对验证码并做尝试次数限制;统一改回 `bcrypt.GenerateFromPassword(..., cost≥10)` 并复用该用户 `salt`;同步失效 Redis 中该 identity 的 token(见 P1-2)。 #### 2. 验证码类接口完全不校验验证码:可凭手机号直接登录/注册任意账号 - **位置**:`module/base/passport/internal/logic/login/code.go:16`、`module/base/passport/internal/logic/login/quick.go:20`、`module/base/passport/internal/logic/register/code.go:13` - **证据**: ```go // logic/login/code.go:16-24 —— in.Code 仅判空 if in.Phone == "" || in.Code == "" || in.Country == "" { return nil, errcode.ErrInvalidArgument } pa, err := models.GetPassportAccountByField("phone", in.Phone) if err != nil { return nil, err } data, err := models.CheckPassportData(pa.ID, pa.Identity) // 直接进入签发 token 流程 ``` ```go // logic/login/quick.go:20-26 —— 同样只判空,随后「查不到就建号」 if in.Phone == "" || in.Code == "" || in.Country == "" { return nil, errcode.ErrInvalidArgument } var pa models.PassportAccount err = impl.DBService.Where("phone = ?", in.Phone).First(&pa).Error ``` ```go // logic/register/code.go:13-25 —— 只校验手机号格式与是否已存在 if in.Phone == "" || in.Code == "" { return nil, errcode.ErrInvalidArgument } matched, err := regexp.MatchString("^1[3456789]{1}\\d{9}$", in.Phone) ... found := models.PassportAccountExists("phone", in.Phone) ``` - **影响**:**P0 任意账号接管 + 批量刷号**。`POST /passport.Login/Code` 的唯一"凭据"是手机号(公开/可枚举),请求体里的 `code` 填任意非空字符串即可拿到该手机号账号的有效 JWT(`login/code.go:41` 写缓存、`code.go:47-58` 返回 token),无需短信、无需密码;`Register.Code` 同理可绕过短信校验批量注册。本模块内**不存在** `GetVerificationCodeCache` 的调用点(见 P0-8,该方法只在 `models/cache.go:131` 定义、全仓库零调用),即"验证码已实现但忘记接线"的猜测也不成立——校验环节从未被编写。 - **建议**:新增统一的 `verifyCode(phone, code)`:从 Redis 读取(key 含业务场景,避免注册/登录/找回验证码互相通用)、`subtle.ConstantTimeCompare` 比对、比对后立即删除(一次性)、单验证码最多 5 次尝试;三处入口全部接入,校验失败统一返回"验证码错误或已过期",不区分"未发送/已过期/错误"。 #### 3. 实名认证可自助伪造:`local` provider 直接置 `document_verify=1`,Jumio 回调无签名校验 - **位置**:`module/base/passport/internal/logic/verify/request.go:53`、`:72`、`module/base/passport/internal/logic/verify/jumio_callback.go:14` - **证据**: ```go // logic/verify/request.go:53-61 case "local": if !common.VerifyMapKeys(in.Args, []string{"type", "name", "number", "front", "back"}) { return nil, errcode.ErrInvalidArgument } err = LocalVerify(auth.ID, in.Args) ``` ```go // logic/verify/request.go:72-85 —— 只要 5 个 key 存在即写入"验证通过" func LocalVerify(authID uint, args map[string]string) error { err := impl.DBService.Model(&models.PassportData{}).Where("passport_id = ?", authID).Update("document_verify", 1).Error ... "document_number": args["number"], ``` ```go // logic/verify/jumio_callback.go:20-34 —— 记录日志后直接返回成功 printer.Info("Received Jumio KYC callback: %+v", in) // 1. Verify the callback signature/authenticity // 2. Update user verification status in database // For now, return success // In production, implement proper callback handling logic return &pb.StatusReply{Timeseq: time.Now().UnixMilli()}, nil ``` ```proto // proto/verify.proto:12 —— 回调无 JWT、无 HMAC,且是公开 RPC rpc JumioCallback(JumioCallbackPayload) returns (base_passport_blocks.StatusReply) {} ``` - **影响**:**P0 实名/KYC 状态可被任意用户伪造**。任何持有普通 JWT 的用户都能调用 `Verify.Request{provider:"local", args:{type:"id_card", name:"任意", number:"任意", front:"任意", back:"任意"}}`,把自己的证件验证状态写入护照表与 `passport_verify` 表(`document_verify=1`),并落库完全虚构的证件号码与图片地址(`request.go:78-85`)。若下游(订单/金融/风控)以 `document_verify` 或 `kyc_verify` 作为放行依据,等于 KYC 形同虚设。`JumioCallback` 本身既不校验 Jumio 的 `Authorization`/HMAC 签名,也不校验来源 IP 或 `scan_reference` 归属,目前虽只打日志(`in.ClientIp` 由调用方自带、可任意伪造),但一旦按注释补齐写库逻辑,即为**无签名可伪造的实名通过回调**。 - **建议**:删除 `local` provider 或将其限制为**仅测试环境、仅内网**(`ParseOptions{MustPrivateAllow:true}` + 环境判断),生产强制 Jumio;`Verify.Request` 返回的 `clientRedirectUrl` 使用 Jumio 官方 `successUrl/errorUrl`;`JumioCallback` 落实注释中 1-4 项:校验 Jumio webhook `Authorization` 头/签名、按 `scan_reference` + `customer_internal_reference`(形如 `ID_`,`request.go:46`)反查归属、加时间窗与幂等键(同一 `scan_reference` 只允许处理一次)。 #### 4. 资料更新接口用零值覆盖整行,导致权限、积分、全部验证状态被清空 - **位置**:`module/base/passport/internal/logic/account/set_data.go:17`、`:30`、`:52` - **证据**: ```go // logic/account/set_data.go:30-42 —— 未传字段在请求中即零值 var data = models.PassportData{ Nickname: in.Nickname, Avatar: in.Avatar, Sex: int8(in.Sex), Birthday: birthday, Country: in.Country, ... Province: in.Province, } // :45-53 impl.DBService.Model(&models.PassportData{}).Where("passport_id=?", auth.ID).Count(&cnt) if cnt == 0 { ... } else { impl.DBService.Where("passport_id=?", auth.ID).Updates(&data) // 无 Select/无 Omit 白名单 } ``` ```go // proto/account.proto:11 —— 注释明确要求「字段值为空或是0,将不更新此数据」 // 更新会员的信息数据,字段值为空或是0,将不更新此数据 rpc SetData(SetDataRequest) returns (base_passport_blocks.StatusReply) {} ``` - **影响**:**P0 必然数据损坏**。用户只想改昵称(`{nickname:"x"}`)时,GORM `Updates(&struct)` 会把 `rights`、`score`、`level`、`agency_id`、`staff_id`、`owner_id`、`owner_identity`、`email_verify`、`phone_verify`、`face_verify`、`document_verify`、`kyc_verify` 全部写成零值——权限被清空、积分等级归零、实名/手机/邮箱验证状态尽失,且不可逆(无审计、无备份逻辑)。同时 `:45` 的 `Count` 与 `:52` 的 `Updates` 返回值都被丢弃,`err` 仅在 `:55` 用于 `cnt==0` 分支的 `Create`,**更新失败也返回成功**,前端与用户都无法察觉。 - **建议**:改为显式白名单 + 只更新非零字段(`db.Model(&PassportData{}).Where(...).Select("nickname","avatar","birthday","sex","sign","cover").Updates(map[string]any{...})`),或者为每个字段用 `*string`/`*int32` 指针区分"未传"与"传空";补 `RowsAffected==0 → ErrNotFound`;`Count` 后的 `Create` 加并发冲突兜底(唯一索引或 `clause.OnConflict`)。 #### 5. JWT 签名密钥未接入 `Authorization.Key`,落入 SDK 公开默认值 - **位置**:`module/base/passport/internal/config/config.go:46`、`module/base/passport/internal/logic/common/token.go:10`、`etc/passport_prod.yaml:43` - **证据**: ```go // internal/config/config.go:46-67 —— 只处理端口/Addr/JwtExpire,从不调用 env.NewEnv() 或设置 JwtSecretKey func New(srvKey string) { conf.New(srvKey, &Spec) Spec.Port = conf.CheckPort(Spec.Port) ... if Spec.Token != nil && Spec.Token.Expire > 0 { _vars.JwtExpire = ... } else { _vars.JwtExpire = 24 * time.Hour } ``` ```go // internal/logic/common/token.go:9-10 —— 直接用 env.Runtime.JwtSecretKey func GenerateTokenAes(id uint, identity, client, role string, extend map[string]string) (string, error) { token, err := token.New(env.Runtime.JwtSecretKey).GenerateJwt(id, identity, client, role, nil, extend) ``` ```go // 依赖 bsm-sdk/core@v0.2.1/env/env.go:17-19 —— 未设置即使用硬编码默认值 Runtime = &types.RuntimeEnv{ JwtSecretKey: GetEnvDefault("BSM_JwtSecretKey", "Cblocksmesh2022C"), ``` ```yaml # etc/passport_prod.yaml:43 —— 配置里的 SecretKey 与 JWT 无任何关系 SecretKey: CHANGE_ME ``` - **影响**:**P0 令牌可伪造**。`export` 环境未设置 `BSM_JwtSecretKey` 时,签名密钥退化为源码中公开的常量 `Cblocksmesh2022C`(任意攻击者可离线复现),配合 `token.New(...).GenerateJwt(id, identity, ...)` 的 HS256,即可**自签任意 `id`/`identity`/`extend` 的 JWT**,直接调用 `Account.Get/SetData/SetPassword/TagCreate` 与 `Verify.Request` 等全部需鉴权接口,绕过所有 `ParseMetaCtx`。对照:同仓库 `pkgs/all/internal/config/config.go:63-73` 明确要求 `Authorization.Key` 非空且必须 16/24/32 字节否则 `panic`,`passport` 完全没有这层校验,`SrvConfig` 里甚至没有 `Authorization` 字段。此外 `token.ParseJwt` 未校验 `token.Method`,属已知的算法混淆风险面(`bsm-sdk/core@v0.2.1/crypto/token/jwt.go:64-73`)——该点属 SDK 缺陷,此处仅作提示。 - **建议**:`SrvConfig` 增补 `Authorization conf.AuthorizationConf`,在 `config.New` 中仿照 `pkgs/all` 校验 key 非空且长度为 16/24/32 后执行 `env.NewEnv().JwtSecretKey = Spec.Authorization.Key`,缺省或长度非法直接 `panic` 拒绝启动;生产密钥由环境变量/密钥管理下发,禁止任何形式的默认值;同时给 `ParseJwt` 增加 `jwt.WithValidMethods([]string{"HS256"})`(需 SDK 侧改动,登记为跨模块事项)。 #### 6. 注册不做账号唯一性校验 + 无唯一索引,可注册重复账号并接管他人登录 - **位置**:`module/base/passport/internal/logic/register/code.go:19`、`module/base/passport/internal/logic/register/pwd.go:18`、`module/base/passport/internal/logic/register/do.go:37`、`module/base/passport/internal/models/passport_account.go:30` - **证据**: ```go // logic/register/pwd.go:17-21 —— 只校验 account 是否存在,但 Code 路径不校验 account found := models.PassportAccountExists("account", in.Account) if found { return nil, errcode.ErrAlreadyExists } ``` ```go // logic/register/do.go:37-50 —— Code 请求的 account 可为任意值,与已验证的 phone 无绑定 var account = in.Account if account == "" { account = in.Phone } pa := models.PassportAccount{ ..., Account: account, Phone: in.Phone, Password: in.Password, Salt: salt } ``` ```go // models/passport_account.go:30-34 —— 无 uniqueIndex、无 index Account string `gorm:"column:account;type:varchar(255);default:'';" json:"account"` Phone string `gorm:"column:phone;type:varchar(20);default:'';" json:"phone"` Email string `gorm:"column:email;type:varchar(255);default:'';" json:"email"` ``` - **影响**:**P0 账号接管 + 数据污染**。`Register.Code` 只对 `phone` 查重,攻击者可用 A 手机号(`code` 仍不校验,见 P0-2)注册时把 `account` 填成**已存在的 B 账号名**,系统照单插入;此后 `Login.Pwd` 用 `GetPassportAccountByField("account", ...)` + `First(&data)`(`models/passport_account.go:52`、`login/pwd.go:22`)**只命中排序第一条**,攻击者即可通过"抢注同名 account"使自己的密码命中他人账号,或制造大量同名账号使正常用户间歇性登录到他人账号。同时 `phone` 同样无唯一索引,`Quick` 路径的"查不到就建号"(`login/quick.go:26-45`)在并发下会插入重复手机号账号,`Account`/`PassportData` 也未包在同一事务(见 P1-5)。此外 `do.go:34` 直接覆写入参 `in.Password` 为哈希值,`do.go:54` 的错误分支又把整个 `pa`(含哈希与 salt)打进日志(见 P2-9)。 - **建议**:`passport_account` 增加 `account`、`phone` 唯一索引(`gorm:"uniqueIndex"`,并在迁移中对历史重复数据先做清洗);`do.go` 的插入统一走 `OnConflict/唯一约束` 兜底并把 `ErrDuplicatedKey` 映射为 `ErrAlreadyExists`;`Code` 路径强制 `account = phone` 或对 `account` 也做查重;`Do` 返回新副本而非修改入参;日志脱敏。 #### 7. 密码哈希使用 bcrypt 最低代价(cost=4),抗爆破能力约为默认值的 1/64 - **位置**:`module/base/passport/internal/logic/register/do.go:30`、`module/base/passport/internal/logic/account/set_password.go:35`、`module/base/passport/internal/models/query.go:21` - **证据**: ```go // logic/register/do.go:28-34 if in.Password != "" { salt = utils.UUID() hashedPassword, err := bcrypt.GenerateFromPassword([]byte(in.Password+salt), bcrypt.MinCost) ``` ```go // logic/account/set_password.go:35 hashedPassword, err := bcrypt.GenerateFromPassword([]byte(in.NewPassword+pa.Salt), bcrypt.MinCost) ``` ```go // models/query.go:21 —— 种子账号同样 MinCost,且 err 被丢弃 hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("welcome"+salt), bcrypt.MinCost) ``` - **影响**:`bcrypt.MinCost` = 4(`golang.org/x/crypto/bcrypt`),成本为默认 `DefaultCost`=10 的 2^-6;单卡 GPU 场景下离线爆破吞吐提高约 64 倍。叠加 P0-8「无任何在线限流」(不限尝试次数、不限频),在线口令喷洒同样不受约束。`README.md:280` 宣称 "bcrypt with salt for secure password storage",与实际代价不符。`query.go:21` 的 `_ =` 忽略错误一旦失败会把**空串**作为密码写入 demo 账号(该函数当前被注释掉,`cmd/main/main.go:37`,风险等级被降级,但仍需一并修正)。 - **建议**:三处统一改为 `bcrypt.DefaultCost`(或按实测调到 12),封装 `models.HashPassword(pwd, salt)` / `models.VerifyPassword` 单一实现;`query.go:21` 的错误必须处理,`InitData`(当前被注释)建议直接删除或移出启动路径(见 P2-8)。 #### 8. 防爆破与验证码存储机制全部是死代码,登录路径零限流、零锁定 - **位置**:`module/base/passport/internal/models/cache.go:125`、`:145`、`:159`、`:169`、`module/base/passport/internal/logic/login/pwd.go:18` - **证据**: ```go // models/cache.go:145-156 —— 有实现 func IncrementLoginAttempts(ctx context.Context, account string) (int64, error) { key := impl.RedisService.BuildKey("loginattempts", account) result := impl.RedisService.Client.Incr(impl.RedisService.Ctx, key) if result.Err() != nil { return 0, result.Err() } impl.RedisService.Client.Expire(impl.RedisService.Ctx, key, 15*time.Minute) return result.Val(), nil } ``` ```go // 全仓库交叉验证:grep "IncrementLoginAttempts|GetLoginAttempts|ClearLoginAttempts| // GetVerificationCodeCache|SetVerificationCodeCache|InvalidateVerificationCodeCache" // 仅命中 models/cache.go 自身的定义行,logic/ 下 0 处调用。 ``` ```go // logic/login/pwd.go:18-30 —— 密码校验前后没有任何计数/锁定/图形验证码 err = bcrypt.CompareHashAndPassword([]byte(pa.Password), []byte(in.Password+pa.Salt)) if err != nil { return nil, errcode.NewError(187, "Passport Error") } ``` - **影响**:`IncrementLoginAttempts`/`GetLoginAttempts`/`ClearLoginAttempts` 以及验证码的 `Set/Get/Invalidate` 六个函数**从未被调用**,模块对密码爆破、验证码爆破(一旦按 P0-2 修复接入验证码,若不同时启用计数则验证码可被在线枚举 10^6 次)没有任何防护;README 的 "Rate Limiting: Built-in request rate limiting" 属不实描述。`pwd.go:27` 失败时也未区分"用户不存在"与"密码错误"之外的任何节流。 - **建议**:在 `login.Pwd`/`login.Code`/`register.Code`/`forget.Verify` 中接入现有的计数函数:`账号+IP` 双维度计数,5 次失败锁定 15 分钟并返回 `ErrUnavailable`,成功登录 `ClearLoginAttempts`;验证码比对失败同样计数并在 N 次后作废该验证码;COUNT 逻辑放到限流中间件以避免未来重复实现。 ### P1 #### 9. 登录失败信息可用于账号枚举(404/187 双通道),且与注册接口互相印证 - **位置**:`module/base/passport/internal/models/passport_account.go:55`、`module/base/passport/internal/logic/login/pwd.go:29` - **证据**: ```go // models/passport_account.go:53-57 —— 查不到返回带文案的 404 if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errcode.ErrNotFound(404, "Account not found") } ``` ```go // logic/login/pwd.go:27-30 —— 密码错误返回 187 + 通用文案 err = bcrypt.CompareHashAndPassword([]byte(pa.Password), []byte(in.Password+pa.Salt)) if err != nil { return nil, errcode.NewError(187, "Passport Error") } ``` - **影响**:攻击者以任意密码请求 `POST /passport.Login/Pwd`,按返回区分 `"Account not found"`(账号不存在)与 `"Passport Error"`(账号存在、密码错误),即可批量枚举有效账号;`Register.Pwd` 的 `ErrAlreadyExists`(`register/pwd.go:19`)与 `Register.Code` 的 `ErrAlreadyExists`(`register/code.go:26`)提供第二条确认通道(手机号/账号是否已注册)。枚举结果直接喂给 P0-1(重置他人密码)与 P1-9 的爆破链路。 - **建议**:登录失败统一返回同一错误码与同一文案(如 `ErrPassword`)并在**等到密码比较完成后**才返回(对不存在的账号执行一次等时的 dummy bcrypt 比较,避免时间侧信道);注册接口对已存在账号/手机号返回中性提示或引入图形验证码,避免把"已注册"直接暴露为可批量探测的信号。 #### 10. 改密后旧 JWT 仍可用 24 小时,无登出,token 缓存写而不查 - **位置**:`module/base/passport/internal/logic/account/set_password.go:40`、`module/base/passport/internal/logic/login/code.go:41`、`module/base/passport/internal/logic/login/pwd.go:48`、`module/base/passport/internal/models/cache.go:105` - **证据**: ```go // logic/account/set_password.go:40-44 —— 只改 password,不动任何会话/缓存 err = impl.DBService.Model(&models.PassportAccount{}).Where("id = ? ", auth.ID).Update("password", string(hashedPassword)).Error ``` ```go // logic/login/pwd.go:48 vs logic/login/code.go:41 —— TTL 不一致,且 code 路径不写缓存 err = impl.RedisService.Client.Set(impl.RedisService.Ctx, vars.TokenPrefix+pa.Identity, token, _vars.JwtExpire).Err() err = impl.RedisService.Client.Set(impl.RedisService.Ctx, vars.TokenPrefix+pa.Identity, token, 0).Err() ``` ```go // bsm-sdk/core@v0.2.1/service/meta.go:31-34 —— 鉴权只验签名 claims, err := token.New(env.Runtime.JwtSecretKey).ParseJwt(Authorizations[0]) ``` - **影响**:JWT 本身不可撤销,而模块既没有 `Logout` RPC,也没有在改密/禁用时失效会话的代码,`:48`/`:41` 写入的 Redis token **从未被任何代码读取**(`models/cache.go:111` 的 `GetTokenCache` 零调用),服务重启或账号被盗后已泄露的 token 在 24 小时内始终有效。`code.go:41` 用 `ttl=0`(永不过期)进一步放大:同一 identity 若曾走验证码登录,其 Redis 值永久残留,而 `Token.Prefix`(`config.Spec.Token.Prefix`)与 `vars.TokenPrefix`(`/TOKEN/`)两种前缀并存,导致同一用户可能存在两份语义不同的键(见 P2-11)。 - **建议**:`SetPassword`、后续的禁用/删除账号流程统一执行 `models.InvalidateTokenCache` + `InvalidateUserCache`;引入 token 版本号(JWT 内加 `ver`,账号表存版本,改密自增)或在鉴权层比对 Redis 中的当前 token;补 `Logout` RPC 并删除孤立键;统一前缀与 TTL 常量。 #### 11. 数据更新与读取路径大量吞错,接口返回成功但实际未生效 - **位置**:`module/base/passport/internal/logic/account/set_data.go:45`、`:52`、`module/base/passport/internal/logic/account/get.go:25`、`:44`、`module/base/passport/internal/logic/account/statistics.go:36`、`:43`、`module/base/passport/internal/logic/verify/request.go:78` - **证据**: ```go // logic/account/set_data.go:44-53 —— 两次 DB 调用的 Error 均未检查 var cnt int64 impl.DBService.Model(&models.PassportData{}).Where("passport_id=?", auth.ID).Count(&cnt) if cnt == 0 { ... } else { impl.DBService.Where("passport_id=?", auth.ID).Updates(&data) } if err != nil { // err 只可能来自 cnt==0 分支的 Create ``` ```go // logic/account/get.go:25,44 —— 标签查询与资料查询的 Error 未检查 impl.DBService.Model(&models.PassportTags{}).Where("passport_id=?", AUTH.ID).Find(&tagRecords) ... impl.DBService.Where("passport_id=?", AUTH.ID).First(data) ``` ```go // logic/account/statistics.go:36-38 —— 统计查询同样忽略 Error impl.DBService.Model(&models.PassportAccount{}).Where("id = ?", AUTH.ID).Count(&count) ``` - **影响**:写路径吞错使 P0-4 的静默数据损坏无法被调用方发现(用户看到 `StatusReply` 成功);读路径吞错会把数据库故障伪装成"用户无标签/无资料/统计数据为 0",排障时完全失去线索。`verify/request.go:78-85` 的 `Updates(map[...])` 若失败同样被吞入返回 `ErrDB`(此处有检查,但 `:73` 的 `document_verify` 已先提交,形成**半更新**:`document_verify=1` 成功而 `passport_verify` 明细失败)。 - **建议**:所有 GORM 调用检查 `.Error` 并映射为 `ErrDB`(读路径可降级但必须 `printer.Error` 记录);`SetData` 与 `LocalVerify` 的多表写用 `DBService.Transaction` 包起来(见下条)。 #### 12. 多表写入无事务:注册与快捷登录会产生孤儿/不一致数据 - **位置**:`module/base/passport/internal/logic/register/do.go:53`、`:70`、`module/base/passport/internal/logic/login/quick.go:41`、`:59`、`module/base/passport/internal/logic/verify/request.go:73`、`:78` - **证据**: ```go // logic/register/do.go:52-73 —— 两次独立 Create,无 Transaction if err := impl.DBService.Create(&pa).Error; err != nil { return nil, errcode.ErrDB } data := models.PassportData{ Std_Passport: types.Std_Passport{PassportID: pa.ID, PassportIdentity: pa.Identity}, ... } if err := impl.DBService.Create(&data).Error; err != nil { printer.Error(...); return nil, errcode.ErrDB } ``` ```go // logic/verify/request.go:73-85 —— 先改 passport_data,再改 passport_verify,无事务 err := impl.DBService.Model(&models.PassportData{}).Where("passport_id = ?", authID).Update("document_verify", 1).Error ... err = impl.DBService.Model(&models.PassportVerify{}).Where("passport_id = ?", authID).Updates(map[string]any{...}).Error ``` - **影响**:`passport_account` 插入成功而 `passport_data` 失败时,账号已占用 `account`/`phone`(不可能重试成功),却永久缺少资料行——后续 `Login.Pwd` 的 `CheckPassportData` 虽会自动补建(`models/passport_data.go:62-70`),但 `Register.Quick` 路径与登录取 `rights`/验证状态的逻辑会拿到空记录;`LocalVerify` 的半更新则直接造成"凭证状态=已认证,但无任何明细/时间戳"的不一致数据。注意 `models/query.go:50` 已有一个 `CreateAccount` 事务封装,但**全仓库零调用**(死代码),说明事务能力存在却未被使用。 - **建议**:`Do`、`Quick` 的新建流程、`LocalVerify` 统一改为 `impl.DBService.Transaction(func(tx *gorm.DB) error {...})`;顺带删除或复用 `CreateAccount`,避免两套写法并存。 #### 13. 手机号唯一性/国家码校验缺失,跨国家码可导致账号合并 - **位置**:`module/base/passport/internal/logic/login/quick.go:26`、`module/base/passport/internal/logic/login/code.go:21`、`module/base/passport/internal/logic/register/code.go:19` - **证据**: ```go // logic/login/quick.go:26 —— 只按 phone 查,country 仅用于新建 PassportData err = impl.DBService.Where("phone = ?", in.Phone).First(&pa).Error ... Country: in.Country, AgencyId: uint(in.AgencyId), ... ``` ```go // logic/register/code.go:19-27 —— 国家码完全未参与唯一性判断 matched, err := regexp.MatchString("^1[3456789]{1}\\d{9}$", in.Phone) ``` - **影响**:账号唯一键只有 `phone` 而忽略 `country`(`LoginByCodeRequest.Country` 必填却在查号时被丢弃),同一个本地号码在不同国家码下(如 `CN`/`US` 的短号、或号码前缀重叠)会被判定为同一账号:攻击者可用自己的国家码 + 受害者的本地号码触发 `Quick`,命中受害者账号并直接拿到其 token(`quick.go:81-98`)。`Country` 也从不校验合法性,可传任意字符串写入资料表。 - **建议**:唯一性与查询键统一为 `(country, phone)` 复合维度并建设复合唯一索引;`country` 做白名单(ISO-3166 alpha-2)或与手机号校验规则(libphonenumber)绑定;`LoginByCode`/`Register.Code` 的手机号正则按 `country` 分支,当前硬编码中国大陆正则(见 P3-14)。 #### 14. 关键查询字段全部无索引/唯一约束,登录路径为全表扫描 - **位置**:`module/base/passport/internal/models/passport_account.go:30`、`:31`、`:32`、`module/base/passport/internal/models/passport_provider.go:16`、`:17` - **证据**: ```go // models/passport_account.go:30-34 —— 无 index/uniqueIndex Account string `gorm:"column:account;type:varchar(255);default:'';" json:"account"` Phone string `gorm:"column:phone;type:varchar(20);default:'';" json:"phone"` Email string `gorm:"column:email;type:varchar(255);default:'';" json:"email"` ``` ```go // models/passport_provider.go:16-17 —— 两个字段各自普通索引,无 (provider, provider_id) 复合唯一键 Provider string `gorm:"type:varchar(20);index;not null" json:"provider"` ProviderID string `gorm:"type:varchar(255);index;not null" json:"provider_id"` ``` - **影响**:`Login.Pwd`(按 `account`)、`Login.Code`/`Quick`/`Forget.Verify`/`Register.Code`(按 `phone`)、找回(按 `identity`)全部落在无索引列上,随账号量增长退化为全表扫描;登录属最高频路径,直接放大 P1-11 的性能风险。`passport_provider` 的 `(provider, provider_id)` 无唯一约束则允许同一第三方身份绑定出多条记录(该表当前无逻辑使用,风险暂缓)。此外 `index` tag 存在但未覆盖查询组合,等于无效索引。 - **建议**:为 `account`、`phone` 补 `uniqueIndex`,`email`、`identity` 补 `index`;`passport_provider` 补 `uniqueIndex:idx_provider_pid (provider, provider_id)`;新增索引需评估线上表体量并走在线 DDL;同时为 `Phone` 的(varchar20)长度与统一格式(`E.164`)做数据清洗。 #### 15. `Statistics` 的 `login_count` 统计的是账号行数,恒为 1 - **位置**:`module/base/passport/internal/logic/account/statistics.go:31`、`:36` - **证据**: ```go // logic/account/statistics.go:33-39 case "login_count": // Count login records for this user var count int64 impl.DBService.Model(&models.PassportAccount{}). Where("id = ?", AUTH.ID). Count(&count) result[field] = count ``` - **影响**:按 `passport_account.id = AUTH.ID` 计数必然得到 0 或 1,`login_count` 永远返回 1,调用方(若用于风控/活跃度展示)会得到完全错误的信号。项目内没有登录记录表,`PassportStatistics`(`models/passport_statistics.go:15-20`,含 `Item`/`Value`)这一本该承载统计的表**从未被读写**。 - **建议**:明确语义后在登录成功路径写入 `passport_statistics`(`item='login_count'` 做原子自增)或新增 `passport_login_log` 表并按其计数;`tag_count` 的 `Count` 错误处理一并补上(P1-11);不支持的 field 目前静默返回 0,建议返回 `ErrInvalidArgument` 或明确文档化。 ### P2 #### 16. 缓存层整体未被使用,登录/资料路径无缓存收益且存在缓存一致性隐患 - **位置**:`module/base/passport/internal/models/cache.go:20`、`:43`、`:66`、`:89`、`module/base/passport/internal/logic/account/get.go:25` - **证据**: ```go // models/cache.go:20 —— GetAccountByCache 只为...自身存在(grep 全仓库零调用) func GetAccountByCache(ctx context.Context, field, value string) (*PassportAccount, error) { ``` ```go // logic/account/get.go:25,44 —— 逻辑层直接查库,绕过 cache.go 提供的封装 impl.DBService.Model(&models.PassportTags{}).Where("passport_id=?", AUTH.ID).Find(&tagRecords) impl.DBService.Where("passport_id=?", AUTH.ID).First(data) ``` - **影响**:`cache.go` 的 13 个函数(读取/失效/userdata/tags/token/验证码/登录计数)**全部零调用**,登录每次至少 2 次 DB 往返(`login/pwd.go:22` + `CheckPassportData`),`Account.Get` 2 次,`SetData` 3 次(`Count`+`Updates`);标签增删(`account/tag_create.go:47`、`tag_remove.go:31`)也从不失效 `usertags` 缓存。当前因缓存未被启用而未爆发脏数据,但一旦有人按 `cache.go` 的命名接入,会立刻出现"改资料后仍读到旧 rights"的一致性 bug(`SetData`/`SetPassword` 都没有调用 `InvalidateUserCache`)。 - **建议**:要么删除 `cache.go` 全部死代码(推荐,先清障),要么成对接线:读路径用 `GetAccountByCache`/`GetUserDataByCache`/`GetUserTagsByCache`,写路径(`SetData`/`SetPassword`/`TagCreate`/`TagRemove`)统一调用 `InvalidateUserCache`;标签与资料缓存键必须与失效键完全一致(当前 `BuildKey("userdata", id)` 与 `BuildKey("account","id",id)` 并存,极易错配)。 #### 17. 启动配置校验缺失:KYC/Token 未校验、`SecretKey` 无 yaml tag、配置结构与实际不符 - **位置**:`module/base/passport/internal/config/config.go:15`、`:23`、`:28`、`module/base/passport/etc/passport_prod.yaml:36`、`:29` - **证据**: ```go // internal/config/config.go:15-34 —— SecretKey 不在 SrvConfig 中(无 yaml tag),Kyc 字段无任何校验 type SrvConfig struct { conf.Base `yaml:",inline"` ... WeChat *WeChatConf `yaml:"WeChatConf"` Token *TokenConf `yaml:"Token"` Kyc *KycConf `yaml:"Kyc"` } ``` ```go // internal/config/config.go:55-64 —— 只校验 Service/Cache 非空 conf.NotNil(Spec.Service, Spec.Cache) if Spec.Token != nil && Spec.Token.Expire > 0 { ... } else { _vars.JwtExpire = 24 * time.Hour } ``` ```yaml # etc/passport_prod.yaml:29-43 —— Kyc: 下 BaseUrl/ApiArgs 是占位符 "123";顶部还有一个永不生效的 KycConf 块 Kyc: Provider: jumio BaseUrl: 123 ApiArgs: 123 KycConf: BaseUrl: 123 ``` - **影响**:`BaseUrl: 123` 会在运行时才以 `http.NewRequest` 失败暴露(`verify/request.go:109`),属"启动成功、调用即挂"的配置;`Token.Prefix`/`Kyc.ApiToken` 未校验空值;`SecretKey: CHANGE_ME` 因 `SrvConfig` 中没有对应字段而被静默忽略,给运维造成"已配置密钥"的错觉(真实密钥走 `BSM_JwtSecretKey`,见 P0-5);`KycConf` 是无效重复块,生产/测试 yaml 与 dev 高度雷同(prod 与 test 直接共用 `dbname=bsm_dev`、`127.0.0.1`),存在误连测试库的风险。`config.go:60` 的 `time.Duration(Spec.Token.Expire * int(time.Second))` 先转 int 再乘,语义正确但写法可疑,属可读性问题。 - **建议**:`config.New` 中补校验:`Token.Prefix` 非空、`Kyc` 非空且 `BaseUrl` 可被 `url.Parse` 且 scheme 为 https、`Provider` 属白名单;`SecretKey` 明确删除或补 yaml tag;生产 yaml 必须使用独立库名/主机;删除 `KycConf` 无效块。 #### 18. 外部 Jumio 调用缺健壮性:不传 context、无重试/熔断、超时硬编码 - **位置**:`module/base/passport/internal/logic/verify/request.go:92`、`:108`、`:118` - **证据**: ```go // logic/verify/request.go:47 —— 有 ctx 却不向下传 resp, err := InitiateJumioScan(id, auth.Identity) ``` ```go // logic/verify/request.go:103-122 jsonBody, err := json.Marshal(reqBody) client := &http.Client{Timeout: 10 * time.Second} req, err := http.NewRequest(http.MethodPost, config.Spec.Kyc.BaseUrl, bytes.NewBuffer(jsonBody)) ... resp, err := client.Do(req) ``` - **影响**:`context` 在 `Request`→`InitiateJumioScan` 之间被丢弃(`http.NewRequest` 而非 `NewRequestWithContext`),客户端取消/超时无法中断外部调用,gRPC 连接释放后仍占用 goroutine;10s 超时与无重试策略均为硬编码魔法数字;`CallbackURL` 直接取 `config.Spec.Kyc.ApiArgs`(`:100`),字段语义严重错位(`ApiArgs` 的注释/命名与"回调地址"毫无关系,README 也写作 "additional_args",`README.md:94`),极易配置错误导致 Jumio 回调打不到服务(进而使 P0-3 的回调链路静默失效);无熔断/降级,Jumio 故障时每个请求都吃满 10s。 - **建议**:`InitiateJumioScan(ctx, ...)` 贯穿 context 并使用 `http.NewRequestWithContext`;超时/重试次数/退避提取为配置项(`Kyc.Timeout`、`Kyc.Retry`);`CallbackURL` 单独建字段(如 `Kyc.CallbackURL`)并做非空+https 校验;加错误计数与告警。 #### 19. 敏感数据明文存储与明文日志(密码哈希/盐、证件号、姓名、图片地址) - **位置**:`module/base/passport/internal/logic/register/do.go:54`、`module/base/passport/internal/logic/verify/jumio_callback.go:21`、`module/base/passport/internal/models/passport_verify.go:36`、`module/base/passport/internal/logic/verify/request.go:82` - **证据**: ```go // logic/register/do.go:53-55 —— 把含 password(哈希) 与 salt 的整个对象打进日志 if err := impl.DBService.Create(&pa).Error; err != nil { printer.Error("create passport account and password extend by data %+v error:%v", pa, err) ``` ```go // logic/verify/jumio_callback.go:20-21 —— 整个回调(含证件号/姓名/customerInternalReference)进日志 // Log the callback for audit purposes printer.Info("Received Jumio KYC callback: %+v", in) ``` ```go // models/passport_verify.go:35-39 —— 证件明细字段无任何加密/脱敏 DocumentType string `gorm:"type:varchar(50)"` // 如: 'id_card', 'passport' DocumentName string `gorm:"type:varchar(100)"` DocumentNumber string `gorm:"type:varchar(100)"` DocumentFront string `gorm:"type:varchar(255)"` // 证件正面 ``` - **影响**:`do.go:54` 在注册失败(含高并发抢注、唯一约束冲突等**最常触发**路径)时把密码哈希与盐写入应用日志;`jumio_callback.go:21` 把姓名、证件号、`customer_internal_reference`(内含 passportID)写入日志。证件姓名/号码/正反面图片地址以明文存于 `passport_verify`,`LocalVerify` 还会把客户端自报的任意值直接落库(P0-3),无 KMS/字段级加密、无脱敏输出;`Account.GetFullReply` 也会原样返回 `phone`/`email`(`logic/account/get.go:38-39`,仅限本人,风险可控)。README 宣称 "Structured Logging",但实际是格式化字符串日志。 - **建议**:日志统一剔除敏感字段(引入 `Printer` 脱敏包装或只打 `id`/`scan_reference` 的哈希);证件类字段评估字段级加密(AES-GCM + 密钥管理)或至少做存储侧脱敏 + 访问审计;输出侧对手机号/证件号做掩码;为敏感字段访问补审计日志表。 #### 20. 测试几乎不存在,关键链路零覆盖 - **位置**:`module/base/passport/test/grpc/main.go:19`、`:45`、`test/readme.md:1` - **证据**: ```go // test/grpc/main.go:15-17,45 —— 唯一"测试"是 main 程序,写死线上地址与一枚真实 JWT const ( address = "api.apinb.com:10020" // 本地测试地址 ) newMetaData.Set("authorization", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NywiaWRlbnRpdHkiOiIwMTk5M2RmMy0yMjk4LTdlZGYtOTIwMS1mYmNhNmI5NDg3NzMiLCJleHRlbmQiOnsicmlnaHRzIjoiIn0sImNsaWVudCI6IiIsIm93cGVyIjpudWxsLCJyb2xlIjoiIiwiZXhwIjoxNzYwNDEzODE0LCJuYmYiOjE3NjAzMjc0MTQsImlhdCI6MTc2MDMyNzQxNH0.3e5O76M6NRxqL8CDQyojA6Qom0dsJWrNPX2PcP8VPig") ``` ``` # test/readme.md:1 restful test ``` - **影响**:`glob **/*_test.go` 返回 **0 个文件**——没有任何单元/集成测试;唯一可执行体是连真实环境的 `main`(无 `Test` 函数,`go test ./...` 不会执行到它),且把一枚真实 JWT(`id=7`,`exp=1760413814`,即 2025-10-14 03:50:14 UTC 到期)与生产域名硬编码进仓库,构成凭据泄露与"测试脚本直连生产"的双重风险(`README.md:254-256` 却宣称 `go test ./...` 可用)。 - **建议**:删除或迁出写死的 token(改为测试专用短时 token + 本地 mock 服务);补齐关键用例清单(见 §5 TODO);把 `test/grpc` 改造成 `_test.go` 并默认指向本地容器。 #### 21. 可观测性与运行期能力缺失:无健康检查、无优雅退出、无 CORS/限流中间件 - **位置**:`module/base/passport/cmd/main/main.go:24`、`module/base/passport/etc/passport_prod.yaml:20`、`README.md:284`、`:291` - **证据**: ```go // cmd/main/main.go:24-43 —— 仅 Start/Stop,无健康检查注册、无 signal 处理 srv := service.New(s.Grpc, &service.Options{ ... GatewayMux: s.Mux }) // 停止服务 defer srv.Stop() srv.Start() ``` ```yaml # etc/passport_prod.yaml:18-22 —— 只声明匿名白名单 passport.ping.hello,但模块内并无 ping/hello RPC MicroService: Enable: false Anonymous: - passport.ping.hello ``` - **影响**:`Anonymous` 白名单指向的 `passport.ping.hello` 在 5 个 proto 中**不存在**(无 health/hello 接口),服务没有可用的存活/就绪探针;README 的 "Health Checks / Structured Logging / Metrics / CORS Support / Rate Limiting"(`README.md:278-293`)在代码中均无对应实现(`grep -i cors` 在模块内 0 命中),对外声明与实际能力不符,会误导部署与安全评审。`defer srv.Stop()` 在 `Start()` 阻塞返回后才执行,是否等待在途请求取决于 SDK 实现(未能验证)。 - **建议**:实现 `Health/Ready` 接口(校验 DB/Redis/Etcd 连通)并登记到白名单;确认 `service.New` 的停止语义并在收到 SIGTERM 时先摘除注册再等待在途请求;限流/鉴权以中间件形式在 `service/exposed` 层统一挂载(同时解决 P0-8);同步修订 README,去掉未实现的能力声明。 ### P3 #### 22. 死代码与未完成实现清单(逐条) - **位置**:`module/base/passport/internal/models/cache.go:20-172`、`module/base/passport/internal/models/query.go:12`、`:50`、`module/base/passport/internal/models/passport_provider.go:12`、`module/base/passport/internal/models/passport_statistics.go:15`、`module/base/passport/internal/models/passport_score.go:15`、`module/base/passport/internal/models/passport_notify.go:15`、`module/base/passport/internal/vars/provider.go:3`、`module/base/passport/internal/config/config.go:23`、`module/base/passport/internal/server/new.go:17`、`:39` - **证据**: ```go // models/query.go:50-64 —— 事务化建号封装,含注释「插入扩展表」,全仓库零调用 func CreateAccount(pa *PassportAccount, nickname string) (err error) { return impl.DBService.Transaction(func(tx *gorm.DB) error { ``` ```go // internal/server/new.go:17,29 —— grpcConns 连接池字段声明后从未使用 grpcConns map[string]*grpc.ClientConn // 连接池 ... grpcConns: make(map[string]*grpc.ClientConn), ``` ```go // internal/config/config.go:23 —— WeChatConf 配置项无任何消费代码 WeChat *WeChatConf `yaml:"WeChatConf"` ``` - **影响**:`cache.go` 全部 13 个函数、`InitData`(被 `cmd/main/main.go:37` 注释)、`CreateAccount`、`PassportProvider`(第三方登录表 + `vars.Provider_*` 6 个常量)、`PassportStatistics`、`PassportScore`、`PassportNotify`、`WeChatConf`、`server.grpcConns` 均为零调用/零读写的死代码或未实现能力;`Register.Do`(`README.md:157` 列为 RPC)与"发送注册验证码"(`README.md:155`)在 proto 与 logic 中都不存在。这些残留物会持续误导审计与开发(例如让人误以为已有登录限流、已有验证码存储、已有第三方登录)。 - **建议**:建立"未使用代码清理"变更:删除 `cache.go`(或按 P2-16 接线)、`CreateAccount`、`InitData`、`grpcConns`;对 `PassportProvider/Statistics/Score/Notify` 与 `WeChatConf` 明确标注 roadmap 或直接删除;同步修正 README 的服务清单(移除 `Register.Do`、`发送注册验证码`、`/v1/*` 路由表)。 #### 23. 错误码与文案复用、魔法数字、注释与实现不符 - **位置**:`module/base/passport/internal/logic/login/pwd.go:29`、`module/base/passport/internal/logic/account/set_password.go:32`、`module/base/passport/internal/logic/register/do.go:86`、`proto/forget.proto:11`、`proto/account.proto:8`、`proto/account.proto:32`、`proto/login.proto:31` - **证据**: ```go // logic/login/pwd.go:29 与 logic/account/set_password.go:32 —— 同一个魔法码 187/同一文案 return nil, errcode.NewError(187, "Passport Error") ``` ```go // logic/register/do.go:86 —— 日志里用了另一个包的 key 前缀,与实际写入的 config.Spec.Token.Prefix 不一致 printer.Error("Set redis cache by key %v error:%v", vars.TokenPrefix+pa.Identity, err) ``` ```proto // proto/forget.proto:11 —— 错别字「重罢密码」(forget/reset.go:16 同名注释) // 重罢密码 // proto/account.proto:8 —— 错别字「assport通行证模块」 // assport通行证模块-帐号数据 // proto/account.proto:32 / proto/login.proto:31 —— 非规范的 json_name 与空行 string email = 4; // 验证码 ← 注释写成"验证码"(应为邮箱) ``` - **影响**:`187` 与 `"Passport Error"` 被"密码错误/旧密码错误"等多个语义复用,前端无法区分;`do.go:86` 的错误日志会打印**从未写入过的键名**,故障时误导排障(真正的键是 `config.Spec.Token.Prefix+pa.Identity`,`:84`);proto 注释的错别字与错配会直接影响生成的 TS 客户端文档。`proto` 缺少 `google.api.http` 注解导致 REST 路径是 `POST /passport.Account/Get` 这类"gRPC 方法名直出"形式,与 README 的 `/v1/account`(`README.md:171-176`)完全不符——若对外承诺过 `/v1/*`,属破坏性不一致。 - **建议**:改用 SDK 的具名错误码(如 `errcode.ErrPassword`)并统一文案;日志/缓存键前缀抽成单一常量并修正 `do.go:86`;订正 proto 注释错别字;确定 REST 路由规范后补 `google.api.http` 注解并重新生成,同时更新 README。 #### 24. 注册路径手机号正则硬编码中国大陆,与 `country` 字段语义冲突;邮箱注册未实现 - **位置**:`module/base/passport/internal/logic/register/code.go:19`、`module/base/passport/internal/logic/register/do.go:41`、`proto/register.proto:18` - **证据**: ```go // logic/register/code.go:18-22 //手机号验证 matched, err := regexp.MatchString("^1[3456789]{1}\\d{9}$", in.Phone) if err != nil || !matched { return nil, errcode.ErrInvalidArgument } ``` ```go // logic/register/do.go:41-50 —— email 字段被完全忽略,从未写入 PassportAccount.Email pa := models.PassportAccount{ Std_IICUDS: types.Std_IICUDS{Identity: utils.UUID(), Status: vars.Status_Normal}, Account: account, Phone: in.Phone, Password: in.Password, Salt: salt, } ``` - **影响**:`RegisterRequest.Country`(`proto/register.proto:17`)声明支持"eg CN US",但校验只接受中国大陆 11 位号码,海外用户无法注册;`RegisterRequest.Email`(`proto/register.proto:18`)被静默丢弃,`passport_account.email` 永远为空,而 `GetFullReply.Email`(`proto/account.proto:32`)却在展示它,且 `PassportData.EmailVerify` 存在却没有邮箱验证码/验证链路——"邮箱注册"实为未实现能力。`Login.Pwd` 的 `account` 与 `Phone` 也未做互斥校验,用户可用任意字符串当账号。 - **建议**:按 `country` 选择号码校验规则(引入 libphonenumber),或明确公告仅支持 CN 并从 proto 移除误导性注释/字段;若保留 `email`,则实现邮箱唯一性校验、验证码下发与验证状态流转,或在 proto 中标注 deprecated。 #### 25. proto/README 与实现、仓库内其它模块的横向不一致 - **位置**:`module/base/passport/README.md:150`、`:155`、`:157`、`:171`、`:284`、`module/base/passport/README.md:21`、`module/base/passport/README.md:222`、`:311` - **证据**: ```markdown #### 注册服务 (Register Service) - `Code()` - 发送注册验证码 ← 实际是"用验证码注册",不发送任何验证码 - `Pwd()` - 密码注册 - `Do()` - 完成注册流程 ← proto 中没有 Do RPC - **Rate Limiting**: Built-in request rate limiting ← 无实现(P0-8) - **CORS Support**: Configurable cross-origin resource sharing ← 无实现 ``` - **影响**:README 描述了未实现的安全能力与不存在的 RPC/路由,且 `swagger/`、`scripts/` 目录(`README.md:222-223`)在模块中并不存在,Docker 构建段还引用了它们;与实际 41 个非生成 Go 文件、无测试、无限流的现状严重脱节。审计与评审若以 README 为准会得出完全错误的结论。 - **建议**:以代码为准重写 README 的 API 表、安全特性表与目录树;把"Rate Limiting/CORS/Health/Metrics"移入 Roadmap 并注明未实现;删除对不存在的 `swagger/`、`scripts/` 的引用,或补齐对应产物。 ## 4. 推荐优化方案 按"先止血、再补机制、后清理"三层推进: **第一层:止血(1 个迭代内,对应 P0)** 1. **封死找回密码链路**:`Forget.Reset` 立即改为需要一次性 `reset_token`(或直接下线该 RPC 直到改造完成),`Forget.Verify` 真正校验验证码。若短期内无法改造,最小缓解是在网关层对该 RPC 追加 JWT 要求(`ParseMetaCtx`)——但这会破坏公开流程,需产品确认。 2. **接入验证码校验**:抽 `verifyCode(phone, code, scene)` 并接入 `Login.Code`/`Login.Quick`/`Register.Code`;同时启用 `IncrementLoginAttempts`/`ClearLoginAttempts`,这是同一处改动(P0-2 + P0-8)。 3. **统一密码哈希**:删除 `Reset` 中的 MD5,抽出 `models.HashPassword/VerifyPassword`(bcrypt `DefaultCost`),注册/改密/重置三处共用;对历史上已被 MD5 覆盖的账号做一次性重哈希或强制走找回流程(需离线数据核查)。 4. **修 `SetData` 覆写**:改白名单 `Select + Updates(map)`,并在同一次改动中补 `InvalidateUserCache`(P0-4 + P2-16 接线)。 5. **JWT 密钥治理**:`config.New` 接入 `Authorization.Key` 并做长度校验、缺失即 `panic`;生产环境注入 `BSM_JwtSecretKey`(P0-5)。这是全局影响项,需与其它模块(`pkgs/all`、`mgt` 等共用同一密钥体系)协同发布,否则跨模块 token 互认会断。 6. **建唯一索引 + 事务**:`passport_account(account)`、`(country,phone)` 唯一索引,注册/快捷登录/实名写入包事务(P0-6、P1-12、P1-14)。 **第二层:补机制(2-3 个迭代,对应 P1)** 7. `Verify.Request` 的 `local` provider 生产禁用 + `JumioCallback` 落实签名/归属/幂等三件套(P0-3)。 8. 登录失败统一文案 + 等时比较;会话撤销(token 版本号或 Redis 校验)与 `Logout` RPC(P1-9、P1-10)。 9. 补齐 `.Error` 检查并在读路径至少记录日志;`Statistics` 语义修正(P1-11、P1-15)。 10. 抽出中间件层承载限流/鉴权/CORS/脱敏日志,避免每个 logic 重复实现(P0-8、P2-19、P2-21)。 **第三层:清理与对齐(与功能迭代并行,对应 P2/P3)** 11. 死代码清理(`cache.go` 接线或删除、`CreateAccount`、`InitData`、`grpcConns`、`WeChatConf`、未实现的表/常量);proto 注释与 REST 路由规范统一;README 按代码重写(P3-22、P3-23、P3-25)。 12. 测试补齐:以表驱动单测覆盖验证码校验、密码哈希往返、`SetData` 白名单、`Reset` 鉴权、`JumioCallback` 签名拒绝五条主线,接入 CI(P2-20)。 **跨模块依赖提示**:P0-5(JWT 密钥)与 `pks/all`、`module/base/mgt` 共享 `env.Runtime.JwtSecretKey` 与 token 格式;P1-10 的 token 版本号方案会改变 JWT Claims 结构,须与网关/其它模块的 `ParseMetaCtx` 使用方共同评审。 ## 5. TODO 清单 - [ ] **P0-1** 下线/改造 `Forget.Reset`:改为一次性 `reset_token` 且必须由 `Forget.Verify` 校验验证码后签发|验收:无 token 或 token 复用/过期时返回拒绝,`Reset` 无法用 `identity` 单参数改密|涉及:`module/base/passport/internal/logic/forget/reset.go:17`、`module/base/passport/internal/logic/forget/verify.go:14`、`module/base/passport/proto/forget.proto:12` - [ ] **P0-1b** 删除 `utils.Md5` 写入,统一 bcrypt(≥DefaultCost)|验收:重置后可用新密码通过 `Login.Pwd` 登录,库中 password 以 `$2` 开头|涉及:`module/base/passport/internal/logic/forget/reset.go:31` - [ ] **P0-2** 实现并接入 `verifyCode(phone, code, scene)`(Redis 读取 + 常量时间比对 + 用后即删 + 尝试次数上限)|验收:错误/过期/已用验证码均被拒绝,且三处入口均覆盖|涉及:`module/base/passport/internal/logic/login/code.go:16`、`module/base/passport/internal/logic/login/quick.go:20`、`module/base/passport/internal/logic/register/code.go:13`、`module/base/passport/internal/models/cache.go:131` - [ ] **P0-3** 生产禁用 `local` provider,`JumioCallback` 增加签名校验 + `scan_reference` 归属校验 + 幂等键 + 时间窗|验收:伪造回调(无签名/重复 scan_reference/未知 reference)均被拒绝且不写库|涉及:`module/base/passport/internal/logic/verify/request.go:53`、`module/base/passport/internal/logic/verify/jumio_callback.go:14` - [ ] **P0-4** `SetData` 改字段白名单更新,保留 `rights/score/level/*_verify/agency_*` 等未传字段|验收:只传 nickname 后,其余字段值与调用前逐字段相等(回归用例)|涉及:`module/base/passport/internal/logic/account/set_data.go:30`、`:52` - [ ] **P0-5** `config.New` 接入并校验 `Authorization.Key`(非空 + 16/24/32 字节,缺失 panic)|验收:不配置密钥时服务启动失败;配置后签发/校验 token 正常且不再使用默认密钥|涉及:`module/base/passport/internal/config/config.go:46`、`module/base/passport/internal/logic/common/token.go:10` - [ ] **P0-6** `passport_account` 增加 `account`/`phone`(或 `country+phone`)唯一索引并把冲突映射为 `ErrAlreadyExists`|验收:并发同名注册只成功一条,`Login.Pwd` 不再出现"同名多行取 First"|涉及:`module/base/passport/internal/models/passport_account.go:30`、`module/base/passport/internal/logic/register/code.go:19`、`module/base/passport/internal/logic/register/do.go:37` - [ ] **P0-7** 统一 bcrypt `DefaultCost`,封装 `HashPassword/VerifyPassword` 并处理全部哈希错误|验收:新注册/改密/重置后的哈希 cost ≥ 10,`query.go` 无 `_ =` 吞错|涉及:`module/base/passport/internal/logic/register/do.go:30`、`module/base/passport/internal/logic/account/set_password.go:35`、`module/base/passport/internal/models/query.go:21` - [ ] **P0-8** 登录/注册/找回接入失败计数与锁定,成功后清零|验收:连续 5 次错误密码/验证码后该账号+IP 被锁定 15 分钟并返回统一错误|涉及:`module/base/passport/internal/models/cache.go:145`、`:169`、`module/base/passport/internal/logic/login/pwd.go:18` - [ ] **P1-9** 登录失败统一错误码与文案,并对不存在的账号执行等时 dummy 比较|验收:存在/不存在账号的响应体与耗时无显著差异;枚举脚本无法区分|涉及:`module/base/passport/internal/logic/login/pwd.go:29`、`module/base/passport/internal/models/passport_account.go:55` - [ ] **P1-10** 改密/禁用时失效会话,补 `Logout`,统一 token 缓存键与 TTL|验收:改密后旧 JWT 立即失效;`GetTokenCache` 有真实调用点或被删除|涉及:`module/base/passport/internal/logic/account/set_password.go:40`、`module/base/passport/internal/models/cache.go:105`、`module/base/passport/internal/logic/login/code.go:41` - [ ] **P1-11** 补齐所有 `DBService` 调用的 `.Error` 检查与日志|验收:`grep -n "DBService" internal/logic` 中不存在未接收 Error 的调用|涉及:`module/base/passport/internal/logic/account/set_data.go:45`、`module/base/passport/internal/logic/account/get.go:25`、`module/base/passport/internal/logic/account/statistics.go:36` - [ ] **P1-12** 注册/快捷登录/实名写入包事务,保证账号+资料+验证明细原子性|验收:注入第二段写入失败时第一段回滚,库中不留半成品|涉及:`module/base/passport/internal/logic/register/do.go:53`、`module/base/passport/internal/logic/login/quick.go:41`、`module/base/passport/internal/logic/verify/request.go:73` - [ ] **P1-13** 查询/唯一性按 `(country, phone)` 复合维度,`country` 做白名单校验|验收:同号码不同 country 视为不同账号;非法 country 被拒绝|涉及:`module/base/passport/internal/logic/login/quick.go:26`、`module/base/passport/internal/logic/register/code.go:19` - [ ] **P1-14** 为 `account/phone/identity/email` 补索引,`passport_provider` 补复合唯一键|验收:`EXPLAIN` 登录查询走索引;重复第三方绑定被拒绝|涉及:`module/base/passport/internal/models/passport_account.go:30`、`module/base/passport/internal/models/passport_provider.go:16` - [ ] **P1-15** 修正 `login_count` 语义(落 `passport_statistics` 或新增登录日志表)|验收:连续登录 3 次后 `login_count` 返回 3|涉及:`module/base/passport/internal/logic/account/statistics.go:33`、`module/base/passport/internal/models/passport_statistics.go:15` - [ ] **P2-16** 决定 `cache.go` 去留:接线(读写成对 + 写路径失效)或整体删除|验收:无"只读不失效"的缓存键;`grep` 无零调用函数|涉及:`module/base/passport/internal/models/cache.go:20`、`module/base/passport/internal/logic/account/get.go:25` - [ ] **P2-17** 补启动期配置校验(Kyc 非空且 https、Token.Prefix 非空、Provider 白名单),清理 `SecretKey`/`KycConf`|验收:非法配置启动即失败;prod/test yaml 不再指向 `bsm_dev`|涉及:`module/base/passport/internal/config/config.go:55`、`module/base/passport/etc/passport_prod.yaml:29` - [ ] **P2-18** Jumio 调用贯穿 context,超时/重试可配,`CallbackURL` 独立字段|验收:客户端取消即中断外部请求;回调地址配置错误在启动期被发现|涉及:`module/base/passport/internal/logic/verify/request.go:92`、`:108`、`:100` - [ ] **P2-19** 日志脱敏:禁止打印密码/盐/证件号/姓名/验证码|验收:构造注册失败与回调场景,日志中无法检索到上述字段明文|涉及:`module/base/passport/internal/logic/register/do.go:54`、`module/base/passport/internal/logic/verify/jumio_callback.go:21` - [ ] **P2-20** 补齐关键用例测试并接入 CI|验收:覆盖登录(密码/验证码)、注册(含并发重复)、找回(无 token 必须失败)、验证码(过期/复用/错误)、回调(伪造签名必须失败)、`SetData` 字段保留,全部为 `_test.go` 且 `go test ./...` 通过|涉及:`module/base/passport/test/grpc/main.go:19` - [ ] **P2-21** 新增健康检查接口并在网关层统一挂载限流/鉴权/CORS 中间件|验收:`/healthz` 可探活;限流与 CORS 有集成测试;README 能力声明与实现一致|涉及:`module/base/passport/cmd/main/main.go:24`、`module/base/passport/etc/passport_prod.yaml:20` - [ ] **P3-22** 清理死代码并标注 roadmap|验收:`cache.go`(或已接线)、`CreateAccount`、`InitData`、`grpcConns`、`WeChatConf`、未实现表/常量均有明确归属(删除或 roadmap 注释)|涉及:`module/base/passport/internal/models/query.go:50`、`module/base/passport/internal/server/new.go:17` - [ ] **P3-23** 统一错误码/文案与常量(去掉魔法码 187、修复 `do.go:86` 日志键、订正 proto 错别字与注释)|验收:`grep -rn "errcode.NewError(187"` 为 0;proto 注释无错别字|涉及:`module/base/passport/internal/logic/login/pwd.go:29`、`module/base/passport/internal/logic/register/do.go:86`、`module/base/passport/proto/forget.proto:11` - [ ] **P3-24** 手机号校验按 country 分支或明确仅支持 CN,`email` 字段实现或标注废弃|验收:非 CN 号码有明确行为定义;`email` 不再被静默丢弃|涉及:`module/base/passport/internal/logic/register/code.go:19`、`module/base/passport/internal/logic/register/do.go:41` - [ ] **P3-25** 按代码重写 README(API 表、安全特性、目录树、REST 路由)|验收:README 中每条声明都能在代码中定位;移除未实现的 Rate Limiting/CORS/Health 描述|涉及:`module/base/passport/README.md:150`、`:284`、`:171` ## 6. 审计摘要(供汇总使用) - **问题数**:P0=8 P1=7 P2=6 P3=4(合计 25) - **最高风险(一句话)**:找回密码接口 `Forget.Reset` 无任何鉴权即可用 `identity` 直接改写他人密码(并写入裸 MD5 使账号永久失去密码登录能力),配合验证码接口完全不校验 `code` 与 JWT 密钥缺省为公开常量 `Cblocksmesh2022C`,攻击者可零成本接管任意账号并伪造实名状态。 - **最优先 3 个动作**: 1. 立刻改造/下线 `Forget.Reset` 并删除 MD5 写入(`internal/logic/forget/reset.go:17`、`:31`),同时在 `Login.Code`/`Login.Quick`/`Register.Code` 接入真实验证码校验(`internal/logic/login/code.go:16` 等三处)。 2. 在 `config.New` 中接入并强制校验 JWT 密钥(`internal/config/config.go:46`、`internal/logic/common/token.go:10`),消除 SDK 公开默认密钥带来的令牌伪造面。 3. 修复 `Account.SetData` 的零值整行覆盖(`internal/logic/account/set_data.go:30`、`:52`)并为 `passport_account` 补账号/手机号唯一索引(`internal/models/passport_account.go:30`),止住权限/验证状态清零与重复账号两类数据损坏。 - **未能覆盖/无法验证的部分**: 1. `go.mod:73` 的 `replace ../../../../../bsm-sdk/core` 在工作区外无法读取,审计基于 module 缓存中同版本 `v0.2.1` 源码(`service/meta.go`、`crypto/token/jwt.go`、`env/env.go`);若本地强制的 SDK 版本有差异,鉴权与密钥结论需复核。 2. 建表 DDL/迁移脚本不在本模块(模型侧确认无 unique/index tag),线上表是否已由外部脚本补索引无法确认(P1-14 需 DBA 侧核对)。 3. 短信/邮件验证码的生成与下发不在本模块(无 sender 调用),验证码随机性、有效期、发送频控无法审计;本报告只能确认"消费侧完全没有校验"。 4. 网关层(`D:\work\bsm-infra\gateway`、`proxy`)是否对 `/passport.*` 路径附加额外鉴权/限流未展开,若存在则部分 P0(如 `Forget.Reset` 的未授权访问)实际可利用性下降,但模块自身仍不具备防护。 5. 未做任何运行时/动态验证(无可用环境与凭据),全部为静态代码分析;`gofmt -l .` 与 `GOWORK=off go vet ./internal/...` 均通过(无输出),未发现编译级告警。