☰
从零实现TOTP:打造兼容Google Authenticator的动态验证码生成器
2026/10/18 11:59:45 网站建设 项目流程

在写自动化脚本、做多账户统一管理,或者单纯想弄明白手机里那个每30秒跳一次的数字到底怎么算出来的,自己照着 TOTP 协议实现一个兼容 Google Authenticator 的认证工具、生成6位验证码,是非常值得动手的练手项目。标题拆开看就三件事:TOTP 算法、Google Authenticator 兼容、6位动态验证码。这篇文章不扒官方源码,而是从协议原理讲起,带着写一个能直接跑起来的命令行工具,并记录我调试过程中的各种坑。

1. 项目拆解:这标题到底想解决什么问题

1.1 标题的三个关键词,拆开就清楚了

先聊标题里的“TOTP”。TOTP 全称 Time-based One-Time Password,翻译过来就是“基于时间的一次性密码”。它的核心逻辑是:客户端和服务器事先约定一个共享密钥,再根据当前时间算出一个数字,这个数字每隔一段时间就变化。你手机上的 Google Authenticator 用的就是这个 RFC 6238 标准的算法,每次生成6位数字,30秒一换。

再看“Google Authenticator 认证工具”。很多人以为 Google Authenticator 是个专属 App,其实它的兼容性非常广,任何实现了 TOTP 算法的工具都能生成相同的6位验证码。我用自己写的脚本生成的同时,手机上的官方 App 也在走同一个算法,两边应该显示一样的数字。所以标题真正要做的事情是“兼容它的算法”,而不是仿冒它的界面。

最后是“获取6位验证码”。这6位数字不是凭空来的,而是由复杂运算得出来的。它看起来随机,实际上是确定性的:只要种子密钥、当前时间、时间步长和算法参数一致,任何人都能算出同一个结果。

1.2 为什么这工具值得自己写一遍

市面上有现成的 Python 库,比如 pyotp,一行就能生成验证码。但直接用库,你永远不知道背后发生了什么。我自己写一遍,至少有三个收获:

第一,彻底搞懂两因素认证的原理。当你理解了共享密钥 + 时间窗口 + HMAC-SHA1 之后,再看到任何扫码登录界面,脑子里就会有完整的画面。

第二,能在自动化环境里跑验证码。比如你想做一个小脚本,定期登录某个需要用双因素认证的服务,但不想每次手动掏手机。这时候自己实现的工具可以集成到脚本里,配上参数就能输出验证码。

第三,排查问题时不用看天书。遇到验证码对不上、时间不同步、Base32 解码报错,如果你没读过算法细节,根本不知道从哪下手。自己写过一遍,问题定位快得多。

1.3 可行性评估和时间成本

这个项目不需要复杂的依赖,我用最基础的 Python 标准库就能完成,HMAC、hashlib、struct、base64、time 这些模块都是自带的。如果你只是想验证算法,十几分钟就能写个原型;想做到支持扫码、多账户、容错窗口这些功能,半天时间也足够了。

我当时从零到跑通第一版,大概花了二十分钟。期间踩了 Base32 padding、时间戳取整、动态截断偏移量这几个坑,后面我会详细展开。

2. TOTP 核心原理:6位数字是怎么算出来的

2.1 共享密钥与时间计数器

TOTP 的输入有三个:共享密钥、时间计数器、参数配置。共享密钥就是大家在扫码时看到的那个一串字符,它可能是JBSWY3DPEHPK3PXP这种样子。这个密钥通常是 Base32 编码的,因为 Base32 只用了大写字母 A-Z 和数字 2-7,方便手工输入,也能避免把 0 和 O、1 和 I 搞混。

时间计数器则是一个不断递增的整数。TOTP 不是直接用当前时间的秒数,而是把时间戳除以时间步长后再向下取整。标准步长是30秒,所以计数器就是当前UNIX时间戳 // 30。比如说当前时间戳是1750000000,除以30得到58333333,这个整数就是计数器值。30秒之内不管你怎么算,计数器都一样,验证码不变;一过30秒整点,计数器加1,验证码就变了。

这里有个关键点:服务器和客户端之间即使时间有差异,只要差异在容差范围内,验证码还能对上。所以“基于时间”既是优点也是风险点,后面排查部分会讲到。

2.2 HMAC-SHA1 和动态截断

有了密钥和计数器,接下来要用 HMAC-SHA1 计算一个20字节的摘要。HMAC 是“密钥化哈希运算”,简单理解就是带着密钥去算哈希。TOTP 标准的默认哈希算法是 SHA1,这也是 Google Authenticator 默认使用的。

