ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TOTP双因素认证原理与实现:从HOTP到动态验证码的工程实践

TOTP双因素认证原理与实现:从HOTP到动态验证码的工程实践

1. 从一个登录框说起:TOTP 到底解决了什么问题

很多人第一次接触 TOTP,是在某个后台系统或者云平台的登录页面上——输完账号密码,页面突然跳出一行提示:“请输入身份验证器中的 6 位动态码”。手机掏出来,打开一个叫“身份验证器”的应用,屏幕上正好有个 6 位数字在倒计时,30 秒一跳。输进去,登录成功。

这个场景背后,就是TOTP(Time-based One-Time Password,基于时间的一次性密码)在干活。它属于双因素认证(2FA)里最主流、最轻量的一种实现方式。所谓“双因素”,说白了就是:光有密码不够,还得有第二把钥匙。这把钥匙不是短信验证码,也不是硬件令牌,而是你手机上一个离线运行的 App 实时算出来的一串数字。

为什么这套机制值得单独拿出来讲?因为它解决了一个非常现实的问题:密码是会泄露的。数据库被拖库、钓鱼网站骗走、撞库攻击命中,任何一种情况发生,单靠密码的防线就塌了。而 TOTP 的价值在于,即使攻击者拿到了你的密码,没有你手机上那个动态码,他依然进不去。更关键的是,这个动态码不依赖网络——手机开飞行模式照样能算出来,因为它本质上是“共享密钥 + 当前时间”做一次哈希运算的结果。

这篇文章适合谁看?如果你是后端开发者,需要给自己的系统加上 2FA;如果你是运维或者安全从业者,想搞清楚身份验证器背后的原理;或者你只是个普通用户,好奇手机里那个 6 位数字到底怎么来的——接下来的内容都能让你从“会用”走到“懂原理”,再到“能自己实现”。

我会从整体设计思路讲起,把 TOTP 和它背后的 HOTP 标准拆开揉碎,然后给出可以直接抄作业的实现步骤,最后把我自己踩过的坑和排查经验整理出来。全程不堆砌术语,能用生活类比的地方绝不用公式吓人。

2. 整体设计思路:为什么是“时间”而不是“次数”

2.1 从 HOTP 到 TOTP 的演进逻辑

要理解 TOTP,得先知道它哥——HOTP(HMAC-based One-Time Password)。HOTP 的思路很朴素:客户端和服务器共享一个密钥,外加一个计数器。每生成一次密码,计数器加一。服务器那边也维护同一个计数器,两边对上了,验证通过。

这个方案在硬件令牌时代很常见,比如早期那种按一下按钮出一个数字的实体令牌。但它有个致命问题:计数器会漂移。用户不小心多按了几次,或者令牌和服务器之间的计数不同步了,就会出现“我明明按规矩来的,怎么就是验证不过”的情况。而且计数器是单调递增的,一旦服务器端因为某种原因回滚或者丢失状态,整个链条就乱了。

TOTP 的改进思路非常巧妙:把“计数器”换成“时间”。时间这个东西,全世界都认,不需要两边同步维护一个状态变量。具体来说,把当前时间戳除以一个固定的时间步长(通常是 30 秒),得到一个整数,这个整数就充当了 HOTP 里的计数器。这样一来,只要两边的时钟大致对齐,生成的密码就能对上。

这里有个关键点:TOTP 并没有抛弃 HOTP,而是把 HOTP 当作底层算法,只是把计数器换成了时间派生出来的值。所以 RFC 6238(TOTP 标准)是建立在 RFC 4226(HOTP 标准)之上的。

2.2 为什么选 30 秒作为默认步长

时间步长这个参数,直接决定了用户体验和安全性的平衡。选 30 秒不是拍脑袋定的,背后有实际考量:

  • 太短(比如 10 秒):用户还没输完就过期了,体验极差,尤其是手动输入 6 位数字的场景。
  • 太长(比如 5 分钟):攻击者截获一个码之后,有足够长的时间窗口去重放,安全性下降。
  • 30 秒:给了用户足够的输入时间,同时把重放窗口控制在一个可接受的范围内。而且 30 秒能被 60 整除,和分钟概念对齐,心理上也好理解。

