Files
full/docs/audit/module-base-passport.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

65 KiB
Raw Blame History

审计报告module/base/passport

1. 模块概览

module/base/passport 是 BSM 的「通行证/会员账号」微服务,绝对路径 D:\work\bsm-infra\full\module\base\passport,同时以 gRPC默认 :12426)与 gRPC-gatewayEnable: true:12425)对外暴露。

代码规模(排除 pb/ 生成物41 个 Go 文件、约 1.8k 行;其中业务逻辑仅 21 个文件、约 1.1k 行。结构:

目录 内容
cmd/maincmd/cli 服务入口(cmd/main/main.go:18)、配置打印 CLI
etc/ passport_dev.yaml / passport_test.yaml / passport_prod.yaml
internal/config SrvConfig(含 TokenKycWeChatConf)、New()
internal/logic/{account,login,register,forget,verify,common} 全部业务逻辑
internal/models 10 张表模型 + cache.go 缓存封装 + query.go
internal/server protoc-gen-slc 生成的薄封装,直接调用 logic
internal/implinternal/vars 依赖注入全局变量、常量
service/ 供聚合模块 Expose() 嵌入注册
proto/ 5 个服务定义:Account / Login / Register / Forget / Verify

对外接口共 15 个 RPC账号 6Get/SetData/SetPassword/TagCreate/TagRemove/Statistics)、登录 3Pwd/Code/Quick)、注册 2Pwd/Code)、找回 2Verify/Reset)、实名 2Request/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.1go.mod:73replace 指向的本地路径在工作区外,改用 Go module 缓存中的同版本源码),以及聚合层 pkgs/all/internal/config/config.gomodule/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/Statistics6 个)、Verify.Request1 个);无 = Login.Pwd/Code/QuickRegister.Pwd/CodeForget.Verify/ResetVerify.JumioCallback9 个),其中 8 个属设计上的公开接口,JumioCallback 属例外。

未能覆盖/无法验证的部分(不臆断):

  1. bsm-sdk/corereplace 目标 ../../../../../bsm-sdk/core 在工作区外,审计使用的是缓存中 v0.2.1 源码;go.mod:73 声明的本地版本若被改动,结论可能偏移。
  2. Database.Configurewith.Databases 等 SDK 装配层未读,建表 DDL 是否由外部迁移脚本补齐唯一索引无法确认(模型侧确认无 unique/index tag见 P1-7
  3. 短信验证码的发送侧不在本模块(无短信 RPC、无 sender 调用),"验证码由谁生成、谁存储"无法确认;因此验证码随机性/有效期/一次性只能就本模块的证据判定为「完全没有校验环节」。
  4. 未做运行时验证(无可用环境与凭据),全部结论为静态代码分析;网关层(D:\work\bsm-infra\gatewayproxy)的额外鉴权策略未展开。
  5. utils.Md5utils.UUIDRedisService.BuildKey 的具体实现未读(属 SDK 内部),但用法本身已足以定性。

3. 问题清单

P0

1. 找回密码接口无任何身份校验,可任意重置他人密码;且改用 MD5 存入导致账号彻底不可登录

  • 位置module/base/passport/internal/logic/forget/reset.go:17module/base/passport/internal/logic/forget/verify.go:14proto/forget.proto:12proto/forget.proto:21
  • 证据
// 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
// 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/forget.proto:12,21-24
rpc Reset(ForgetResetRequest) returns (base_passport_blocks.StatusReply) {}
message ForgetResetRequest {
    string identity = 1; //唯一码
    string password = 2; // 密码
}
  • 影响P0 账号接管ResetParseMetaCtx、无 code 参数、无一次性 reset token只要知道或从任何接口/分享链接/日志中取得)目标用户 identity,即可把其密码改成任意值并立即用 Login.Pwd 登录;Verify 完全不校验 in.Code,无法充当门禁(其返回值 StatusReply 也不含任何 token客户端拿不到可回传的凭据链路本身不可闭合叠加第 2 重破坏:重置写入的是 utils.Md5(password)(无盐),而登录校验是 bcryptlogin/pwd.go:27),二者格式互斥——Reset 之后该用户密码登录永久失效,同时把凭据降级为可彩虹表秒破的裸 MD5mgt 模块正是靠 isBcrypt 前缀判断兼容 MD5module/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:16module/base/passport/internal/logic/login/quick.go:20module/base/passport/internal/logic/register/code.go:13
  • 证据