光有20字节的摘要还不够,我们只需要6位数字,所以要从摘要里截取一段。截取方式叫“动态截断”,步骤如下:

  1. 取摘要结果的最后一个字节,只看它的低4位,得到偏移量 offset,范围是 0 到 15。
  2. 从摘要的第 offset 字节开始,连续取4个字节(32位)。
  3. 把这32位整数最高位去掉,也就是跟0x7fffffff做与运算,避免得到负数。
  4. 然后用这个整数对10^6取模,得到0到999999之间的数。
  5. 不足6位左侧补0,就成了我们看到的6位验证码。

为什么用动态截断?因为直接取摘要的一部分不够随机。最后一位字节的低4位作为偏移,相当于在摘要里挑一个随机位置,不同时间算出来的验证码分布更均匀,也更容易满足安全要求。

2.3 手把手推演一个6位验证码

我用一个具体例子把流程走一遍。假设种子密钥是JBSWY3DPEHPK3PXP,这个密钥在官方示例里经常出现,它对应的字节序列其实是 ASCII 字符 “Hello!” 的 Base32 编码。

第一步,把密钥 Base32 解码成字节串。第二步,假设当前计数器值为2000000000,把计数器转成8字节的大端整数,也就是用struct.pack('>Q', counter)得到二进制数据。第三步,用密钥的原始字节和这个二进制数据计算 HMAC-SHA1,得到20字节摘要。第四步,取最后一个字节0xa7的低4位0x07,也就是偏移量7。第五步,从摘要第7字节开始取4字节0x9d 0x2f 0x3a 0x1b,组成0x9d2f3a1b,去掉最高位后对1000000取模,得到983234之类的结果。最后补零。

每一步都是确定性的,所以只要算法一致,我的脚本算出来的数字和 Google Authenticator 显示的一定一样。如果不一样,说明某个环节有偏差,通常是时间窗口或者密钥。

3. 动手实现:一个可用的 TOTP 命令行工具

3.1 环境准备与基础约定

我选择 Python 3 来实现,因为标准库丰富,跨平台也方便。不需要额外安装任何第三方库。写之前约定参数:密钥使用 Base32 编码的字符串,时间步长默认30秒,验证码长度默认6位,哈希算法默认 SHA1。这些都是在 TOTP 标准里最常见的配置,也决定了我们能和主流认证 App 互通。

3.2 Base32 解码函数

密钥是 Base32 字符串,但 Python 标准库的base64.b32decode要求字符串长度必须是8的倍数,并且要有正确的 padding。实际使用中,很多二维码里的密钥并没有 padding,比如JBSWY3DPEHPK3PXP长度是16,已经满足8的倍数,但有些密钥长度不是8的倍数,就需要手动补上等号。

普通用户输入的密钥还可能带空格、小写字母,或者混入分隔符,比如jbsWY3DP EHPK3PXP。所以解码前要先清理字符串:去掉空格和连接符,转成大写,然后补 padding。处理后用base64.b32decode解码,得到字节串。

import base64 import re def base32_decode(secret): # 清理:去掉空白字符和连字符 cleaned = re.sub(r'[\s-]', '', secret).upper() # 补齐 padding,密钥长度必须是8的倍数 padding = '=' * ((8 - len(cleaned) % 8) % 8) return base64.b32decode(cleaned + padding)

这段代码看似简单,却解决了我踩过的第一个大坑。最开始我直接调用b32decode而不补 padding,当密钥长度不是8的倍数时会抛binascii.Error。补 padding 的公式是(8 - len % 8) % 8,当长度正好是8的倍数时,结果为0,不会多加等号。

3.3 核心 TOTP 计算函数

核心函数就是根据当前时间戳生成验证码。逻辑如下:

import hmac import hashlib import struct import time def get_totp_token(secret, timestamp=None, step=30, digits=6): if timestamp is None: timestamp = int(time.time()) counter = timestamp // step key = base32_decode(secret) msg = struct.pack('>Q', counter) digest = hmac.new(key, msg, hashlib.sha1).digest() offset = digest[-1] & 0x0F binary = struct.unpack('>I', digest[offset:offset + 4])[0] & 0x7FFFFFFF token = binary % (10 ** digits) return str(token).zfill(digits)