实际实现中,服务器通常会允许前后各一个时间窗口的容错,也就是当前窗口、上一个窗口、下一个窗口都接受。这样即使客户端时钟稍微快一点或慢一点,也不至于直接验证失败。这个容错机制是 TOTP 能落地的前提,后面讲实现时会详细说。

2.3 共享密钥:整个体系的安全根基

TOTP 的安全性完全建立在共享密钥(Shared Secret)之上。这个密钥在绑定阶段由服务器生成,通过二维码或者手动输入的方式传给客户端,之后两边各自保存,再也不通过网络传输。

这里的设计哲学是:密钥只在绑定那一刻暴露一次,之后永远离线。这也是为什么身份验证器 App 可以在飞行模式下工作——它根本不需要联网,所有计算都在本地完成。

密钥的格式通常是 Base32 编码的字符串。为什么用 Base32 而不是 Base64?因为 Base32 的字符集是 A-Z 和 2-7,全是大小写不敏感、不容易看错的字符。你想想,如果密钥里混进了小写字母 l 和数字 1、大写 O 和数字 0,用户手动输入时得多崩溃。Base32 牺牲了存储效率(比 Base64 多占约 20% 空间),换来了人类可读性和输入容错性,这个取舍在需要手动输入密钥的场景下非常值得。

2.4 二维码里到底藏了什么

绑定阶段扫的那个二维码,内容其实是一个otpauth URI,格式长这样:

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

拆开看几个关键参数:

参数含义常见取值
type类型totp 或 hotp
label账号标识通常是 issuer:account
secret共享密钥Base32 编码字符串
issuer发行方显示在验证器里的名称
algorithm哈希算法SHA1 / SHA256 / SHA512
digits验证码位数6 或 8
period时间步长(秒)默认 30

身份验证器 App 扫到这个 URI,解析出 secret 和参数,就能开始生成动态码了。整个绑定过程不需要服务器和 App 之间有实时通信,二维码本身就是全部信息载体。这种“离线绑定”的设计,让 TOTP 的部署成本极低——服务器只要生成一个二维码图片,剩下的交给 App 就行。

3. 核心细节解析:6 位数字是怎么算出来的

3.1 一步步拆解 TOTP 的计算过程

很多人觉得 TOTP 神秘,是因为那串 6 位数字看起来像是随机蹦出来的。其实它的计算过程非常确定,给定密钥和时间,结果唯一。我把整个过程拆成五步:

第一步:确定时间计数器

取当前 Unix 时间戳(从 1970-01-01 00:00:00 UTC 开始的秒数),除以时间步长,向下取整。

counter = floor(current_unix_timestamp / 30)

比如当前时间戳是 1700000000,除以 30 得 56666666.67,向下取整就是 56666666。这个整数就是本次的“时间计数器”。

第二步:把计数器转成 8 字节大端序

HOTP 算法要求计数器是一个 8 字节的二进制串,采用大端序(高位在前)。56666666 转成十六进制是 0x0360B5AA,补齐成 8 字节就是:

00 00 00 00 03 60 B5 AA

第三步:用密钥做 HMAC 运算

用共享密钥作为 HMAC 的密钥,对上一步得到的 8 字节数据做 HMAC-SHA1 运算,得到一个 20 字节的哈希值。

hmac_result = HMAC-SHA1(secret, counter_bytes)

第四步:动态截断(Dynamic Truncation)

20 字节的哈希值不能直接拿来用,需要截断成 4 字节。截断规则是:取哈希值最后一个字节的低 4 位作为偏移量,然后从这个偏移量开始取 4 个字节。

offset = hmac_result[19] & 0x0F binary_code = (hmac_result[offset] & 0x7F) << 24 | (hmac_result[offset+1] & 0xFF) << 16 | (hmac_result[offset+2] & 0xFF) << 8 | (hmac_result[offset+3] & 0xFF)

