一、背景
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 是基于共享密钥和时间窗口生成动态验证码,不依赖短信。
对比一下:
| 项目 | WebOTP | TOTP |
|---|---|---|
| 来源 | 短信 | 认证器本地计算 |
| 是否依赖运营商 | 依赖 | 不依赖 |
| 是否需要共享密钥 | 不需要前端保存 | 服务端和认证器持有 |
| 典型用途 | 手机号登录、短信验证 | 两步验证、敏感操作确认 |
| 前端角色 | 自动读取短信并填充 | 展示二维码、收集验证码 |
WebOTP 可以优化短信 OTP 的输入体验,但不能替代 TOTP。
十、安全边界
TOTP 能提高账户安全性,但它不是万能的。
需要特别注意几类风险:
- 钓鱼页面可以诱导用户输入当前 TOTP
- XSS 可以窃取用户输入的验证码
- 设备丢失会导致认证器不可用
- 服务端密钥泄露会让 TOTP 失去意义
- 缺少失败次数限制会放大暴力尝试风险
因此 TOTP 通常还需要配合:
- HTTPS
- CSP 和 XSS 防护
- 登录风险识别
- 恢复码机制
- 设备管理
- 操作审计
总结
TOTP 的核心并不复杂:服务端和认证器共享同一个密钥,再基于当前时间窗口各自计算一次性验证码。
真正需要关注的是工程边界:
- 密钥由服务端生成并安全保存
- 认证器保存密钥并生成验证码
- 前端只负责绑定展示和验证码输入
- 服务端负责验证、容错、限流和防重放
理解这条链路后,再看两步验证、敏感操作二次确认或企业后台登录,就能更清楚地判断哪些逻辑属于前端体验,哪些逻辑必须落在服务端安全边界内。