Gin的OTP认证易卡在“只校验密码”是因为框架不内置认证流程,需显式用中间件控制状态流转:密码通过后必须强制跳转OTP页,OTP接口须校验otp_code并处理时间偏移,且服务端时间须与NTP同步。

为什么 Gin 的 OTP 认证容易卡在“只校验了密码”这一步
因为 Gin 本身不内置认证流程,google-authenticator 或 oath 类库只负责算码,不自动拦截请求。你写了生成二维码、也存了密钥,但没在登录后强制跳转到 OTP 输入页,也没在后续接口里读取并校验 otp_code 参数——结果就是整个流程“看起来能跑”,实际第二步根本没触发。
常见错误现象:
- 用户输完账号密码直接进后台,没看到 OTP 输入框
- 前端传了
otp_code,后端日志里压根没收到或没解析 - 校验函数返回
false,但没检查时间窗口(比如服务端时间比客户端快 2 分钟)
关键点:
- 必须显式用中间件或路由分组控制「密码通过后 → 要求 OTP」的状态流转
- OTP 校验不能放在登录接口里一次性做完(否则无法区分“密码错”和“OTP 错”)
- 服务端时间必须和 NTP 同步,误差超过 1.5 个时间片(默认 30 秒)就可能失败
如何用 github.com/pquerna/otp 库做 TOTP 校验
github.com/pquerna/otp 是 Go 生态最稳定的 TOTP 实现,比手写 HMAC-SHA1 更可靠。它不依赖外部服务,纯内存计算,适合 Gin 这类轻量框架。
实操建议:
- 生成密钥时用
otp.GenerateDefaultTOTPConfig(),别手动拼base32字符串,易出编码错误 - 校验时调用
totp.Validate,传入用户提交的otp_code和当前时间,它会自动处理 ±1 时间片容错 - 不要自己实现时间片计算:用
time.Now().Unix() / 30是错的——必须用totp.TimeStep内部逻辑对齐起始时间点
示例片段(校验逻辑):
valid := totp.Validate(otpCode, user.Secret)
if !valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid otp code"})
return
}
Gin 中如何设计 OTP 状态机而不爆 session
很多人用 gin-contrib/sessions 存 “已通过密码” 状态,但忘了清理。结果用户关掉页面再重登,session 还在,直接跳过 OTP——安全链条就断了。
更稳妥的做法是把状态存在服务端短期缓存(如 map[string]time.Time + 定时清理 goroutine),或用带 TTL 的 Redis。但若只是个人项目,可简化为:
- 密码验证成功后,返回一个临时 token(如 JWT,有效期 5 分钟),里面塞
user_id和step: "password_ok" - OTP 输入接口只认这个 token,且校验通过后立即作废(不续期)
- 前端拿到 token 后,所有后续请求都带
Authorization: Bearer <token></token>,Gin 中间件统一鉴权
注意:jwt-go 已归档,推荐用 golang-jwt/jwt/v5,签发时务必设 exp 和 iat。
前端扫码绑定时,二维码 URL 怎么生成才兼容 Google Authenticator
Google Authenticator 只认特定格式的 otpauth:// URI。少一个参数或大小写不对,扫码就失败。
必须满足:
- 协议头是
otpauth://totp/(不是hotp,也不是小写totp) - 路径部分用 URL 编码过的用户名(如
myapp%3Auser%40example.com) - 查询参数含
secret(base32 编码)、issuer(应用名)、algorithm=SHA1、digits=6、period=30
示例(生成后端返回给前端):
otpauth://totp/MyApp%3Auser%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyApp&algorithm=SHA1&digits=6&period=30
别用 qrcode 库直接渲染字符串——先用 url.QueryEscape 处理 secret 和 issuer,否则中文 issuer 一定扫不出。
真正难的不是生成密钥或算码,而是让三个时间点对齐:用户手机系统时间、服务器系统时间、TOTP 算法内部的时间片起始点。哪怕只有 90 秒偏差,校验就会静默失败,而日志里看不出任何异常。