注意第一个字节要和 0x7F 做与运算,这是为了去掉符号位,保证结果是一个正数。

第五步:取模得到最终验证码

把上一步得到的 31 位整数对 10^6 取模,就得到 6 位数字。如果不足 6 位,前面补零。

code = binary_code % 1000000

整个过程用一句话概括:时间决定计数器,计数器加密钥做哈希,哈希截断后取模,得到 6 位数字。没有任何随机性,同样的输入永远得到同样的输出。

3.2 为什么用 HMAC 而不是普通哈希

有人可能会问:直接对“密钥+时间”做 SHA1 不就行了吗,为什么要用 HMAC?

这是个好问题。普通哈希函数(比如直接 SHA1(secret + counter))存在一个理论上的风险,叫长度扩展攻击。攻击者如果知道 SHA1(secret + counter) 的结果,有可能在不知道 secret 的情况下,推算出 SHA1(secret + counter + padding + extra) 的结果。虽然在这个具体场景下利用起来有难度,但密码学的基本原则是:不要自己发明构造方式,用经过验证的标准。

HMAC 的设计就是为了避免这类问题。它用两层哈希(内层和外层)把密钥和消息混合,结构上更健壮。RFC 4226 明确规定用 HMAC-SHA1,我们照做就行,不需要也不应该去“优化”它。

3.3 时钟漂移:TOTP 最现实的敌人

TOTP 假设客户端和服务器的时钟是同步的。但现实中,手机时钟可能因为各种原因偏移——用户手动改过时间、时区设置错误、设备长时间没联网导致 NTP 同步失败等等。

如果客户端时钟比服务器快了一个窗口,那么客户端算出来的是“下一个 30 秒”的码,而服务器还在验证“当前 30 秒”的码,结果就是验证失败。用户会看到“验证码错误”,但明明 App 里显示的数字是对的。

解决这个问题的标准做法是窗口容错:服务器在验证时,不只检查当前时间窗口,还检查前后各一个窗口。也就是说,服务器会计算 counter-1、counter、counter+1 三个值,只要用户输入的码匹配其中任何一个,就通过验证。

这个容错窗口的大小是可以配置的。窗口越大,容错能力越强,但安全性越低(因为攻击者的重放窗口也变大了)。实践中,前后各一个窗口(总共 3 个窗口,90 秒)是比较常见的平衡点。有些对安全要求极高的场景会收紧到只允许当前窗口,但那样用户体验会很差,除非客户端有强制的时钟同步机制。

实操心得:如果你的系统用户经常反馈“验证码明明对却提示错误”,八成是时钟漂移问题。先别急着怀疑代码,让用户检查一下手机时间是不是设成了“手动设置”。很多手机默认是自动同步网络时间的,但有些用户为了某些原因会关掉自动同步,这就埋下了隐患。

3.4 密钥长度与算法选择

RFC 4226 建议 HMAC-SHA1 的密钥长度至少是 128 位(16 字节),推荐 160 位(20 字节)。实践中,大多数服务生成的密钥是 160 位的,Base32 编码后是 32 个字符。

关于哈希算法,虽然标准默认是 SHA1,但现在很多服务开始支持 SHA256 和 SHA512。SHA1 在密码学上已经不被认为是强哈希了(存在碰撞攻击),但在 HMAC 的构造下,SHA1 的抗碰撞性并不是安全性的关键——HMAC-SHA1 目前仍然被认为是安全的。不过,如果你是新开发系统,选择 SHA256 作为默认算法是更稳妥的做法,身份验证器 App 普遍都支持。

算法哈希输出长度截断后长度兼容性
SHA120 字节4 字节最好,所有 App 都支持
SHA25632 字节4 字节较好,主流 App 支持
SHA51264 字节4 字节一般,部分 App 支持

