Google Authenticator 用的是 TOTP 算法,每30秒基于密钥和UTC时间戳生成一个动态验证码。密钥得用totp.Generate生成标准Base32字符串,验证时得先解码成字节切片,再用time.Now().UTC().Unix()当时间戳。这几个步骤,哪一步错了,验证就过不了。

直接用 github.com/pquerna/otp/totp 库,别自己手算 HMAC 或拼 Base32。90% 的“扫码后验证失败”问题,都出在这两步上。
密钥生成必须用 totp.Generate,不能手搓随机字符串
密钥生成这步,必须用 totp.Generate,别自己手搓随机字符串。为什么?Google Authenticator 和 Authy 只认 RFC 4648 标准 Base32 编码的密钥,而且原始密钥字节长度至少 10 字节、熵值足够高。自己用 crypto/rand.Read 加 base32.StdEncoding.EncodeToString 拼,容易因为字节长度不对、编码表误用(比如用了 URLEncoding)、大小写或填充符处理错,导致扫码失败。
totp.Generate内部自动用真随机源、选够长字节、标准 Base32 编码、大写无 padding,一步到位。- 调用时必须传
AccountName和Issuer,否则生成的key.URL()不符合 otpauth:// 规范,扫码工具可能拒绝识别。 - 示例:
key, err := totp.Generate(totp.GenerateOpts{ AccountName: "alice@example.com", Issuer: "MyApp", Period: 30, })
验证时必须先 base32.StdEncoding.DecodeString 密钥字符串
验证时犯的错,往往更隐蔽。数据库里存的是 Base32 字符串(比如 "JBSWY3DPEHPK3PXP"),但 totp.ValidateTime 第二个参数要的是原始字节切片([]byte)。跳过解码直接传字符串,HMAC 计算对象就错了,永远不匹配。
- 错误写法:
totp.ValidateTime(code, []byte(user.Secret), ...)—— 这是把 Base32 字符串当 raw bytes 用了。 - 正确写法:
secretBytes, err := base32.StdEncoding.DecodeString(user.Secret) if err != nil { return false } valid := totp.ValidateTime(code, secretBytes, time.Now().UTC().Unix(), totp.WithPeriod(30), totp.WithSkew(1)) WithSkew(1)表示允许 ±1 个时间窗口(即 ±30 秒),不加这个,手机时钟差 15 秒就验证失败。
时间戳必须是 time.Now().UTC().Unix(),不是纳秒、毫秒或本地时区
时间戳这块,也是个容易踩的坑。 totp.ValidateTime 的第三个参数要求是 int64 类型的 Unix 秒级时间戳(从 1970-01-01 UTC 开始计数)。传错类型或时区,会导致时间步长计算偏移,所有验证全挂。
- 绝对不要用:
time.Now().UnixMilli()、time.Now().UnixNano()、time.Now().Local().Unix() - 必须用:
time.Now().UTC().Unix()—— UTC 是硬性要求,本地时区哪怕只差 1 小时,也会跨多个 30 秒窗口。 - 服务器时间偏差超过 90 秒(±3 个窗口)就会彻底失效,上线前务必确认 NTP 已同步:
sudo systemctl enable systemd-timesyncd && sudo systemctl start systemd-timesyncd
启用 MFA 前必须校验用户输入的首次 TOTP 值
最后,绑定流程也需要注意。用户扫码后,前端会提交一个当前动态码。这一步不是形式主义,它是唯一能确认“密钥已正确导入客户端+时间基本对齐”的手段。跳过它直接存密钥,等于允许攻击者上传任意 Base32 字符串完成绑定。
- 绑定流程:生成密钥 → 返回 Base32 字符串和
key.URL()给前端 → 用户扫码 → 提交一次当前 6 位码 → 后端调用totp.ValidateTime验证 → 成功才加密存储密钥原文(不是 Base32 字符串!)。 - 密钥原文(解码后的
[]byte)必须 AES-GCM 加密后落库,绝不能明文存字段如user.mfa_secret。 - MFA 开关字段(如
mf_enabled)必须独立存在,验证逻辑需前置判断:未启用则跳过 TOTP 校验,否则主密码流程会被污染。
说到底,最容易被忽略的就是密钥解码和时间戳类型这两个操作。看起来简单,但错一个字符或一个方法名,整个 TOTP 就静默失效,而且没有任何明确报错提示——它只会安静地返回 false。这才是关键所在。