OTP与2FA原理详解:从HOTP/TOTP算法到pyotp落地实践
2026/9/9 4:35:33 网站建设 项目流程

最近做登录安全改造,几乎每天都要跟这三个词打交道:OTP、2FA,以及一个 Python 库 pyotp。很多人以为给登录页加个动态口令就是 2FA,可真让他上手写代码,又说不清服务端到底要存什么、校验时到底在比什么。这篇文章就把整条链路拆开来讲:先厘清 OTP 这个词为什么会被"烧录"热搜带偏,再拆 2FA 的完整流程,然后从 HOTP 和 TOTP 的算法原理一路写到 pyotp 落地,最后补上生产环境里最常见的坑。适合两类人看:一类是想把原理搞清楚的开发,另一类是已经在用 pyotp 但总觉得哪里没吃透、想补全细节的同学。

1. 先把 OTP 这个词捋清楚:一次性密码和一次性烧录

1.1 OTP 的两种含义,别被热搜带偏

先说个很实际的困惑。最近搜 OTP 相关关键词,经常能看到"otp 烧录""cis otp 烧录原理"这类热门内容。这里的 OTP 和我们要做的动态口令完全不是一个东西,它是 One-Time Programmable 的缩写,指的是芯片里一次性可编程存储器,比如单片机里的 OTP ROM,烧录一次之后内容就不能再改。这个领域的人聊 OTP,聊的是熔丝、烧录器、固件保护。

而我们这篇文章里要讲的 OTP,是 One-Time Password,一次性密码,也叫动态口令。它俩缩写一样,但技术栈、应用场景、代码实现八竿子打不着。如果你搜资料的时候看到大量"烧录""芯片""熔丝"的内容,说明你已经跑错了方向,赶紧换关键词,搜 TOTP、HOTP、RFC 6238 这些。

顺带一提,很多做过硬件的人一听到"用 OTP 做双因素认证",第一反应也是懵的,因为他脑子里是芯片编程。所以团队协作时,涉及到这个话题先对齐一下语境,否则沟通成本非常高。我甚至见过有人在技术方案评审会上为这个事争论了十几分钟。

1.2 密码学 OTP 的两种形态:HOTP 与 TOTP

密码学意义上的 OTP,核心思想是一次一密:同一个密钥,每次生成的密码都不同,用过即弃,或者说在极短时间窗口内有效,从而避免重放攻击。它通常有两种标准形态。

HOTP,全称 HMAC-Based One-Time Password,基于 HMAC 的一次性密码,定义在 RFC 4226。它的原理是客户端和服务端共同维护一个计数器,每次认证后计数器加一,密码就是"计数器 + 共享密钥"做 HMAC 运算后截取出来的结果。

TOTP,全称 Time-Based One-Time Password,基于时间的一次性密码,定义在 RFC 6238。它把 HOTP 里的计数器换成了时间步长,最常见的是每 30 秒一个窗口。Google Authenticator、Microsoft Authenticator、1Password 这些软件动态口令用的都是 TOTP。

两者的关系很简单:TOTP 是 HOTP 的一种特殊变体,只不过把"手动递增计数器"改成了"按时间自动递增计数器"。

1.3 选型判断:无脑 TOTP,除非你能解决计数器同步

如果你正在设计一个新的认证系统,我的建议很直白:默认选 TOTP。

原因在于 HOTP 有一个绕不开的痛点:服务端和客户端各自维护一个计数器,这个计数器必须保持同步。用户可能在手机上按了好几次"生成按钮",但服务端并不知道;用户可能在两个设备上同时使用同一个密钥,计数器立刻分叉;用户可能长期不用,计数器严重落后,服务端需要做很复杂的"预同步"逻辑,允许一定范围内的偏移。

TOTP 就没有这个烦恼。它依赖的是时间和共享密钥,服务端在收到验证码时,用当前时间算出对应的窗口,再计算 OTP 做比对,不需要记住"用户上一次用到了第几个计数器"。这也是为什么 TOTP 能成为现代双因素认证的事实标准。

