两步验证(2FA)已成为账号安全的标准配置。验证器 App 里那个每 30 秒变一次的六位数字,背后是 TOTP——基于时间的一次性密码。这篇文章讲清 TOTP 的内部原理、如何在应用里实现 2FA,以及如何在不打开验证器 App 的情况下生成测试验证码。
TOTP 是什么
TOTP(Time-Based One-Time Password,基于时间的一次性密码)由 RFC 6238 定义。它生成一个短数字码(通常 6 位),具有以下特性:
- 每 30 秒更新一次
- 每个码只能使用一次
- 需要服务器和验证器共享一个密钥
算法是确定性的——相同的密钥和相同的时间窗口,必然产生相同的验证码。“一次性”意味着每个码只在 30 秒窗口内有效,防止重放攻击。
TOTP 的计算过程
TOTP 基于 HOTP(基于 HMAC 的一次性密码,RFC 4226)构建。核心算法:
T = floor(当前 Unix 时间戳 / 30) # 时间计数器
TOTP = HOTP(secret, T)
HOTP = Truncate(HMAC-SHA1(secret, T))
逐步拆解:
-
时间计数器: 将当前 Unix 时间戳除以时间步长(默认 30 秒)并取整。
floor(1712500000 / 30) = 57083333 -
HMAC: 以密钥和时间计数器为输入,计算 HMAC-SHA1,得到 20 字节的哈希值。
-
动态截断: 取哈希最后一字节,其低 4 位决定偏移量。从该偏移量取 4 字节,屏蔽最高位,得到一个 31 位整数。
-
取模: 计算
整数 mod 10^6(6 位码),得到最终 OTP。
服务器执行相同的计算,如果结果匹配则接受(通常还会接受前后各一个时间步长的码,用于处理时钟偏差)。
共享密钥
密钥是一个 base32 编码的随机值,通常 160 位(20 字节)。扫描验证器 App 里的二维码时,二维码包含如下 URI:
otpauth://totp/Service:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Service&algorithm=SHA1&digits=6&period=30
secret 参数就是 base32 编码的共享密钥。你的应用在用户开启 2FA 时生成它一次,加密存储到数据库。用户将其存入验证器 App(Google Authenticator、Authy、1Password 等)。
密钥必须保密。 任何人拿到密钥都能为该账号生成有效验证码。
在线生成测试 TOTP 验证码
输入 base32 密钥,立即得到当前有效的 TOTP 验证码,适合:
- 开发阶段测试 2FA 实现
- 验证密钥配置是否正确
- 排查时间同步问题
所有计算在浏览器中完成,数据不上传服务器。
在应用中实现 TOTP
开启 2FA 的流程
- 生成一个随机 20 字节密钥
- 编码为 base32
- 构建包含服务名和用户标识的
otpauth://URI - 显示为二维码让用户扫描
- 要求用户输入当前验证码以确认开启成功
- 将密钥加密存储到数据库
Node.js 实现
import { authenticator } from 'otplib';
// 开启 2FA
const secret = authenticator.generateSecret(); // 例如 "JBSWY3DPEHPK3PXP"
const otpauthUrl = authenticator.keyuri('[email protected]', 'MyApp', secret);
// 生成二维码(使用 qrcode 库)
import qrcode from 'qrcode';
const qrDataUrl = await qrcode.toDataURL(otpauthUrl);
// 验证(登录时)
const isValid = authenticator.verify({ token: userCode, secret });
Python 实现
import pyotp
import qrcode
# 开启 2FA
secret = pyotp.random_base32()
totp = pyotp.TOTP(secret)
# 生成二维码用的 OTPAuth URI
uri = totp.provisioning_uri(name="[email protected]", issuer_name="MyApp")
img = qrcode.make(uri)
img.save("qr.png")
# 验证
is_valid = totp.verify(user_code) # 接受当前 ±1 个时间步长
Go 实现
import "github.com/pquerna/otp/totp"
// 开启 2FA
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "MyApp",
AccountName: "[email protected]",
})
secret := key.Secret()
// 将 key.URL() 显示为二维码
// 验证
valid := totp.Validate(userCode, secret)
TOTP 参数配置
RFC 6238 允许修改默认参数,大多数验证器 App 支持标准变体:
| 参数 | 默认值 | 常见替代值 |
|---|---|---|
| 算法 | SHA-1 | SHA-256、SHA-512 |
| 位数 | 6 | 8 |
| 时间步长 | 30 秒 | 60 秒 |
除非有特殊需求,坚持使用默认值(SHA-1,6 位,30 秒)。 非标准配置可能导致部分验证器 App 不兼容。SHA-1 在这里不构成安全问题——HMAC-SHA1 配合 160 位密钥提供 80 位安全强度,对于 OTP 生成已经足够(在 30 秒内暴力破解 6 位数字本身就不可行)。
时钟同步与漂移
TOTP 要求服务器和客户端时钟基本同步。大多数实现会接受 T-1、T、T+1 三个时间步长的验证码(即当前时间前后各 ±30 秒),以处理时钟漂移。
如果用户设备时钟偏差较大(几分钟以上),TOTP 会失败。这在现代智能手机上很少见,但在嵌入式设备或自定义实现中值得注意。
服务器时钟应使用 NTP 同步。Linux 上:
timedatectl status # 查看当前 NTP 同步状态
timedatectl set-ntp true # 启用 NTP 同步
TOTP 与其他 2FA 方案对比
| 方案 | 安全性 | 防钓鱼 | 无外部依赖 |
|---|---|---|---|
| TOTP | 高 | 否 | 是 |
| 短信验证码 | 低 | 否 | 否(依赖运营商) |
| Push 推送 | 中 | 否 | 否(依赖 App 服务器) |
| FIDO2 / 通行密钥 | 非常高 | 是 | 是 |
| 硬件 Token(TOTP) | 高 | 否 | 是 |
TOTP 存在实时钓鱼风险(攻击者诱导用户在伪造页面输入验证码,并立即转发)。对于最高安全需求,FIDO2/WebAuthn 硬件密钥才是防钓鱼的方案。但 TOTP 相比纯密码已是质的提升,是当前大多数应用 2FA 的实用标准。
恢复码
开启 TOTP 的同时,必须提供恢复码。用户丢失验证器设备时,需要靠恢复码找回账号:
- 开启时生成 8–10 个一次性恢复码
- 以哈希形式存储(bcrypt 或 Argon2)
- 只展示一次,提示用户离线保存
- 每个码使用后立即失效
import secrets
def generate_recovery_codes(count=10):
return [secrets.token_hex(10) for _ in range(count)]