选择算法的原则很简单:看你的用户用什么 App。如果面向大众用户,SHA1 兼容性最好;如果是内部系统,可以统一要求用某个支持 SHA256 的 App。

4. 实操过程:从零实现一套 TOTP 验证

4.1 服务端生成密钥与绑定二维码

假设我们用 Python 来实现服务端逻辑。第一步是生成一个随机密钥,并构造 otpauth URI。

import base64 import os import urllib.parse def generate_secret(length=20): """生成指定字节数的随机密钥,返回 Base32 编码字符串""" random_bytes = os.urandom(length) secret = base64.b32encode(random_bytes).decode('utf-8') # 去掉末尾的等号,很多验证器不接受带等号的密钥 return secret.rstrip('=') def build_otpauth_uri(secret, account, issuer): """构造 otpauth URI""" label = urllib.parse.quote(f"{issuer}:{account}") params = { 'secret': secret, 'issuer': issuer, 'algorithm': 'SHA1', 'digits': '6', 'period': '30' } query = urllib.parse.urlencode(params) return f"otpauth://totp/{label}?{query}"

生成出来的 URI 可以直接丢给二维码生成库(比如 qrcode),渲染成图片给用户扫。用户扫码后,身份验证器 App 就保存了密钥和参数,开始生成动态码。

注意事项:密钥生成一定要用密码学安全的随机源(os.urandom 或 secrets 模块),绝对不能用 random 模块。random 模块是伪随机,种子可预测,用它生成密钥等于把安全根基挖空了。

4.2 服务端验证动态码的完整实现

验证逻辑是核心,我把它写成一个独立函数,包含窗口容错:

import hmac import hashlib import struct import time def hotp(secret, counter, digits=6, algorithm='sha1'): """HOTP 核心算法""" # 把计数器转成 8 字节大端序 counter_bytes = struct.pack('>Q', counter) # 解码 Base32 密钥 key = base64.b32decode(secret + '=' * ((8 - len(secret) % 8) % 8)) # HMAC 运算 if algorithm == 'sha1': digest = hmac.new(key, counter_bytes, hashlib.sha1).digest() elif algorithm == 'sha256': digest = hmac.new(key, counter_bytes, hashlib.sha256).digest() else: digest = hmac.new(key, counter_bytes, hashlib.sha512).digest() # 动态截断 offset = digest[-1] & 0x0F binary = struct.unpack('>I', digest[offset:offset+4])[0] & 0x7FFFFFFF # 取模 return str(binary % (10 ** digits)).zfill(digits) def verify_totp(secret, user_code, window=1, digits=6, period=30, algorithm='sha1'): """验证 TOTP,允许前后 window 个时间窗口的容错""" current_counter = int(time.time()) // period for i in range(-window, window + 1): counter = current_counter + i expected = hotp(secret, counter, digits, algorithm) # 使用恒定时间比较,防止时序攻击 if hmac.compare_digest(expected, user_code): return True return False

这段代码有几个细节值得展开说:

Base32 解码的补位处理:Base32 编码要求长度是 8 的倍数,不足的用等号补齐。但很多验证器生成的密钥不带等号,所以解码前要手动补上。上面那行'=' * ((8 - len(secret) % 8) % 8)就是干这个的,逻辑是:如果长度已经是 8 的倍数,补 0 个;否则补到下一个 8 的倍数。

恒定时间比较:验证码比较不能用普通的==,因为==在发现第一个不匹配的字符时就会返回,攻击者可以通过测量响应时间逐位猜测验证码。hmac.compare_digest会始终比较完所有字符,时间消耗与内容无关,杜绝了时序攻击。

窗口容错的实现:循环从-window到+window,逐个计算并比较。window=1 就是检查前一个、当前、后一个,共 3 个窗口。

4.3 客户端侧:身份验证器 App 在做什么

客户端(身份验证器 App)做的事情和服务端验证逻辑几乎一样,只是方向相反:它用保存的密钥和当前时间算出验证码,显示给用户。用户把这个码输回服务端,服务端用同样的算法算一遍,对比是否一致。