那 HOTP 还有没有用?有。比如某些硬件 Token,没有时钟芯片,只能靠按钮触发;再比如一些离线场景,客户端无法拿到准确时间,只能靠计数。但在常规 Web 应用和移动 App 里,TOTP 几乎是唯一正确解。

2. 2FA 的完整流程:注册、登录与无状态验证

2.1 先搞清楚什么是"双因素"

2FA 是 Two-Factor Authentication 的缩写,双因素认证。它的核心不是"多一步验证",而是"多一个因素"。

认证因素一般分三类:

  • 知识因素:你知道什么,例如密码、PIN。
  • 持有因素:你拥有什么,例如手机、硬件 Token、安全密钥。
  • 生物因素:你是什么,例如指纹、人脸、虹膜。

只有密码,属于单因素;密码加短信验证码,如果短信验证码是发到你手机上的,那"手机"就是持有因素,这算是双因素;密码加邮箱验证码,如果邮箱本身只有密码保护,那仍然等同单因素,因为攻击者拿到你的密码可能也拿到了邮箱——这点很多人会忽略。

所以真正的 2FA,必须是"知识因素 + 持有因素"或者"知识因素 + 生物因素"的组合。这篇文章里讲的场景,是"密码 + TOTP",密码是知识因素,装了认证器 App 的手机是持有因素。

2.2 一次完整的 2FA 注册与登录流程

我把整个流程拆成注册和登录两段来讲,这比单纯背概念有用得多。

注册阶段:

  1. 用户输入用户名、密码,完成账号创建。
  2. 用户主动开启"两步验证"功能。
  3. 服务端调用pyotp.random_base32()生成一个随机密钥。
  4. 服务端把这个密钥以otpauth://totp/...这样一段 URI 的形式转成二维码,展示给用户。
  5. 用户用认证器 App 扫描二维码,App 保存密钥,开始本地生成动态口令。
  6. 用户把 App 上当前显示的 6 位验证码回填给服务端。
  7. 服务端用刚生成的密钥和当前时间窗口计算一次 OTP,和用户输入比对。比对成功,说明用户确实已经把密钥正确保存到了自己的设备上,此时才把密钥正式存入数据库,开启 2FA。

注意第 6、7 步。很多新手会偷懒:生成密钥后直接把二维码甩给用户,连一次校验都不做。结果是用户扫完码,App 里保存的密钥和服务端不一致,或者用户根本没有认真保存,等到真正登录时才暴露问题,体验非常烂。必须在注册阶段做一次验证闭环,这叫"激活确认"。

登录阶段:

  1. 用户输入用户名和密码,服务端校验密码。
  2. 密码正确后,服务端进入"待 2FA 验证"状态,要求用户输入动态口令。
  3. 用户打开认证器 App,看到当前 6 位数字,输入到页面里。
  4. 服务端从数据库取出该用户保存的密钥,结合当前时间窗口计算 OTP,然后与用户输入比对。
  5. 一致则放行,建立登录会话;不一致则拒绝,并记录一次失败尝试。

这个流程里最关键的认知点是:服务端不保存"验证码本身",它保存的是密钥。验证码是服务端在接收到请求的那一刻,用密钥和当前时间现场算出来的。这就是为什么这种验证方式没有"验证码过期后还能不能查到记录"这种问题,天然无状态。

2.3 OTP 与短信验证码的本质差异

很多人会混淆 TOTP 和短信验证码。两者确实看起来很像:都是 6 位数字,都强调时效性。但底层逻辑差异巨大。

短信验证码是"服务端生成、下发给用户、再和用户输入比对"。它需要在服务端保存一份状态,记录这个验证码发给了谁、什么时候过期、已经试了几次。如果数据库被拖走,历史验证码可能被翻出来;如果短信通道被劫持,验证码可能在传输中就泄露。

TOTP 是"客户端和服务端各自独立计算同一个值"。它不需要把验证码从服务端传到用户手机,验证码是在用户手机里本地生成的。传输链路里只有用户最终输入的那一次交互,而且这个验证码 30 秒后就失效了。

此外,TOTP 不需要短信通道,没有运营商费用,也不存在"短信延迟 5 分钟导致验证码过期"的体验问题。更重要的是,短信验证码严重依赖手机号的安全性和短信网关的可用性,而 TOTP 只要认证器 App 正常,离线也能生成验证码。