拆开解释一下:

  • timestamp // step是整数除法,直接算出当前时间落在哪个30秒窗口。这里建议用int(time.time())而不是直接用浮点数,避免除法的精度问题。
  • struct.pack('>Q', counter)把计数器编码为8字节大端整数,Q表示无符号64位整数。TOTP 标准里要求计数器是64位的大端表示。
  • hmac.new(key, msg, hashlib.sha1)用来计算 HMAC-SHA1 摘要。digest()返回20字节原始哈希值。
  • 动态截断部分,digest[-1] & 0x0F取最后一个字节的低4位,得到0到15的偏移量。然后从摘要的对应位置切4字节,struct.unpack('>I', ...)解析为32位无符号整数。
  • 和0x7FFFFFFF做与运算,去掉符号位,这一步是 RFC 4226 规定的,防止最高位导致结果变成负数。
  • 最后取模,10 ** digits得到1000000,取余就是0到999999。用zfill(digits)补足前导零,确保输出一定是6位字符串。

3.4 otpauth:// URI 与二维码支持

要生成一个能被 Google Authenticator 扫码添加的账户,密钥必须以 otpauth URI 格式呈现。标准的 URI 格式如下:

otpauth://totp/Issuer:Account?secret=JBSWY3DPEHPK3PXP&issuer=Issuer&algorithm=SHA1&digits=6&period=30

Issuer:Account用来给用户标识账户,secret是 Base32 密钥,issuer参数是服务方名称。有些旧版 App 只认secret,但加上这些参数更能保证兼容。

如果你要生成二维码,可以引入第三方库qrcode,或者调用命令行工具生成。我这里避免引入重量级依赖,只演示 URI 的构造函数,二维码部分知道原理即可。真正扫码时,可以用任何二维码生成器把这个 URI 转成图片。

from urllib.parse import quote def build_otpauth_uri(secret, account, issuer='MyTool', algorithm='SHA1', digits=6, period=30): label = f'{issuer}:{account}' params = ( f'secret={secret}' f'&issuer={quote(issuer)}' f'&algorithm={algorithm}' f'&digits={digits}' f'&period={period}' ) return f'otpauth://totp/{quote(label)}?{params}'

这里quote做 URL 编码很重要。账号里如果有空格、斜杠、中文字符,不编码会导致扫码失败。我见过有人把账号写成了John Doe,URI 里直接带空格,结果 App 识别不出来。

3.5 命令行封装

把前面三个函数整合成一个可直接运行的脚本,再从命令行接收参数。我支持两种用法:

  • 直接传入密钥,输出当前的6位验证码。
  • 传一个包含多个账户密钥的文件,批量输出所有验证码。

第一种用法代码:

if __name__ == '__main__': import argparse parser = argparse.ArgumentParser(description='TOTP Token Generator') parser.add_argument('-s', '--secret', required=True, help='Base32 encoded secret key') parser.add_argument('-t', '--timestamp', type=int, help='unix timestamp (default now)') parser.add_argument('-d', '--digits', type=int, default=6, help='token digits (default 6)') parser.add_argument('--step', type=int, default=30, help='time step seconds (default 30)') args = parser.parse_args() print(get_totp_token(args.secret, args.timestamp, args.step, args.digits))

这样在终端里执行:

python totp.py --secret JBSWY3DPEHPK3PXP

输出一行6位数字,比如483912。你可以和手机上的 Google Authenticator 对一下,正常情况下数字完全一致。

批量场景下,我会用一个配置文件,每行格式为账户名 密钥,脚本遍历所有行输出验证码。这在管理多个测试账户时非常方便。

4. 实测与兼容性验证

4.1 用官方 App 扫码对比

第一个测试方法是用上面生成的 otpauth URI 转成二维码,再拿 Google Authenticator 扫码添加。添加成功之后,App 上每30秒跳一次数字。我同时在终端运行脚本,把两个数字并排对比。