整个流程可以概括为:

  1. 服务端生成密钥,通过二维码传给客户端
  2. 客户端保存密钥,每次打开时用当前时间计算验证码
  3. 用户输入验证码,服务端用相同算法验证
  4. 双方不需要实时通信,密钥永不再传输

这种设计的优雅之处在于极简的信任模型:绑定之后,双方各自独立计算,没有任何网络依赖。服务器不需要维护会话状态,不需要发送短信,不需要第三方服务。成本几乎为零,可靠性却很高。

4.4 绑定流程的完整时序

把整个绑定和验证流程串起来,大概是这样的:

阶段服务端动作客户端动作数据流向
绑定发起生成密钥,构造 otpauth URI无服务端 → 客户端(二维码)
绑定确认要求用户输入一次动态码验证扫码保存密钥,生成动态码客户端 → 服务端(用户输入)
日常登录验证用户输入的动态码打开 App 查看动态码客户端 → 服务端(用户输入)

绑定确认这一步很重要:它确保用户真的成功扫描并保存了密钥。如果用户扫码失败或者 App 有问题,在绑定阶段就能发现,而不是等到下次登录时才发现进不去。很多系统会要求用户在绑定后立即输入一次动态码,验证通过才算绑定成功。

实操心得:绑定阶段一定要给用户备用恢复码。万一手机丢了、App 卸载了、换设备了,没有恢复码用户就彻底锁在门外了。恢复码通常是一组一次性使用的长随机字符串,用户保存到安全的地方,每个只能用一次。这是 TOTP 体系里最容易被忽略但最关键的兜底机制。

5. 常见问题与排查技巧实录

5.1 验证码总是验证失败,怎么排查

这是最高频的问题。排查思路应该从外到内,逐层排除:

第一层:检查时间同步

让用户确认手机时间是否自动同步。iOS 在“设置 → 通用 → 日期与时间”里,Android 在“设置 → 系统 → 日期与时间”里。如果“自动设置”是关闭的,手动打开。这一条能解决大部分“验证码错误”的问题。

第二层:检查密钥是否正确

如果用户是手动输入密钥绑定的,很可能输错了。Base32 字符集里没有数字 0、1、8、9,如果用户说密钥里有这些字符,那肯定是看错了。另外注意大小写——虽然 Base32 理论上大小写不敏感,但有些 App 实现会区分,最好统一用大写。

第三层:检查算法和参数是否匹配

服务端用 SHA256,客户端默认 SHA1,那算出来的码永远对不上。检查 otpauth URI 里的 algorithm、digits、period 参数是否和服务端配置一致。

第四层:检查窗口容错是否开启

如果服务端没有实现窗口容错,而客户端时钟恰好偏了几秒,就会间歇性失败。开启前后各一个窗口的容错,能显著改善。

第五层:检查代码实现

如果以上都没问题,那就是代码 bug 了。重点检查:计数器计算是否用了整数除法、字节序是否是大端、动态截断的偏移量计算是否正确、取模的位数是否匹配。

5.2 常见问题速查表

现象可能原因排查方法解决方案
验证码始终错误时钟不同步对比手机和服务器时间开启自动时间同步
验证码间歇性错误窗口容错未开启检查服务端 window 参数设置 window=1
手动输入密钥后无法绑定密钥含非法字符检查密钥字符集确认只用 A-Z 和 2-7
换手机后无法登录密钥丢失检查是否有恢复码使用恢复码或联系管理员重置
验证码位数不对digits 参数不匹配检查 otpauth URI统一为 6 位
绑定后立即失效绑定确认未完成检查绑定流程要求绑定后立即验证一次

5.3 几个容易踩的坑

坑一:Base32 解码时的补位问题

不同语言和库对 Base32 解码的要求不一样。Python 的base64.b32decode要求长度是 8 的倍数,不补位会直接抛异常。而有些 JavaScript 库则能自动处理。跨语言实现时,这个差异会导致“服务端能算,客户端算不出”的诡异现象。统一在解码前补位是最稳妥的做法。

