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.
This commit is contained in:
2026-09-14 22:16:27 +08:00
parent fc1a64f9dd
commit 63aeedc2fe
19 changed files with 12724 additions and 0 deletions

View File

@@ -0,0 +1,776 @@
# 审计报告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/...` 均通过(无输出),未发现编译级告警。