恢复码是独立于设备和时间的一次性应急凭证,用完即失效且无法重生成,必须启用两步验证时生成并安全存档;其数据库字段需加密存储,因明文泄露等同于永久通行证,加密依赖app_key,更换密钥将使旧码失效。

恢复码不是“备用密码”,而是独立于设备和时间的一次性应急凭证,用完即失效,且无法重生成——必须在启用两步验证时就生成并安全存档。
为什么 recovery_codes 字段要加密存储
数据库里的 two_factor_recovery_codes 字段默认是加密的 JSON 字符串(Laravel 10+ 使用 encrypt,旧版可能用 serialize + encrypt),因为恢复码本身等同于“绕过 TOTP 的永久通行证”。一旦明文泄露,攻击者可直接登录,无需任何设备或时间同步。
- 加密由 Laravel 的
Encrypter完成,密钥来自APP_KEY,所以不能随意更换应用密钥,否则旧恢复码全部不可用 - 生成后立即加密写入,不建议手动解密查看内容;调试时可用
php artisan tinker调用decrypt()验证(仅限本地) - 若用自定义存储(如 Redis),需自行实现加解密逻辑,否则等于裸奔
Google2FA::generateBackupCodes() 和 Laravel 原生恢复码的区别
两者都生成 8–16 位随机字符串,但调用路径和生命周期管理完全不同:
-
Google2FA::generateBackupCodes()返回明文数组,需开发者自己加密、拆分、存库、标记已用——容易漏掉“标记已用”逻辑,导致同一码被重复使用 - Laravel 原生的
TwoFactorAuthenticatabletrait 在enableTwoFactorAuthentication()中自动调用generateRecoveryCodes(),生成后直接加密存入two_factor_recovery_codes,且每次useRecoveryCode()都会从数组中移除该码并更新字段 - 不要混用:如果用了
Google2FA包,就别再手动往two_factor_recovery_codes字段塞数据,否则原生验证流程会因格式不匹配失败
用户输入恢复码时的常见校验失败原因
报错 Invalid recovery code 不一定是用户输错,更可能是上下文状态不一致:
- 用户提交的是旧备份码,而数据库里已更新为新一批(比如用户重新启用了 2FA)
- 请求未携带 session 或 token,导致
Auth::user()为空,useRecoveryCode()内部直接返回 false - 字段值被意外截断:MySQL
TEXT类型默认长度足够,但如果手动改过字段类型为VARCHAR(255),加密后字符串超长会被静默截断,解密失败 - 缓存了旧的用户模型实例(比如用了
Cache::remember存用户),导致读取的two_factor_recovery_codes是过期快照
恢复码真正的难点不在生成,而在“用完即焚”的原子性保障——哪怕数据库更新成功但响应没发出去,用户也可能会误以为没生效而重复提交,造成下一次真正需要时已无可用码。











