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

777 lines
65 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 审计报告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` 之后**该用户密码登录永久失效**,同时把凭据降级为可彩虹表秒破的裸 MD5mgt 模块正是靠 `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_<passportID>``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 tagKyc 字段无任何校验
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 连接释放后仍占用 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.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
<!-- 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/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` 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` 接线或删除、`CreateAccount``InitData``grpcConns``WeChatConf`、未实现的表/常量proto 注释与 REST 路由规范统一README 按代码重写P3-22、P3-23、P3-25
12. 测试补齐:以表驱动单测覆盖验证码校验、密码哈希往返、`SetData` 白名单、`Reset` 鉴权、`JumioCallback` 签名拒绝五条主线,接入 CIP2-20
**跨模块依赖提示**P0-5JWT 密钥)与 `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"` 为 0proto 注释无错别字|涉及:`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** 按代码重写 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: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/...` 均通过(无输出),未发现编译级告警。