两步验证(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))

逐步拆解:

  1. 时间计数器: 将当前 Unix 时间戳除以时间步长(默认 30 秒)并取整。floor(1712500000 / 30) = 57083333

  2. HMAC: 以密钥和时间计数器为输入,计算 HMAC-SHA1,得到 20 字节的哈希值。

  3. 动态截断: 取哈希最后一字节,其低 4 位决定偏移量。从该偏移量取 4 字节,屏蔽最高位,得到一个 31 位整数。

  4. 取模: 计算 整数 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 验证码

在线 TOTP 生成器 →

输入 base32 密钥,立即得到当前有效的 TOTP 验证码,适合:

  • 开发阶段测试 2FA 实现
  • 验证密钥配置是否正确
  • 排查时间同步问题

所有计算在浏览器中完成,数据不上传服务器。

在应用中实现 TOTP

开启 2FA 的流程

  1. 生成一个随机 20 字节密钥
  2. 编码为 base32
  3. 构建包含服务名和用户标识的 otpauth:// URI
  4. 显示为二维码让用户扫描
  5. 要求用户输入当前验证码以确认开启成功
  6. 将密钥加密存储到数据库

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-1SHA-256、SHA-512
位数68
时间步长30 秒60 秒

除非有特殊需求,坚持使用默认值(SHA-1,6 位,30 秒)。 非标准配置可能导致部分验证器 App 不兼容。SHA-1 在这里不构成安全问题——HMAC-SHA1 配合 160 位密钥提供 80 位安全强度,对于 OTP 生成已经足够(在 30 秒内暴力破解 6 位数字本身就不可行)。

时钟同步与漂移

TOTP 要求服务器和客户端时钟基本同步。大多数实现会接受 T-1TT+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)]

立即在线生成并测试 TOTP 验证码 →