二次验证接口不可与登录路由共用中间件,因jwt仅授权发起验证请求而非直接通过验证;须独立设计/init-2fa接口校验设备指纹、时间窗口等约束,并严格绑定sessionid实现会话级门禁。

二次验证接口不能和登录路由共用同一中间件
登录成功后发放的 token 本身不等于二次验证资格——它只是“允许你发起二次验证请求”的凭证。如果把 jwtAuthMiddleware 和二次验证 handler(比如 /auth/verify-2fa)绑在同一个路由组里,攻击者拿到短期有效的 JWT 就能直接调用该接口,绕过设备/会话绑定等前置条件。
正确做法是:登录接口返回 JWT 后,前端必须用该 token 请求一个独立的、带额外约束的二次验证发起接口(如 /auth/init-2fa),服务端在此接口中校验:
- JWT 有效且未被吊销
- 用户当前登录设备指纹(X-Device-ID)已记录且匹配
- 该用户最近 10 分钟内未触发过二次验证流程(防暴力试探)
- 返回的 challenge_id 绑定本次会话,后续 /auth/verify-2fa 只认这个 ID,不认 JWT
TOTP 验证必须用 constant-time 比较且校验时间窗口
Go 标准库的 == 比较字符串存在时序侧信道风险,攻击者可通过响应延迟差异爆破 TOTP 码。别写 if userCode == inputCode。
使用 crypto/subtle.ConstantTimeCompare 是底线:
- 先将输入码和数据库查出的预期码都转为 []byte
- 两者长度必须严格一致(不足 6 位补前导零,超长直接拒)
- 时间窗口设为 ±30 秒,但需同步校验客户端传来的 timestamp 是否落在 [now-30, now+30] 内,防止重放
- 单次 challenge_id 只允许验证 3 次,失败即失效,不锁账号(防拒绝服务)
恢复码(Recovery Code)必须一次性使用且不可逆生成
恢复码不是密码哈希,不能复用 bcrypt。它本质是一次性密钥,生成和校验逻辑必须隔离:
- 生成时用 crypt/rand.Read 取 16 字节,Base32 编码成 26 位字符串(如 Y7QX2N4R8T9VWZB3C5D6E7F8G9H)
- 存入 DB 前,用独立密钥(非主业务密钥)AES-GCM 加密,IV 随机生成并存字段旁
- 校验时先解密再比对,成功后立即执行 UPDATE ... SET used_at = NOW() WHERE id = ? AND used_at IS NULL
- 任何环节失败(解密异常、已使用、不存在)都统一返回 “无效恢复码”,不区分原因
二次验证状态必须与会话强绑定,禁用全局缓存
别把用户是否通过 2FA 的状态存在 Redis 全局 key 里(如 user:123:2fa_ok)。这会导致:同一用户多设备登录时互相干扰;服务重启后状态丢失;横向扩展时节点间不同步。
应将状态存在 session 层:
- Gin 中用 c.Request.Context() 携带 sessionID(来自 Cookie 或 Header)
- 所有二次验证相关操作(发起、校验、跳过)都以 sessionID 为唯一索引查 DB 或本地 LRU cache
- session 过期时间设为 15 分钟,且每次成功验证后重置 TTL
- 跳过二次验证(如“记住此设备”)也只对该 session 生效,不影响其他登录态
关键点在于:二次验证不是“用户级开关”,而是“会话级门禁”。所有判断依据必须锚定在当前请求上下文里可验证、不可伪造的字段上,而不是任何跨请求共享的状态。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