但 TOTP 不是没有弱点。它最怕的是中间人实时转发:攻击者在钓鱼网站上拿到用户当前输入的验证码,立刻用于真实站点登录。这个后面第 6 章会展开讲。

3. HOTP 与 TOTP 的底层算法:HMAC、动态截断和时间步长

3.1 HOTP 的四步演算

要真正吃透 OTP,不能停留在库调用层面,必须把 RFC 4226 的公式看懂。HOTP 的公式长这样:

HOTP(K, C) = Truncate(HMAC-SHA-1(K, C))

其中 K 是共享密钥,C 是计数器。展开来看,整个计算过程分四步。

第一步,把计数器编码成 8 字节的大端整数。比如计数器是 0,编码后就是00 00 00 00 00 00 00 00,计数器是 1,就是00 00 00 00 00 00 00 01

第二步,用 HMAC-SHA1 对"计数器"做计算,密钥是 K。HMAC 的本质是带密钥的哈希,它可以看成"用密钥给消息加盐后再哈希",输出的是一段固定长度的伪随机字节流。SHA1 的输出是 20 字节,所以 HMAC 之后我们得到一个 20 字节的数组。

第三步,动态截断(Dynamic Truncation)。取这 20 个字节的最后一个字节,和0x0f做按位与,得到偏移量 offset,范围在 0 到 15 之间。然后从 offset 开始,连续取 4 个字节。

这 4 个字节是一个 32 位整数,但它的最高位(符号位)必须被弃掉,做法是和0x7fffffff做按位与。这样我们就得到了一个 31 位的非负整数。

第四步,把这个整数对 10 的 digits 次方取模。digits 默认是 6,也就是% 1000000,不足 6 位前面补零。

这就是 HOTP 的全部秘密。所有看似奇怪的步骤,目标只有一个:把一长串无规律的二进制数据,压缩映射成一个人类方便输入的口令数字。

3.2 TOTP:把时间变成计数器

TOTP 在 RFC 6238 里的定义非常简洁:

TOTP = HOTP(K, T) T = (Unix Time - T0) / X

解释一下。Unix Time 是当前时间戳,单位是秒。T0 是起始时间,常规取 0。X 是时间步长,常规取 30 秒。T 就是对两者做整数除法得到的整数。

也就是说,TOTP 根本没发明新的密码算法,它只是把一个固定的计数器换成了"当前时间戳除以时间步长"的结果。

假设当前 Unix 时间是 1700000000,除以 30 取整后得到 56666666。服务端和用户的手机只要时间一致,算出来的计数器就完全一致,自然能算出同一个验证码。

30 秒之后,Unix 时间变成 1700000030,除以 30 取整得到 56666667,计数器变化了,验证码也就跟着变了。所以 TOTP 天然具备"自动滚动"的能力,用户不需要手动按键刷新。

3.3 三个"反直觉但正确"的设计细节

第一,为什么用 SHA1?现在很多安全标准早就淘汰 SHA1 了,但 TOTP 的兼容基底仍然是 SHA1。原因在于这里根本不需要 SHA1 的抗碰撞性——我们不是拿它做签名,不涉及"找到两个相同摘要"的攻击。这里的关键是密钥保密性和 HMAC 的伪随机性,SHA1 在这两个维度上目前仍然可用。更重要的是,Google Authenticator 等老牌 App 对 SHA1 支持最广,换成 SHA256 反而可能遇到兼容问题。

第二,为什么取最后字节的低 4 位作为偏移量?因为我们要从 20 字节里均匀地切出一段,偏移量必须在 0 到 15 之间。最后一个字节的低 4 位正好能表达 0 到 15,而且它本身来源于 HMAC 结果,具有足够随机性。这个设计不是为了安全对抗,是为了保证截断位置不完全固定,从而避免每次输出都只依赖前几个字节。

第三,为什么去掉最高位?RFC 4226 的作者在设计时考虑了跨语言兼容。某些语言会把 32 位整数当成有符号数,如果最高位是 1,就成了负数,取模结果会不稳定。统一把最高位清零,确保任何语言实现出来的结果一致。这就是标准的力量:你可以用 Python、Go、Java 各实现一遍,算出来的口令完全相同。

