TOTP 原理和实践

一、背景

TOTP 经常出现在两步验证、敏感操作确认和企业后台登录中。用户在 Google Authenticator、Microsoft Authenticator、1Password、Authy 等工具里看到的 6 位动态验证码,大多数就是 TOTP。

它的核心目标很简单:即使用户的静态密码泄露,攻击者也不能只凭密码完成登录。

从认证因素看,TOTP 通常作为第二因素使用:

用户名 + 密码
  -> 验证用户知道什么
 
TOTP 动态验证码
  -> 验证用户持有什么

这里的“持有什么”,本质上是用户设备上的认证器保存了一份和服务端一致的密钥。

二、从 OTP 到 TOTP

OTP 是 One-Time Password,一次性密码。它不是某一种具体算法,而是一类动态验证码方案。

常见 OTP 可以分成两类:

  • HOTP:基于计数器,每验证一次计数器递增
  • TOTP:基于时间,把当前时间切成固定窗口

HOTP 的标准来自 RFC 4226,TOTP 的标准来自 RFC 6238。TOTP 可以理解为 HOTP 的时间版本:它把 HOTP 里的计数器换成了时间步长。

一个简化关系是:

HOTP = HMAC(secret, counter)
TOTP = HMAC(secret, time_step)

HOTP 需要客户端和服务端维护一致的计数器,适合硬件令牌等场景。TOTP 只依赖时间同步,使用成本更低,因此在互联网产品里更常见。

三、TOTP 的核心原理

TOTP 的输入主要有三个:

  • secret:服务端和认证器共享的密钥
  • time step:时间步长,常见值是 30 秒
  • digits:验证码位数,常见值是 6 位

生成过程可以概括为:

当前 Unix 时间
  -> 除以时间步长并向下取整
  -> 得到 time_step
  -> HMAC(secret, time_step)
  -> 动态截断
  -> 对 10^digits 取模
  -> 得到 6 位验证码

对应的公式可以简化成:

TOTP = Truncate(HMAC-SHA-1(secret, T)) mod 10^digits
T = floor(current_unix_time / period)

其中 period 通常是 30 秒。也就是说,在同一个 30 秒窗口内,只要客户端和服务端持有相同的 secret,并且时间基本同步,就会算出相同的验证码。

四、绑定流程

TOTP 的使用通常分成两步:绑定和验证。

绑定阶段,服务端会为用户生成一个随机密钥,并把它编码成认证器能识别的格式。

典型流程如下:

用户开启两步验证
  -> 服务端生成 secret
  -> 服务端生成 otpauth:// URI
  -> 前端展示二维码
  -> 用户用认证器扫码
  -> 用户输入认证器生成的 6 位验证码
  -> 服务端验证通过
  -> 绑定完成

二维码里通常不是验证码本身,而是一个 otpauth:// URI。它大致长这样:

otpauth://totp/Example:user@example.com?secret=BASE32_SECRET&issuer=Example&period=30&digits=6

几个关键字段:

  • secret:共享密钥,通常使用 Base32 编码
  • issuer:服务名称,认证器里显示用
  • period:时间步长
  • digits:验证码位数

绑定完成后,服务端需要保存用户的 TOTP 密钥。认证器也会保存同一份密钥。后续验证时,两边各自根据当前时间计算验证码。

五、验证流程

验证阶段,前端只负责收集用户输入的验证码,真正的校验应该在服务端完成。

用户输入 6 位验证码
  -> 前端提交给服务端
  -> 服务端读取用户 secret
  -> 服务端按当前时间计算 TOTP
  -> 对比用户输入
  -> 返回验证结果

服务端一般不会只验证当前时间窗口,而是允许一定容错。例如当前窗口前后各放宽一个时间步:

T - 1
T
T + 1

如果时间步长是 30 秒,前后各一个窗口意味着大约允许 90 秒范围内的验证码匹配。这样可以容忍用户设备和服务端之间的小幅时间偏差。

但容错范围不宜过大。范围越大,可接受的验证码越多,暴力猜测成功率也会随之上升。

六、前端应该做什么

在标准的 TOTP 认证流程里,前端不应该长期保存或计算用户的 TOTP 密钥。

前端更合理的职责是:

  • 展示绑定二维码
  • 引导用户保存恢复码
  • 收集用户输入的验证码
  • 做基础格式校验
  • 展示倒计时和错误提示
  • 在敏感操作时触发二次验证