坑二:时间戳的精度问题

有些语言的时间函数返回的是毫秒,有些是秒。如果用毫秒时间戳去除以 30,得到的计数器会大 1000 倍,算出来的码完全不对。实现时一定要确认时间戳单位,统一用秒。

坑三:HMAC 密钥的编码

密钥在传输和存储时是 Base32 字符串,但在做 HMAC 运算前必须解码成原始字节。如果直接把 Base32 字符串当密钥用,算出来的结果和标准实现不一致。这个错误很隐蔽,因为代码不会报错,只是结果不对。

坑四:动态截断的符号位

动态截断时,取出的 4 个字节要做& 0x7FFFFFFF去掉最高位。如果忘了这一步,当最高位是 1 时,结果会变成负数,取模后得到负的验证码,显示出来就是错的。这个 bug 出现的概率大约是 50%,而且只在特定时间窗口出现,非常难排查。

坑五:验证码的前导零

6 位验证码如果算出来是 12345,必须补零成 012345。如果服务端和客户端对前导零的处理不一致,就会出现“App 显示 012345,用户输入 12345,服务端算出来是 12345,但和用户输入比较时因为字符串长度不同而失败”的情况。统一用zfill(6)补零,比较时也按字符串比较。

5.4 安全性加固的几个建议

TOTP 本身是安全的,但实现细节上的疏忽会削弱它的防护能力。几个值得注意的点:

限制尝试次数:TOTP 验证码只有 6 位,理论上 100 万种可能。如果没有尝试次数限制,攻击者可以在一个 30 秒窗口内暴力尝试。实践中应该限制每个账号在时间窗口内的尝试次数,比如 5 次,超过就锁定一段时间。

防止重放:同一个验证码在同一个时间窗口内可能被多次使用。如果攻击者截获了一个码,在它过期前重放,就能通过验证。解决方案是记录已使用的验证码,同一个码只能用一次。不过这会增加状态维护成本,很多系统选择接受这个风险,因为窗口只有 30 秒。

绑定时的身份验证:绑定 TOTP 是一个敏感操作,必须要求用户已经通过其他方式登录(比如密码),防止攻击者在用户不知情的情况下绑定自己的设备。

恢复码的安全存储:恢复码应该像密码一样哈希存储,而不是明文。使用时验证哈希值,用后立即失效。

6. 从原理到落地:我的几点实操体会

TOTP 这套机制我前前后后在几个项目里实现过,也帮别人排查过不少问题。最大的感受是:它的简单是表象,细节才是魔鬼。算法本身十几行代码就能写完,但要让它在生产环境里稳定运行,需要考虑的东西远超算法本身——时钟同步、窗口容错、恢复机制、尝试限制、重放防护,每一个环节出问题都会导致用户被锁在门外。

另一个体会是,兼容性比先进性更重要。虽然 SHA256 在密码学上比 SHA1 更现代,但如果你的用户群体用的 App 五花八门,坚持用 SHA1 反而是更务实的选择。标准之所以是标准,就是因为它被广泛支持。在 TOTP 这个场景下,SHA1 配合 HMAC 的构造,安全性对于绝大多数应用场景是足够的。

最后分享一个排查技巧:当你遇到“验证码对不上”的问题时,最快的定位方法是在服务端打印出当前时间窗口的计数器值,以及用这个计数器算出来的期望验证码,然后和客户端 App 里显示的对比。如果计数器一致但验证码不同,问题在算法实现;如果计数器不同,问题在时间同步。这一招能帮你在一分钟内把问题范围缩小一半。

恢复码这个东西,真的是用的时候才知道重要。我见过太多用户因为换手机、卸载 App、清除数据而丢失密钥,最后只能走人工申诉流程。如果你在实现 TOTP,请务必把恢复码功能做进去,并且在绑定阶段就用足够醒目的方式提醒用户保存。这不是可选项,是必选项。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进