4. 不依赖 pyotp,先用标准库手写 TOTP

4.1 准备工作:一个兼容 RFC 的密钥

搞清楚算法之后,我强烈建议你不要急着写 pyotp,先用 Python 标准库手写一版 TOTP。这样以后无论换什么语言,你都能从容应对。

先准备一个密钥。RFC 4226 附录 D 给了一组官方测试向量,密钥是 ASCII 字符串:

secret = b"12345678901234567890"

对应的测试向量如下:

计数器HOTP 输出
0755224
1287082
2359152
3969429
4338314

这组向量是国际标准,不是谁随便编的,调试的时候直接用这组数据最稳。你手写出来的代码,如果在这个密钥下算不出755224,别怀疑标准,先检查代码。

4.2 手写 HOTP 核心函数

下面是我经常在项目里用来讲解和做工具验证的 HOTP 实现:

import hashlib import hmac import struct def hotp(secret: bytes, counter: int, digits: int = 6) -> str: # 第一步:计数器编码为 8 字节大端整数 counter_bytes = struct.pack(">Q", counter) # 第二步:HMAC-SHA1 mac = hmac.new(secret, counter_bytes, hashlib.sha1).digest() # 第三步:动态截断 offset = mac[-1] & 0x0F binary = int.from_bytes(mac[offset:offset + 4], "big") & 0x7FFFFFFF # 第四步:取模并补零 otp = binary % (10 ** digits) return str(otp).zfill(digits)

逐行解释一下关键点。

struct.pack(">Q", counter)是把整数转换成 8 字节无符号大端字节序。>表示大端,Q表示无符号 64 位整数。大端的意思是最高位字节放最前面,这和网际协议里面的字节序习惯一致,RFC 里也是这么定义的。

mac[-1] & 0x0F取最后字节的低 4 位。mac[offset:offset + 4]是从动态偏移位置切出 4 个字节。int.from_bytes(..., "big")把这 4 个字节解析成整数,再用& 0x7FFFFFFF把最高位清零。

binary % (10 ** digits)把整数映射到 0 到 999999 之间,zfill(6)保证不足 6 位时前面补零。

这个函数就完成了 HOTP 的全过程。

4.3 手写 TOTP 并用 RFC 测试向量验证

TOTP 只不过是在 HOTP 外面套了一层时间转计数器:

import time def totp(secret: bytes, interval: int = 30, digits: int = 6) -> str: counter = int(time.time()) // interval return hotp(secret, counter, digits)

int(time.time()) // 30就是当前时间窗口的编号。只要调用方和服务端时钟一致,算出来的结果就一致。

先验证 HOTP 是否实现正确:

print(hotp(b"12345678901234567890", 0)) # 应输出 755224 print(hotp(b"12345678901234567890", 1)) # 应输出 287082

如果你的输出和表里一致,说明 HOTP 逻辑没问题。至于 TOTP,没有固定的静态测试向量,因为它的结果随时间变化。你可以手动构造一个固定时间戳来测试,比如假设时间是 1700000000,算一下1700000000 // 30对应的计数器,然后调用hotp,再和后面 pyotp 的结果对比。

4.4 手写实现里容易漏掉的两个细节

第一个细节是密钥编码。实际业务里,密钥通常是 Base32 编码的字符串,而不是裸字节。用户拿到的是类似JBSWY3DPEHPK3PXP这样的字符串,你在计算前必须先解码成字节:

import base64 def base32_decode(secret: str) -> bytes: return base64.b32decode(secret.upper())

为什么用 Base32?因为 Base32 字符集只有A-Z2-7,总共 32 个字符,刻意剔除了0189这些容易混淆的字符。另外 Base32 不区分大小写,用户手动输入时容错率高。这就是为什么认证器 App 里的密钥全是字母数字但很少出现 0 和 1。

第二个细节是处理用户输入的容错。用户在登录页面手输验证码时,偶尔会带空格或中间横线,比如123 456或者123-456。很多新手直接拿去比对,结果永远失败。稳妥的做法是先把输入里所有非数字字符去掉再比对。