例如输入校验可以保持很轻:

function normalizeTotp(input: string) {
  return input.replace(/\s/g, '')
}
 
function isValidTotpFormat(input: string) {
  return /^\d{6}$/.test(normalizeTotp(input))
}

前端可以提升体验,但不要把前端当成安全边界。验证码是否正确、是否过期、是否已被重放,都应该由服务端判断。

七、服务端实践要点

服务端实现 TOTP 时,重点不是公式本身,而是密钥、状态和风控。

密钥生成与存储

secret 必须由安全随机数生成,不能由用户信息、时间戳或可预测字符串拼出来。

保存时需要注意:

  • 不要明文出现在日志里
  • 不要返回给前端多次展示
  • 数据库中尽量加密存储
  • 绑定完成前可以暂存,绑定成功后再正式启用
  • 用户重置 TOTP 时需要让旧密钥失效

验证与重放

TOTP 在同一个时间窗口内会生成相同验证码。如果攻击者在验证码有效期内截获它,理论上仍有被重放的风险。

因此服务端可以记录用户最近成功使用过的时间窗口,避免同一个窗口内重复使用同一个验证码。

一个简化逻辑是:

校验验证码
  -> 找到匹配的 time_step
  -> 判断该 time_step 是否已经使用过
  -> 未使用则通过并记录
  -> 已使用则拒绝

失败次数限制

6 位数字验证码只有 100 万种可能。单次猜中概率不高,但如果没有限制,攻击者可以持续尝试。

实践中通常需要:

  • 限制单用户失败次数
  • 对连续失败增加冷却时间
  • 对高风险登录触发额外校验
  • 对异常 IP、设备、地区做风控判断

八、一个最小实现示例

生产系统建议使用成熟库实现 TOTP,不建议自己手写密码学细节。下面示例只展示服务端大致流程。

import { authenticator } from 'otplib'
 
// 绑定阶段:生成密钥和二维码内容
function createTotpBinding(userEmail: string) {
  const secret = authenticator.generateSecret()
  const uri = authenticator.keyuri(userEmail, 'Example', secret)
 
  return {
    secret,
    uri
  }
}
 
// 验证阶段:校验用户输入
function verifyTotp(token: string, secret: string) {
  return authenticator.verify({
    token,
    secret
  })
}

实际落地时,不要直接把 secret 暴露给普通业务日志,也不要在绑定完成后继续让前端反复获取这份密钥。

九、WebOTP 和 TOTP 不是一回事

很多前端同学会把 WebOTP 和 TOTP 混在一起,但它们解决的问题不同。

WebOTP API 是浏览器从短信中读取一次性验证码并自动填充输入框,主要提升短信验证码体验。

TOTP 是基于共享密钥和时间窗口生成动态验证码,不依赖短信。

对比一下:

项目WebOTPTOTP
来源短信认证器本地计算
是否依赖运营商依赖不依赖
是否需要共享密钥不需要前端保存服务端和认证器持有
典型用途手机号登录、短信验证两步验证、敏感操作确认
前端角色自动读取短信并填充展示二维码、收集验证码

WebOTP 可以优化短信 OTP 的输入体验,但不能替代 TOTP。

十、安全边界

TOTP 能提高账户安全性,但它不是万能的。

需要特别注意几类风险:

  • 钓鱼页面可以诱导用户输入当前 TOTP
  • XSS 可以窃取用户输入的验证码
  • 设备丢失会导致认证器不可用
  • 服务端密钥泄露会让 TOTP 失去意义
  • 缺少失败次数限制会放大暴力尝试风险

因此 TOTP 通常还需要配合:

  • HTTPS
  • CSP 和 XSS 防护
  • 登录风险识别
  • 恢复码机制
  • 设备管理
  • 操作审计

总结

TOTP 的核心并不复杂:服务端和认证器共享同一个密钥,再基于当前时间窗口各自计算一次性验证码。

真正需要关注的是工程边界:

  • 密钥由服务端生成并安全保存
  • 认证器保存密钥并生成验证码
  • 前端只负责绑定展示和验证码输入
  • 服务端负责验证、容错、限流和防重放

理解这条链路后,再看两步验证、敏感操作二次确认或企业后台登录,就能更清楚地判断哪些逻辑属于前端体验,哪些逻辑必须落在服务端安全边界内。

按下 K 进行搜索