必须用 totp.Generate 生成密钥,存库前加密,验证时用 ValidateTime 配 WithSkew(1) 和 UTC 时间戳,解码密钥需 base32.StdEncoding.DecodeString,且 MFA 开关未启用时跳过校验。

密钥生成必须用 totp.Generate,别手搓 base32 或随机字符串
直接用 github.com/pquerna/otp/totp 的 Generate 函数生成密钥,它自动处理高熵、Base32 编码、RFC 4648 兼容性。自己用 rand.Read + base32.StdEncoding.EncodeToString 拼接,容易因字节长度不对(需至少 10 字节原始密钥)或编码表错(比如用了 URLEncoding)导致扫码失败——Google Authenticator 和 Authy 只认标准 Base32。
- 正确做法:
key, err := totp.Generate(totp.GenerateOpts{AccountName: "user@example.com", Issuer: "MyApp", Period: 30}) - 密钥字段存库前必须加密,明文存
user.mfa_secret是高危操作 - 生成后立刻用
key.URL()得到标准 otpauth:// URL,喂给 QR 生成库(如rsc/qr),别手动拼接
ValidateTime 必须带滑动窗口,否则客户端时间一偏就失效
totp.ValidateTime 默认只校验当前周期(±0 秒),但手机时钟常有 ±10 秒偏差,用户扫完码输的时候很可能已跨周期。不设窗口,50% 场景会提示“验证码错误”,实际只是时间没对齐。
- 安全又实用的窗口:允许 ±1 个周期,即
totp.ValidateTime(code, key.Secret(), time.Now().Unix(), totp.WithPeriod(30), totp.WithSkew(1)) - 别用
Validate(旧版无窗口)或自己算T = (now - epoch) / 30再比对——ValidateTime内部已做整数截断和 UTC 归一化 - 时间戳必须用
time.Now().UTC().Unix(),本地时区或纳秒时间都会错
验证前必须查 mf_enabled 开关,MFA 和主密码流程要隔离
MFA 不是密码增强层,而是独立认证通道。用户没开 MFA 时硬塞 TOTP 校验,等于把登录流程搞成“要么输密码+验证码,要么输密码+空验证码”,逻辑崩坏且埋下越权漏洞。
- 登录路由里先查数据库:
if !user.MfEnabled { return validatePasswordOnly(...) } - MFA 校验失败应单独计数(IP + 用户 ID + 时间),5 分钟内超 5 次就锁
/api/verify-mfa,不影响主密码入口 - 重置密钥必须走二次确认(如邮箱点击链接 + 当前密码),不能仅靠“忘记密码”流程覆盖
解码密钥时别跳过 base32.StdEncoding.DecodeString
数据库存的是 Base32 字符串(如 JBSWY3DPEHPK3PXP),验证时得先还原成原始字节切片,再喂给 ValidateTime。直接把字符串当字节传进去,HMAC 计算对象就错了,永远不匹配。
立即学习“go语言免费学习笔记(深入)”;
- 典型错误:
valid := totp.ValidateTime(code, []byte(user.Secret), ...)—— 这是在对 Base32 字符串本身做 HMAC - 正确解码:
secretBytes, _ := base32.StdEncoding.DecodeString(user.Secret),再传secretBytes - 解码失败要返回明确错误(如
invalid base32 string),而不是静默吞掉或 fallback 到空密钥
TOTP 看似就几个函数调用,但时间偏移、密钥编解码、验证上下文隔离这三处,几乎每个上线项目都踩过坑。最麻烦的是问题现象不报错——只是“验证码总不对”,排查时容易怀疑网络、前端、时钟,最后发现是后端少传了一个 WithSkew(1)。


