我在手写实现时还有一个调试技巧:临时写个函数,打印 offset 和 binary 这两个中间量,和已知正确实现对比。一旦发现自己手写结果和 pyotp 对不上,先看这两个值,基本能定位是截断逻辑还是字节序的问题。

5. pyotp 代码落地:密钥生成、校验接口与二维码

5.1 安装与密钥生成

新手用 pyotp 最常见的误区是拿到库就直接TOTP(secret).now()生成验证码,完全不管密钥是怎么来的、应该怎么存。正确的落地姿势是从密钥生成开始规划。

安装很简单:

pip install pyotp

生成密钥的习惯用法:

import pyotp # 默认生成 16 个字符的 Base32 字符串,约 80 位熵 short_secret = pyotp.random_base32() # 生产环境建议使用更长密钥 secret = pyotp.random_base32(32) # 32 个字符,约 160 位熵

这里有个细节值得说。pyotp.random_base32()默认只生成 16 个字符的 Base32 字符串,对应 80 位随机性。对大多数应用来说 80 位已经够用,但既然标准建议更高强度,而生成 32 个字符成本几乎为零,我一般直接传32。这不是强迫症,是让密钥强度从源头对齐安全最佳实践。

密钥生成之后,立即把它转成 provisioning URI 准备生成二维码,这个 URI 是认证器 App 识别密钥的唯一凭证。

5.2 最基本的 TOTP/HOTP 调用

TOTP 的基本用法:

import pyotp secret = pyotp.random_base32(32) totp = pyotp.TOTP(secret) # 生成当前验证码 current_code = totp.now() print(current_code) # 校验用户输入 print(totp.verify(current_code)) # True print(totp.verify("000000")) # False

TOTP(secret)默认使用 SHA1、6 位数字、30 秒窗口。如果你想调整,可以显式指定:

totp = pyotp.TOTP(secret, digits=8, digest=hashlib.sha256, interval=30)

不过我要提醒一句:修改默认参数前要确认你的认证器 App 是否支持。Google Authenticator 对 8 位、SHA256 的支持在不同版本上并不一致,强行用高配置反而可能让部分用户扫不了码。除非你有专门定制的 App,否则保持默认最省心。

HOTP 用法:

hotp = pyotp.HOTP(secret) print(hotp.at(0)) print(hotp.verify("755224", 0)) # 注意必须传入计数器

HOTP 校验时必须告诉库"当前计数器是多少",而且验证成功后服务端要把计数器加一。这个计数器的持久化是个麻烦事,所以我还是建议:新项目优先 TOTP。

5.3 provisioning URI 与二维码

认证器 App 扫描的二维码,内容不是密钥本身,而是一个 URI。pyotp 提供了现成方法:

uri = pyotp.TOTP(secret).provisioning_uri( name="alice@example.com", issuer_name="MyApp" ) print(uri)

输出类似:

otpauth://totp/MyApp:alice@example.com?secret=...&issuer=MyApp&algorithm=SHA1&digits=6&period=30

这个 URI 的格式是公开标准,Authy、Google Authenticator、Microsoft Authenticator 都认它。name是用户标识,通常会显示在 App 的条目名称里,issuer_name是应用名称。

生成二维码可以用qrcode库:

pip install qrcode
import qrcode img = qrcode.make(uri) img.save("qrcode.png")

如果你是在服务器终端环境里做调试,可以直接输出 ASCII 二维码:

qrcode.make(uri).print_ascii()

注意,用户扫码完成之后,密钥必须立即从响应里"消失"。很多项目把 URI 和密钥存在 HTTP 响应里供前端反复获取,这是安全隐患:任何能访问到你接口日志的人都能拿到密钥,2FA 形同虚设。正确做法是注册激活时返回一次,之后只返回 URI 或直接不再返回密钥原文。

5.4 一个最小化服务端校验闭环

下面我用 Flask 写一个最小但完整的闭环,方便你理解怎么把 pyotp 集成进真实项目。

注册阶段生成密钥并返回 URI:

from flask import Flask, request, jsonify import pyotp app = Flask(__name__) @app.post("/api/2fa/register") def register_2fa(): user = get_current_user() # 假设已从会话中拿到当前用户 # 生成新密钥 secret = pyotp.random_base32(32) # 生成 otpauth URI uri = pyotp.TOTP(secret).provisioning_uri( name=user["email"], issuer_name="MyApp" ) # 先不落库,放到临时状态,等待激活确认 pending_secrets[user["id"]] = secret return jsonify({"uri": uri, "secret": secret})

激活确认接口:

@app.post("/api/2fa/activate") def activate_2fa(): user = get_current_user() secret = pending_secrets.get(user["id"]) if not secret: return jsonify({"error": "no pending secret"}), 400 code = request.json.get("code", "") totp = pyotp.TOTP(secret) if not totp.verify(code): return jsonify({"error": "invalid code"}), 400 # 激活成功,密钥加密后落库 user["otp_secret"] = encrypt_secret(secret) save_user(user) pending_secrets.pop(user["id"], None) return jsonify({"ok": True})

登录接口的第二步校验:

@app.post("/api/login/2fa") def login_2fa():: user = get_user_from_session() if not user: return jsonify({"error": "password step required"}), 403 code = request.json.get("code", "") secret = decrypt_secret(user["otp_secret"]) totp = pyotp.TOTP(secret) if not totp.verify(code, valid_window=1): return jsonify({"error": "invalid otp"}), 400 # 校验通过,为会话打上 2FA 标记 session["second_factor_verified"] = True return jsonify({"ok": True})

这个闭环里有几个关键词值得展开:pending_secrets是临时存储,可以用内存或 Redis;encrypt_secret是对称加密函数,生产环境建议用 KMS 托管密钥;valid_window=1是允许前后各一个时间窗口的容错。

5.5 校验接口的注意点

第一个注意点:verify的第三个参数valid_window表示前后各容忍多少个窗口。每个窗口 30 秒,valid_window=1意味着允许当前时间前后各 30 秒的偏差,总共检查 3 个窗口。

第二个注意点:不要在 try/except 里吞掉底层异常。verify如果收到非数字、空字符串,一般不会抛异常而是返回 False,但你最好在入口做显式格式校验,避免脏数据一路传到数据库。

第三个注意点:verify本身不区分"验证码错误"和"验证码已过期"。如果用户输入了一个 5 分钟前的验证码,它只是返回 False。如果产品需要给用户更精确的提示,比如"验证码已过期,请刷新后重试",你得自己计算下时间窗口编号,然后给出对应文案。

6. 生产环境最容易翻车的六个细节

6.1 服务器时间必须 NTP 同步

TOTP 依赖时间,这是它最大的软肋。如果服务器时间漂移超过 30 秒,客户端算出的验证码服务端会判定为不正确,用户就会遇到"明明输入的是 App 上显示的码,却一直登录失败"的诡异问题。

解决办法是配置 NTP 时间同步服务。Linux 上用chronyntpd都行,定期校准系统时间。容器环境里尤其要注意,有些精简镜像默认没有安装时钟同步服务,或者容器宿主机时间本身就不准。

另外,不只服务器要准,用户的手机也要准。如果用户手机系统时间手动改慢了 5 分钟,验证码同样对不上。这种情况不是你能用代码解决的,需要在用户多次验证失败时提示"请检查设备时间是否准确"。

6.2 密钥存储与日志脱敏

服务端保存的密钥是 2FA 体系里最敏感的数据。它相当于"第二把钥匙"的母版,一旦泄露,攻击者就能自己生成任意时刻的验证码。

数据库里存储密钥,必须加密。我见过有人在用户表里直接加一个otp_secret字段,存的还是明文,一旦数据库泄露,等于所有用户的 2FA 全部作废。正确做法是使用 AES-GCM 等认证加密算法,用独立的密钥管理系统(如 KMS)保管主密钥,数据库里只存密文。

日志脱敏同样重要。很多人会在排查问题时顺手打印用户对象,或者打印请求参数,如果日志里包含了otp_secret字段,就等于把密钥写进了日志系统。日志的访问权限通常比数据库宽松很多,这是非常隐蔽的泄露路径。建议在日志配置里直接过滤掉secretotppassword这类字段。