// 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 流程
// 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
// 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 填任意非空字符串即可拿到该手机号账号的有效 JWTlogin/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=1Jumio 回调无签名校验

  • 位置module/base/passport/internal/logic/verify/request.go:53:72module/base/passport/internal/logic/verify/jumio_callback.go:14
  • 证据
// 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)
// 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"],
// 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/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_verifykyc_verify 作为放行依据,等于 KYC 形同虚设。JumioCallback 本身既不校验 Jumio 的 Authorization/HMAC 签名,也不校验来源 IP 或 scan_reference 归属,目前虽只打日志(in.ClientIp 由调用方自带、可任意伪造),但一旦按注释补齐写库逻辑,即为无签名可伪造的实名通过回调
  • 建议:删除 local provider 或将其限制为仅测试环境、仅内网ParseOptions{MustPrivateAllow:true} + 环境判断),生产强制 JumioVerify.Request 返回的 clientRedirectUrl 使用 Jumio 官方 successUrl/errorUrlJumioCallback 落实注释中 1-4 项:校验 Jumio webhook Authorization 头/签名、按 scan_reference + customer_internal_reference(形如 ID_<passportID>request.go:46)反查归属、加时间窗与幂等键(同一 scan_reference 只允许处理一次)。

4. 资料更新接口用零值覆盖整行,导致权限、积分、全部验证状态被清空

  • 位置module/base/passport/internal/logic/account/set_data.go:17:30:52
  • 证据
// 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 白名单
	}