我第一次测试就发现脚本输出的数字和 App 不一样,差了很大一段。后来意识到是我的脚本里time.time()返回的是浮点数,在转换成整数时和 App 内部处理不一致。准确做法应该是先取整再除步长,我改成int(time.time() // step),注意这里的time.time()本身就是浮点,但先地板除再取整效果不同。其实更稳妥的是int(time.time()) // step。改正之后,两个数字终于能对上了。

这里值得多说一句:兼容性验证不要只在某一个时刻看,最好观察两个连续时间窗口。因为如果时间边界处理有偏差,可能在某个30秒的中间显示正确,但在整点切换时差出6位。我习惯在 App 倒计时剩5秒时连续跑三次脚本,确保每次输出的数字都和 App 同步。

4.2 时钟同步与容差设置

TOTP 对时间差特别敏感。我的电脑如果时钟和手机差了十几秒,验证码就可能对不上。解决办法有两个:

第一,让服务器端或验证方允许一定的时间偏移。很多服务端会允许前后各一个时间步长的容错窗口,也就是30秒的差值。我的脚本也加了-t参数,可以传入一个向后偏移的时间戳,用于模拟时钟偏差场景。

第二,本机启动 NTP 时间同步。在 Linux 上可以用systemctl enable --now systemd-timesyncd,Windows 上开启自动同步。实际工作中我发现,很多验证码错误并不是算法问题,而是测试者的虚拟机时钟狂奔了,刷新一下时间就好。

4.3 读取已有设备中的种子并迁移

另一个常见的需求是,你已经有一个 Google Authenticator 账户,但想把它迁移到自己的工具里。官方 App 通常提供“导出账户”功能,导出时会让你输入一个 PIN,然后生成一组二维码。这些二维码内容就是 otpauth URI,里面包含了种子密钥。

拿到 URI 后,提取其中的secret参数,填进我的脚本就能继续生成验证码。这里有个安全提醒:导出过程和保存密钥的过程一定要在可信环境里操作,密钥一旦泄露,任何拿到它的人都能冒充你完成两因素认证。

5. 常见问题排查速查表

5.1 验证码总是差几位,问题出在哪

表现是:同一个时间点,脚本输出的数字和 App 不一样,或者验证总是失败。可能的嫌疑排序:

  • 时间不同步,先执行date看一下系统时间,再和手机时间对比。
  • 密钥没清理干净,比如复制的时候带了空格或者换行。
  • Base32 解码时 padding 算错了。
  • 计数器取整方式不对,确认是int(timestamp) // step而不是timestamp // step时把浮点数保留在小数位。
  • 动态截断偏移量写错,比如取了第四个字节而不是低四位。

我踩过最隐蔽的坑是在 Windows 上使用time.time()得到的浮点数精度是足够的,但 Python 在struct.pack('>Q', counter)里如果传入的是浮点数,即使值看起来是整数,某些版本里也会报错。所以一定要先int()转换。

5.2 Base32 解码报错

binascii.Error: Non-base32 digit found是比较常见的错误。这通常是因为密钥里有数字1、0或者小写l,它们不是合法的 Base32 字符。Base32 字符集只包含A-Z和2-7,你应该让用户在上层就先校验。

还有一种是Incorrect padding错误。我的基类解码函数补了 padding,但如果你直接复制网上的算法,可能忽略了对长度不是8倍数的处理。记住公式:padding = '=' * ((8 - len(cleaned) % 8) % 8)。

5.3 多个账户怎么管理

当账户多起来,每次手动输入密钥就很痛苦。我的做法是维护一个文本文件,每行存账户名:密钥。批量输出时,读取文件,对每一行调用get_totp_token,然后格式化成表格打印。

要注意的是,不要在密码管理器之外明文存储密钥文件。至少要对文件设置权限,避免其他用户读走。某些场景下也可以把密钥放到环境变量里,脚本启动时读取,这样不落盘。

5.4 安全注意事项,这些底线不能破

第一,种子密钥等于两因素认证的“第二因素”本身。谁拿到密钥,谁就能生成验证码,所以密钥必须像密码一样严格保管。不要在 GitHub 示例代码里放真实密钥,不要写进日志,不要随意截图。

第二,验证码本身也很敏感。它只有30秒有效期,但在这30秒内就相当于你的登录凭证。脚本输出验证码后,如果终端日志被记录,验证码会随之泄漏。我一般在脚本里不打印完整密钥,只打印验证码。

第三,传输和存储要加密。如果你做了一个 Web 服务来调用这个工具,务必用 HTTPS,防止中间人截获 otpauth URI 或验证码。

第四,时间步长不要轻易改短。改短到10秒意味着验证码刷新太快,容易出现时间窗口错过,也会降低安全性。除非有特殊业务需求,30秒是标准选择。

6. 最后分享一点我的实操习惯

项目本身并不复杂,但我在完成之后养成了一个习惯:不再手动复制密钥到在线生成器,而是把种子密钥放在本地的加密凭证库里,需要时用脚本生成验证码,然后把输出复制到目标网站。这样既省去了掏手机的步骤,又确保了密钥不经过第三方服务。

如果你后续想扩展,可以做两个方向。一是支持 Steam 风格的字母验证码,原来 Steam 的令牌不是纯数字,但底层原理非常相似;二是把脚本封装成 Web API 或者手机端的小程序,方便多设备使用。不过无论怎么扩展,核心还是 TOTP 的那五步:解码密钥、算计数器、算 HMAC、动态截断、取模补零。把这几步吃透,以后遇到任何两因素认证工具,你都能一眼看穿它的本质。

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

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

立即咨询