6.3 valid_window 是用来容忍时钟偏差的,不是给暴力破解开的门

valid_window=1很好用,它能同时容忍服务器和用户手机各有 30 秒左右的偏差。但我见过有人图省事,直接设成valid_window=10,意思是一下子容忍前后 300 秒的偏差。

这会带来一个安全问题:验证码的有效时间窗口越长,攻击者暴力破解的时间窗口就越大。6 位验证码一共只有 100 万种组合,在 10 分钟的有效窗口里,如果攻击者能以每秒几百次的速度遍历,撞库成功的概率会显著上升。

所以我的建议是:valid_window保持 1 到 2 即可,时钟偏差太大不是靠放宽窗口来解决的,而是靠 NTP 同步和设备时间校准来解决。

6.4 恢复码、重新绑定与设备丢失

用户手机丢了、App 被卸载了,怎么办?这是 2FA 落地必须提前设计好的流程,不然客服会被骂死。

业界通行做法是:开启 2FA 时,一次性生成 8 个恢复码,让用户抄下来存到安全的地方。每个恢复码只能用一次,服务端保存的是恢复码的哈希值,而不是明文。当用户无法提供 OTP 时,可以用恢复码登录,登录成功后该恢复码立即作废。

恢复码的格式建议是一串可读的随机字符串,不要用纯数字,否则 8 位数字也可能被穷举。生成时用secrets.token_urlsafe(16)之类的随机源,保证不可预测。

另外要提供"重新绑定"功能:用户通过密码和恢复码验证身份后,可以生成新的密钥并重新扫码。这个操作意味着旧密钥立即失效,如果旧设备还在别人手里,旧设备上的验证码从此作废。

6.5 速率限制是必须项而不是可选项

6 位验证码意味着理论上有 100 万种可能。如果登录接口不限制尝试次数,攻击者可以快速遍历整个空间。TOTP 和短信验证码最大的不同就在这里:短信验证码有服务端状态,可以控制"发送频率"和"验证次数",而 TOTP 是无状态校验,服务端每次接收验证码都不知道这个用户之前试了多少次,除非你自己加一层限制。

推荐做法是:对同一个账号,每分钟最多允许 5 次 OTP 校验失败,超过后锁定 10 分钟;对同一个 IP,每小时最多允许 20 次不同账号的 OTP 校验失败。方案落地时可以用 Redis 做计数器,INCREXPIRE即可。

还有一个小技巧:验证失败时统一返回"用户名、密码或验证码错误",不要告诉用户具体是哪一步错了,避免攻击者先爆破密码再单独爆破 OTP。

6.6 OTP 不是银弹

最后说点泼冷水的话。TOTP 能解决"密码被撞库后账号被直接登录"的问题,但它防不住钓鱼和中间人实时转发。

举个例子:攻击者搭建一个和真实站点一模一样的钓鱼页面,用户在这个页面输入密码和 OTP,攻击者立刻把这些数据转发到真实站点完成登录。整个过程里,用户的 OTP 是真实有效的,只是被"中转"了。TOTP 对此无能为力。

这也是为什么我更建议在风险高的场景里叠加 WebAuthn/FIDO2 这类基于公钥密码学的无密码认证,它通过绑定域名和来源,能有效防止钓鱼站点借走凭证。TOTP 可以作为过渡方案和基础方案,但它不应该是你对"双因素"的全部理解。

话说回来,TOTP 仍然是我做项目时的默认选项,因为它零成本、标准成熟、用户接受度高。只不过在方案评审时,我会把"防钓鱼能力不足"这个边界条件明确写出来,让业务方知道它保护到什么程度。

最后分享一个我的个人习惯。我会把 OTP 校验封装成独立模块,返回结构里包含validreasonremaining_attempts三个字段,而不是简单返回布尔值。这样上层既能决定是否放行,也能决定失败后怎么提示用户。封装好之后,无论前端是 Web 页、小程序还是原生 App,接入成本都压得很低。至于密钥生成、加密存储、恢复码,这些逻辑都放在同一个模块里,避免散落在业务代码里造成遗漏。这套东西我用了很多年,稳定可靠,你拿去直接参考也完全可行。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询