// proto/account.proto:11 —— 注释明确要求「字段值为空或是0将不更新此数据」
    // 更新会员的信息数据,字段值为空或是0将不更新此数据
    rpc SetData(SetDataRequest) returns (base_passport_blocks.StatusReply) {}
  • 影响P0 必然数据损坏。用户只想改昵称({nickname:"x"}GORM Updates(&struct) 会把 rightsscorelevelagency_idstaff_idowner_idowner_identityemail_verifyphone_verifyface_verifydocument_verifykyc_verify 全部写成零值——权限被清空、积分等级归零、实名/手机/邮箱验证状态尽失,且不可逆(无审计、无备份逻辑)。同时 :45Count:52Updates 返回值都被丢弃,err 仅在 :55 用于 cnt==0 分支的 Create更新失败也返回成功,前端与用户都无法察觉。
  • 建议:改为显式白名单 + 只更新非零字段(db.Model(&PassportData{}).Where(...).Select("nickname","avatar","birthday","sex","sign","cover").Updates(map[string]any{...})),或者为每个字段用 *string/*int32 指针区分"未传"与"传空";补 RowsAffected==0 → ErrNotFoundCount 后的 Create 加并发冲突兜底(唯一索引或 clause.OnConflict)。

5. JWT 签名密钥未接入 Authorization.Key,落入 SDK 公开默认值

  • 位置module/base/passport/internal/config/config.go:46module/base/passport/internal/logic/common/token.go:10etc/passport_prod.yaml:43
  • 证据
// 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 }
// 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)
// 依赖 bsm-sdk/core@v0.2.1/env/env.go:17-19 —— 未设置即使用硬编码默认值
		Runtime = &types.RuntimeEnv{
			JwtSecretKey: GetEnvDefault("BSM_JwtSecretKey", "Cblocksmesh2022C"),
# 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/TagCreateVerify.Request 等全部需鉴权接口,绕过所有 ParseMetaCtx。对照:同仓库 pkgs/all/internal/config/config.go:63-73 明确要求 Authorization.Key 非空且必须 16/24/32 字节否则 panicpassport 完全没有这层校验,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:19module/base/passport/internal/logic/register/pwd.go:18module/base/passport/internal/logic/register/do.go:37module/base/passport/internal/models/passport_account.go:30
  • 证据
// logic/register/pwd.go:17-21 —— 只校验 account 是否存在,但 Code 路径不校验 account
	found := models.PassportAccountExists("account", in.Account)
	if found { return nil, errcode.ErrAlreadyExists }
// 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 }
// 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.PwdGetPassportAccountByField("account", ...) + First(&data)models/passport_account.go:52login/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 增加 accountphone 唯一索引(gorm:"uniqueIndex",并在迁移中对历史重复数据先做清洗);do.go 的插入统一走 OnConflict/唯一约束 兜底并把 ErrDuplicatedKey 映射为 ErrAlreadyExistsCode 路径强制 account = phone 或对 account 也做查重;Do 返回新副本而非修改入参;日志脱敏。

7. 密码哈希使用 bcrypt 最低代价cost=4抗爆破能力约为默认值的 1/64

  • 位置module/base/passport/internal/logic/register/do.go:30module/base/passport/internal/logic/account/set_password.go:35module/base/passport/internal/models/query.go:21
  • 证据
// logic/register/do.go:28-34
	if in.Password != "" {
		salt = utils.UUID()
		hashedPassword, err := bcrypt.GenerateFromPassword([]byte(in.Password+salt), bcrypt.MinCost)
// logic/account/set_password.go:35
	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(in.NewPassword+pa.Salt), bcrypt.MinCost)
// models/query.go:21 —— 种子账号同样 MinCost且 err 被丢弃
		hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("welcome"+salt), bcrypt.MinCost)
  • 影响bcrypt.MinCost = 4golang.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:169module/base/passport/internal/logic/login/pwd.go:18
  • 证据
// 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
}
// 全仓库交叉验证grep "IncrementLoginAttempts|GetLoginAttempts|ClearLoginAttempts|
// GetVerificationCodeCache|SetVerificationCodeCache|InvalidateVerificationCodeCache" 
// 仅命中 models/cache.go 自身的定义行logic/ 下 0 处调用。
// 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:55module/base/passport/internal/logic/login/pwd.go:29
  • 证据
// models/passport_account.go:53-57 —— 查不到返回带文案的 404
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, errcode.ErrNotFound(404, "Account not found")
		}
// 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.PwdErrAlreadyExistsregister/pwd.go:19)与 Register.CodeErrAlreadyExistsregister/code.go:26)提供第二条确认通道(手机号/账号是否已注册)。枚举结果直接喂给 P0-1重置他人密码与 P1-9 的爆破链路。
  • 建议:登录失败统一返回同一错误码与同一文案(如 ErrPassword)并在等到密码比较完成后才返回(对不存在的账号执行一次等时的 dummy bcrypt 比较,避免时间侧信道);注册接口对已存在账号/手机号返回中性提示或引入图形验证码,避免把"已注册"直接暴露为可批量探测的信号。

10. 改密后旧 JWT 仍可用 24 小时无登出token 缓存写而不查

  • 位置module/base/passport/internal/logic/account/set_password.go:40module/base/passport/internal/logic/login/code.go:41module/base/passport/internal/logic/login/pwd.go:48module/base/passport/internal/models/cache.go:105
  • 证据
// logic/account/set_password.go:40-44 —— 只改 password不动任何会话/缓存
	err = impl.DBService.Model(&models.PassportAccount{}).Where("id = ? ", auth.ID).Update("password", string(hashedPassword)).Error
// 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()
// 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:111GetTokenCache 零调用),服务重启或账号被盗后已泄露的 token 在 24 小时内始终有效。code.go:41ttl=0(永不过期)进一步放大:同一 identity 若曾走验证码登录,其 Redis 值永久残留,而 Token.Prefixconfig.Spec.Token.Prefix)与 vars.TokenPrefix/TOKEN/)两种前缀并存,导致同一用户可能存在两份语义不同的键(见 P2-11
  • 建议SetPassword、后续的禁用/删除账号流程统一执行 models.InvalidateTokenCache + InvalidateUserCache;引入 token 版本号JWT 内加 ver,账号表存版本,改密自增)或在鉴权层比对 Redis 中的当前 tokenLogout RPC 并删除孤立键;统一前缀与 TTL 常量。

11. 数据更新与读取路径大量吞错,接口返回成功但实际未生效

  • 位置module/base/passport/internal/logic/account/set_data.go:45:52module/base/passport/internal/logic/account/get.go:25:44module/base/passport/internal/logic/account/statistics.go:36:43module/base/passport/internal/logic/verify/request.go:78
  • 证据
// 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
// 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)
// 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-85Updates(map[...]) 若失败同样被吞入返回 ErrDB(此处有检查,但 :73document_verify 已先提交,形成半更新document_verify=1 成功而 passport_verify 明细失败)。
  • 建议:所有 GORM 调用检查 .Error 并映射为 ErrDB(读路径可降级但必须 printer.Error 记录);SetDataLocalVerify 的多表写用 DBService.Transaction 包起来(见下条)。

12. 多表写入无事务:注册与快捷登录会产生孤儿/不一致数据

  • 位置module/base/passport/internal/logic/register/do.go:53:70module/base/passport/internal/logic/login/quick.go:41:59module/base/passport/internal/logic/verify/request.go:73:78
  • 证据
// 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 }
// 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.PwdCheckPassportData 虽会自动补建(models/passport_data.go:62-70),但 Register.Quick 路径与登录取 rights/验证状态的逻辑会拿到空记录;LocalVerify 的半更新则直接造成"凭证状态=已认证,但无任何明细/时间戳"的不一致数据。注意 models/query.go:50 已有一个 CreateAccount 事务封装,但全仓库零调用(死代码),说明事务能力存在却未被使用。
  • 建议DoQuick 的新建流程、LocalVerify 统一改为 impl.DBService.Transaction(func(tx *gorm.DB) error {...});顺带删除或复用 CreateAccount,避免两套写法并存。

13. 手机号唯一性/国家码校验缺失,跨国家码可导致账号合并

  • 位置module/base/passport/internal/logic/login/quick.go:26module/base/passport/internal/logic/login/code.go:21module/base/passport/internal/logic/register/code.go:19
  • 证据
// 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), ...
// logic/register/code.go:19-27 —— 国家码完全未参与唯一性判断
	matched, err := regexp.MatchString("^1[3456789]{1}\\d{9}$", in.Phone)
  • 影响:账号唯一键只有 phone 而忽略 countryLoginByCodeRequest.Country 必填却在查号时被丢弃),同一个本地号码在不同国家码下(如 CN/US 的短号、或号码前缀重叠)会被判定为同一账号:攻击者可用自己的国家码 + 受害者的本地号码触发 Quick,命中受害者账号并直接拿到其 tokenquick.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:32module/base/passport/internal/models/passport_provider.go:16:17
  • 证据
// 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"`
// 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 存在但未覆盖查询组合,等于无效索引。
  • 建议:为 accountphoneuniqueIndexemailidentityindexpassport_provideruniqueIndex:idx_provider_pid (provider, provider_id);新增索引需评估线上表体量并走在线 DDL同时为 Phone 的(varchar20)长度与统一格式(E.164)做数据清洗。

15. Statisticslogin_count 统计的是账号行数,恒为 1

  • 位置module/base/passport/internal/logic/account/statistics.go:31:36
  • 证据
// 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 或 1login_count 永远返回 1调用方若用于风控/活跃度展示)会得到完全错误的信号。项目内没有登录记录表,PassportStatisticsmodels/passport_statistics.go:15-20,含 Item/Value)这一本该承载统计的表从未被读写
  • 建议:明确语义后在登录成功路径写入 passport_statisticsitem='login_count' 做原子自增)或新增 passport_login_log 表并按其计数;tag_countCount 错误处理一并补上P1-11不支持的 field 目前静默返回 0建议返回 ErrInvalidArgument 或明确文档化。

P2

16. 缓存层整体未被使用,登录/资料路径无缓存收益且存在缓存一致性隐患

  • 位置module/base/passport/internal/models/cache.go:20:43:66:89module/base/passport/internal/logic/account/get.go:25
  • 证据
// models/cache.go:20 —— GetAccountByCache 只为...自身存在grep 全仓库零调用)
func GetAccountByCache(ctx context.Context, field, value string) (*PassportAccount, error) {
// 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 + CheckPassportDataAccount.Get 2 次,SetData 3 次(Count+Updates);标签增删(account/tag_create.go:47tag_remove.go:31)也从不失效 usertags 缓存。当前因缓存未被启用而未爆发脏数据,但一旦有人按 cache.go 的命名接入,会立刻出现"改资料后仍读到旧 rights"的一致性 bugSetData/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:28module/base/passport/etc/passport_prod.yaml:36:29
  • 证据
// internal/config/config.go:15-34 —— SecretKey 不在 SrvConfig 中(无 yaml tagKyc 字段无任何校验
type SrvConfig struct {
	conf.Base    `yaml:",inline"`
	...
	WeChat       *WeChatConf             `yaml:"WeChatConf"`
	Token        *TokenConf              `yaml:"Token"`
	Kyc          *KycConf                `yaml:"Kyc"`
}
// 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 }
# 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_MESrvConfig 中没有对应字段而被静默忽略,给运维造成"已配置密钥"的错觉(真实密钥走 BSM_JwtSecretKey,见 P0-5KycConf 是无效重复块,生产/测试 yaml 与 dev 高度雷同prod 与 test 直接共用 dbname=bsm_dev127.0.0.1),存在误连测试库的风险。config.go:60time.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
  • 证据
// logic/verify/request.go:47 —— 有 ctx 却不向下传
		resp, err := InitiateJumioScan(id, auth.Identity)
// 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)
  • 影响contextRequestInitiateJumioScan 之间被丢弃(http.NewRequest 而非 NewRequestWithContext),客户端取消/超时无法中断外部调用gRPC 连接释放后仍占用 goroutine10s 超时与无重试策略均为硬编码魔法数字;CallbackURL 直接取 config.Spec.Kyc.ApiArgs:100),字段语义严重错位(ApiArgs 的注释/命名与"回调地址"毫无关系README 也写作 "additional_args"README.md:94),极易配置错误导致 Jumio 回调打不到服务(进而使 P0-3 的回调链路静默失效);无熔断/降级Jumio 故障时每个请求都吃满 10s。
  • 建议InitiateJumioScan(ctx, ...) 贯穿 context 并使用 http.NewRequestWithContext;超时/重试次数/退避提取为配置项(Kyc.TimeoutKyc.RetryCallbackURL 单独建字段(如 Kyc.CallbackURL)并做非空+https 校验;加错误计数与告警。

19. 敏感数据明文存储与明文日志(密码哈希/盐、证件号、姓名、图片地址)

  • 位置module/base/passport/internal/logic/register/do.go:54module/base/passport/internal/logic/verify/jumio_callback.go:21module/base/passport/internal/models/passport_verify.go:36module/base/passport/internal/logic/verify/request.go:82
  • 证据
// 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)
// logic/verify/jumio_callback.go:20-21 —— 整个回调(含证件号/姓名/customerInternalReference进日志
	// Log the callback for audit purposes
	printer.Info("Received Jumio KYC callback: %+v", in)
// 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_verifyLocalVerify 还会把客户端自报的任意值直接落库P0-3无 KMS/字段级加密、无脱敏输出;Account.GetFullReply 也会原样返回 phone/emaillogic/account/get.go:38-39仅限本人风险可控。README 宣称 "Structured Logging",但实际是格式化字符串日志。
  • 建议:日志统一剔除敏感字段(引入 Printer 脱敏包装或只打 id/scan_reference 的哈希证件类字段评估字段级加密AES-GCM + 密钥管理)或至少做存储侧脱敏 + 访问审计;输出侧对手机号/证件号做掩码;为敏感字段访问补审计日志表。

20. 测试几乎不存在,关键链路零覆盖

  • 位置module/base/passport/test/grpc/main.go:19:45test/readme.md:1
  • 证据
// 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 ./... 不会执行到它),且把一枚真实 JWTid=7exp=1760413814,即 2025-10-14 03:50:14 UTC 到期)与生产域名硬编码进仓库,构成凭据泄露与"测试脚本直连生产"的双重风险(README.md:254-256 却宣称 go test ./... 可用)。
  • 建议:删除或迁出写死的 token改为测试专用短时 token + 本地 mock 服务);补齐关键用例清单(见 §5 TODOtest/grpc 改造成 _test.go 并默认指向本地容器。

21. 可观测性与运行期能力缺失:无健康检查、无优雅退出、无 CORS/限流中间件

  • 位置module/base/passport/cmd/main/main.go:24module/base/passport/etc/passport_prod.yaml:20README.md:284:291
  • 证据
// cmd/main/main.go:24-43 —— 仅 Start/Stop无健康检查注册、无 signal 处理
	srv := service.New(s.Grpc, &service.Options{ ... GatewayMux: s.Mux })
	// 停止服务
	defer srv.Stop()
	srv.Start()
# 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-172module/base/passport/internal/models/query.go:12:50module/base/passport/internal/models/passport_provider.go:12module/base/passport/internal/models/passport_statistics.go:15module/base/passport/internal/models/passport_score.go:15module/base/passport/internal/models/passport_notify.go:15module/base/passport/internal/vars/provider.go:3module/base/passport/internal/config/config.go:23module/base/passport/internal/server/new.go:17:39
  • 证据
// models/query.go:50-64 —— 事务化建号封装,含注释「插入扩展表」,全仓库零调用
func CreateAccount(pa *PassportAccount, nickname string) (err error) {
	return impl.DBService.Transaction(func(tx *gorm.DB) error {
// internal/server/new.go:17,29 —— grpcConns 连接池字段声明后从未使用
	grpcConns map[string]*grpc.ClientConn // 连接池
	...
		grpcConns: make(map[string]*grpc.ClientConn),
// internal/config/config.go:23 —— WeChatConf 配置项无任何消费代码
	WeChat       *WeChatConf             `yaml:"WeChatConf"`
  • 影响cache.go 全部 13 个函数、InitData(被 cmd/main/main.go:37 注释)、CreateAccountPassportProvider(第三方登录表 + vars.Provider_* 6 个常量)、PassportStatisticsPassportScorePassportNotifyWeChatConfserver.grpcConns 均为零调用/零读写的死代码或未实现能力;Register.DoREADME.md:157 列为 RPC与"发送注册验证码"README.md:155)在 proto 与 logic 中都不存在。这些残留物会持续误导审计与开发(例如让人误以为已有登录限流、已有验证码存储、已有第三方登录)。
  • 建议:建立"未使用代码清理"变更:删除 cache.go(或按 P2-16 接线)、CreateAccountInitDatagrpcConns;对 PassportProvider/Statistics/Score/NotifyWeChatConf 明确标注 roadmap 或直接删除;同步修正 README 的服务清单(移除 Register.Do发送注册验证码/v1/* 路由表)。

23. 错误码与文案复用、魔法数字、注释与实现不符

  • 位置module/base/passport/internal/logic/login/pwd.go:29module/base/passport/internal/logic/account/set_password.go:32module/base/passport/internal/logic/register/do.go:86proto/forget.proto:11proto/account.proto:8proto/account.proto:32proto/login.proto:31
  • 证据
// logic/login/pwd.go:29 与 logic/account/set_password.go:32 —— 同一个魔法码 187/同一文案
		return nil, errcode.NewError(187, "Passport Error")
// 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/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:84proto 注释的错别字与错配会直接影响生成的 TS 客户端文档。proto 缺少 google.api.http 注解导致 REST 路径是 POST /passport.Account/Get 这类"gRPC 方法名直出"形式,与 README 的 /v1/accountREADME.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:19module/base/passport/internal/logic/register/do.go:41proto/register.proto:18
  • 证据
// 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 }
// 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.Countryproto/register.proto:17)声明支持"eg CN US",但校验只接受中国大陆 11 位号码,海外用户无法注册;RegisterRequest.Emailproto/register.proto:18)被静默丢弃,passport_account.email 永远为空,而 GetFullReply.Emailproto/account.proto:32)却在展示它,且 PassportData.EmailVerify 存在却没有邮箱验证码/验证链路——"邮箱注册"实为未实现能力。Login.PwdaccountPhone 也未做互斥校验,用户可用任意字符串当账号。
  • 建议:按 country 选择号码校验规则(引入 libphonenumber或明确公告仅支持 CN 并从 proto 移除误导性注释/字段;若保留 email,则实现邮箱唯一性校验、验证码下发与验证状态流转,或在 proto 中标注 deprecated。

25. proto/README 与实现、仓库内其它模块的横向不一致

  • 位置module/base/passport/README.md:150:155:157:171:284module/base/passport/README.md:21module/base/passport/README.md:222:311
  • 证据
<!-- README.md:154-157 -->
#### 注册服务 (Register Service)
- `Code()` - 发送注册验证码        ← 实际是"用验证码注册",不发送任何验证码
- `Pwd()` - 密码注册
- `Do()` - 完成注册流程            ← proto 中没有 Do RPC

<!-- README.md:284-285 -->
- **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/VerifyPasswordbcrypt DefaultCost),注册/改密/重置三处共用;对历史上已被 MD5 覆盖的账号做一次性重哈希或强制走找回流程(需离线数据核查)。
  4. SetData 覆写:改白名单 Select + Updates(map),并在同一次改动中补 InvalidateUserCacheP0-4 + P2-16 接线)。
  5. JWT 密钥治理config.New 接入 Authorization.Key 并做长度校验、缺失即 panic;生产环境注入 BSM_JwtSecretKeyP0-5。这是全局影响项需与其它模块pkgs/allmgt 等共用同一密钥体系)协同发布,否则跨模块 token 互认会断。
  6. 建唯一索引 + 事务passport_account(account)(country,phone) 唯一索引,注册/快捷登录/实名写入包事务P0-6、P1-12、P1-14

第二层补机制2-3 个迭代,对应 P1 7. Verify.Requestlocal provider 生产禁用 + JumioCallback 落实签名/归属/幂等三件套P0-3。 8. 登录失败统一文案 + 等时比较会话撤销token 版本号或 Redis 校验)与 Logout RPCP1-9、P1-10。 9. 补齐 .Error 检查并在读路径至少记录日志;Statistics 语义修正P1-11、P1-15。 10. 抽出中间件层承载限流/鉴权/CORS/脱敏日志,避免每个 logic 重复实现P0-8、P2-19、P2-21

第三层:清理与对齐(与功能迭代并行,对应 P2/P3 11. 死代码清理(cache.go 接线或删除、CreateAccountInitDatagrpcConnsWeChatConf、未实现的表/常量proto 注释与 REST 路由规范统一README 按代码重写P3-22、P3-23、P3-25。 12. 测试补齐:以表驱动单测覆盖验证码校验、密码哈希往返、SetData 白名单、Reset 鉴权、JumioCallback 签名拒绝五条主线,接入 CIP2-20

跨模块依赖提示P0-5JWT 密钥)与 pks/allmodule/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:17module/base/passport/internal/logic/forget/verify.go:14module/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:16module/base/passport/internal/logic/login/quick.go:20module/base/passport/internal/logic/register/code.go:13module/base/passport/internal/models/cache.go:131
  • P0-3 生产禁用 local providerJumioCallback 增加签名校验 + scan_reference 归属校验 + 幂等键 + 时间窗|验收:伪造回调(无签名/重复 scan_reference/未知 reference均被拒绝且不写库涉及module/base/passport/internal/logic/verify/request.go:53module/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:46module/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:30module/base/passport/internal/logic/register/code.go:19module/base/passport/internal/logic/register/do.go:37
  • P0-7 统一 bcrypt DefaultCost,封装 HashPassword/VerifyPassword 并处理全部哈希错误|验收:新注册/改密/重置后的哈希 cost ≥ 10query.go_ = 吞错|涉及:module/base/passport/internal/logic/register/do.go:30module/base/passport/internal/logic/account/set_password.go:35module/base/passport/internal/models/query.go:21
  • P0-8 登录/注册/找回接入失败计数与锁定,成功后清零|验收:连续 5 次错误密码/验证码后该账号+IP 被锁定 15 分钟并返回统一错误|涉及:module/base/passport/internal/models/cache.go:145:169module/base/passport/internal/logic/login/pwd.go:18
  • P1-9 登录失败统一错误码与文案,并对不存在的账号执行等时 dummy 比较|验收:存在/不存在账号的响应体与耗时无显著差异;枚举脚本无法区分|涉及:module/base/passport/internal/logic/login/pwd.go:29module/base/passport/internal/models/passport_account.go:55
  • P1-10 改密/禁用时失效会话,补 Logout,统一 token 缓存键与 TTL验收改密后旧 JWT 立即失效;GetTokenCache 有真实调用点或被删除|涉及:module/base/passport/internal/logic/account/set_password.go:40module/base/passport/internal/models/cache.go:105module/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:45module/base/passport/internal/logic/account/get.go:25module/base/passport/internal/logic/account/statistics.go:36
  • P1-12 注册/快捷登录/实名写入包事务,保证账号+资料+验证明细原子性|验收:注入第二段写入失败时第一段回滚,库中不留半成品|涉及:module/base/passport/internal/logic/register/do.go:53module/base/passport/internal/logic/login/quick.go:41module/base/passport/internal/logic/verify/request.go:73
  • P1-13 查询/唯一性按 (country, phone) 复合维度,country 做白名单校验|验收:同号码不同 country 视为不同账号;非法 country 被拒绝|涉及:module/base/passport/internal/logic/login/quick.go:26module/base/passport/internal/logic/register/code.go:19
  • P1-14account/phone/identity/email 补索引,passport_provider 补复合唯一键|验收:EXPLAIN 登录查询走索引;重复第三方绑定被拒绝|涉及:module/base/passport/internal/models/passport_account.go:30module/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:33module/base/passport/internal/models/passport_statistics.go:15
  • P2-16 决定 cache.go 去留:接线(读写成对 + 写路径失效)或整体删除|验收:无"只读不失效"的缓存键;grep 无零调用函数|涉及:module/base/passport/internal/models/cache.go:20module/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:55module/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:54module/base/passport/internal/logic/verify/jumio_callback.go:21
  • P2-20 补齐关键用例测试并接入 CI验收覆盖登录密码/验证码)、注册(含并发重复)、找回(无 token 必须失败)、验证码(过期/复用/错误)、回调(伪造签名必须失败)、SetData 字段保留,全部为 _test.gogo test ./... 通过|涉及:module/base/passport/test/grpc/main.go:19
  • P2-21 新增健康检查接口并在网关层统一挂载限流/鉴权/CORS 中间件|验收:/healthz 可探活;限流与 CORS 有集成测试README 能力声明与实现一致|涉及:module/base/passport/cmd/main/main.go:24module/base/passport/etc/passport_prod.yaml:20
  • P3-22 清理死代码并标注 roadmap验收cache.go(或已接线)、CreateAccountInitDatagrpcConnsWeChatConf、未实现表/常量均有明确归属(删除或 roadmap 注释)|涉及:module/base/passport/internal/models/query.go:50module/base/passport/internal/server/new.go:17
  • P3-23 统一错误码/文案与常量(去掉魔法码 187、修复 do.go:86 日志键、订正 proto 错别字与注释)|验收:grep -rn "errcode.NewError(187" 为 0proto 注释无错别字|涉及:module/base/passport/internal/logic/login/pwd.go:29module/base/passport/internal/logic/register/do.go:86module/base/passport/proto/forget.proto:11
  • P3-24 手机号校验按 country 分支或明确仅支持 CNemail 字段实现或标注废弃|验收:非 CN 号码有明确行为定义;email 不再被静默丢弃|涉及:module/base/passport/internal/logic/register/code.go:19module/base/passport/internal/logic/register/do.go:41
  • P3-25 按代码重写 READMEAPI 表、安全特性、目录树、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:46internal/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:73replace ../../../../../bsm-sdk/core 在工作区外无法读取,审计基于 module 缓存中同版本 v0.2.1 源码(service/meta.gocrypto/token/jwt.goenv/env.go);若本地强制的 SDK 版本有差异,鉴权与密钥结论需复核。
    2. 建表 DDL/迁移脚本不在本模块(模型侧确认无 unique/index tag线上表是否已由外部脚本补索引无法确认P1-14 需 DBA 侧核对)。
    3. 短信/邮件验证码的生成与下发不在本模块(无 sender 调用),验证码随机性、有效期、发送频控无法审计;本报告只能确认"消费侧完全没有校验"。
    4. 网关层(D:\work\bsm-infra\gatewayproxy)是否对 /passport.* 路径附加额外鉴权/限流未展开,若存在则部分 P0Forget.Reset 的未授权访问)实际可利用性下降,但模块自身仍不具备防护。
    5. 未做任何运行时/动态验证(无可用环境与凭据),全部为静态代码分析;gofmt -l .GOWORK=off go vet ./internal/... 均通过(无输出),未发现编译级告警。