---
title: "2FA 是什么：定义、作用与 TOTP 的实现原理"
date: 2026-11-10
slug: what-is-2fa
canonical: https://kernelranch.com/blog/2026/11/what-is-2fa
markdown_url: https://kernelranch.com/blog/2026/11/what-is-2fa.md
tags: ["2fa", "totp", "security", "authentication", "rfc6238"]
---密码只有一个失效模式：一旦泄露，它和账号就同时失效。两步验证（Two-Factor Authentication，2FA）要解决的就是这件事——让"知道密码"不再等于"能登录"。

## 1. 定义

2FA 要求登录时提供两类不同性质的凭据，类别通常是这三种：

| 类别 | 例子 | 性质 |
|---|---|---|
| 你知道的 | 密码、PIN、安全问题 | 可被记住，也可被窃取 |
| 你拥有的 | 手机、验证器 App、硬件密钥 | 物理持有，复制成本高 |
| 你是什么 | 指纹、面容、虹膜 | 生物特征，难以更换 |

任意两类组合即构成 2FA。关键在于"不同性质"：密码加安全问题不算，因为两者同属"你知道的"，一次钓鱼可以同时骗走。

## 2. 作用：把一次泄露降级为一次失败尝试

现实中账号失守的主因是撞库、钓鱼和第三方数据泄露，这三类都直接落在密码上。启用 2FA 之后，攻击者拿到了密码，仍然缺第二把钥匙。

它带来的变化是具体的：原本一次密码泄露等于账号被接管，现在变成一次会失败的登录尝试，而且你能在日志里看到这次尝试。安全上的价值不在于"更难猜"，而在于"单点失守不再致命"。

## 3. 它挡不住什么

说清楚边界比夸它有用更重要。三种情况 2FA 基本无效：

会话劫持。你登录之后的 Cookie 被偷走，攻击者直接复用会话，根本不需要过验证码。

实时中间人钓鱼。假站点把你的账号密码和验证码实时转发给真站点，验证码在 30 秒内是有效的。

MFA 疲劳。攻击者连续推送登录确认，直到你手滑点了"同意"。

这三种的共同点是：攻击发生在"你已经通过验证之后"或"你主动配合了攻击者"。所以 2FA 是降低单点失守的损失，不是万能盾。

## 4. 原理：一个 HMAC 加一个时间片

最普及的形式是 TOTP（Time-based One-Time Password，RFC 6238）。服务器与你的验证器在绑定阶段共享一个密钥，之后双方各自计算：

```
counter = floor(unix_time / 30)
code    = H(HMAC-SHA1(secret, counter)) mod 10^6
```

`H` 是动态截断（dynamic truncation）：取 HMAC 结果的最后一个字节的低 4 位作为偏移量，从该位置取 4 个字节，去掉最高位，再对 10^6 取模，左侧补零到 6 位。这样得到的数字分布在 000000–999999 之间。

整个过程没有网络交互，所以飞行模式下也能出码。这也是它比短信验证码可靠的地方：不依赖运营商，也不怕 SIM 卡被补办。

## 5. 四个实现细节决定安全性

时间片长度。默认 30 秒。太短会频繁失效，太长会扩大重放窗口。

时钟漂移。服务器通常接受前后各一个时间片，也就是 ±30 秒的容差。客户端实现里要按这个窗口做校验，而不是只比对当前值。

一次性。同一个时间片内的验证码用过一次就该作废，否则截获后可以重放。这一步很多自建系统漏掉了。

密钥分发。密钥只在绑定那一刻通过二维码（`otpauth://` URI）传输，之后永远不离开设备。服务器端存的也只是这个共享密钥，不是验证码。

## 6. 一个可以自己验证的演示

我把这套逻辑做成了一个纯前端的演示站：页面在浏览器里用 WebCrypto 算出验证码，服务端用同一套算法提供 API，没有任何数据库。

为了确认实现不是"看起来能用"，我用 RFC 6238 附录里的测试向量做了核对。密钥取 ASCII `12345678901234567890`（Base32 为 `GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ`），取 8 位结果的低 6 位：

| 时间（秒） | RFC 6238 的 8 位结果 | 本站 API 返回的 6 位 | 结论 |
|---|---|---|---|
| 59 | 94287082 | 287082 | 一致 |
| 1111111109 | 07081804 | 081804 | 一致 |
| 1111111111 | 050471 | 050471 | 一致 |
| 1234567890 | 89005924 | 005924 | 一致 |

四个向量全中，说明 HMAC-SHA1、动态截断和取模都实现对了。你可以直接把页面上的验证码拿去和任意 TOTP 验证器对比。

演示地址：https://quote.kernelranch.com/2fa

接口也很简单：`GET /api/totp?secret=...` 返回当前验证码与剩余秒数，`GET /api/verify?secret=...&code=...` 返回是否通过（前后各一个时间片内匹配即通过）。

## 7. 第二因素怎么选

| 方式 | 抗钓鱼 | 便利性 | 建议 |
|---|---|---|---|
| TOTP（验证器 App） | 一般 | 高，离线可用 | 大多数账号的默认选择 |
| 短信验证码 | 差，可被 SIM 劫持 | 最高 | 只作兜底，不做唯一手段 |
| 硬件密钥（WebAuthn） | 强，绑定域名 | 中，需随身携带 | 邮箱、云账号、代码仓库优先 |
| 推送确认 | 中 | 高 | 企业内部，注意防疲劳轰炸 |

一个务实的原则：主账号用硬件密钥，日常账号用 TOTP，短信只留作恢复通道，同时把恢复码离线保存。只在一个设备上绑定、丢了手机就永久失去账号，是真实发生过的场景。

## 8. 三个常见误区

把 2FA 当成"防止密码被猜中"。它防的是密码已经泄露之后的事，弱密码仍然该改。

把验证码发给别人。任何要你提供验证码的"客服"都是攻击者。

只绑定一个设备。恢复码没存、备用验证器没绑，等于把账号的命运交给一台手机。

## 小结

2FA 的核心不是多加一道工序，而是引入一个"不在密码库里"的因素。它把凭据泄露的后果从"账号丢失"降级为"一次失败的尝试"。

TOTP 之所以成为事实标准，是因为它足够简单：一个共享密钥、一个时间片、一次 HMAC，就能在完全离线的情况下算出双方一致的六位数字。简单到可以用几十行代码实现，也简单到能用 RFC 的测试向量验证对错。

想亲手试一次，可以打开 https://quote.kernelranch.com/2fa ，页面上的验证码是本地算的，断网也不会失效。

## 数据来源与检索时间

检索时间 2026-11-10。RFC 测试向量与 API 返回值为本机实测。

| 来源 | URL | 对应数据 |
|---|---|---|
| RFC 6238《TOTP》 | https://www.rfc-editor.org/rfc/rfc6238.txt | 时间片 30 秒、HMAC-SHA1 构造、动态截断算法、附录 B 的测试向量（59→94287082、1111111109→07081804、1111111111→050471、1234567890→89005924） |
| OWASP《Multifactor Authentication Cheat Sheet》 | https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html | 第二因素分类、短信验证码的风险、MFA 疲劳与推送轰炸的防护建议 |
| RFC 4226《HOTP》 | https://www.rfc-editor.org/rfc/rfc4226.txt | 动态截断与基于计数器的构造，TOTP 的基础 |

## 核验说明

- 四个测试向量的比对是本机对已部署站点发起的真实请求（`/api/totp?secret=...&t=...`），返回的 `code` 与 RFC 值逐条核对一致。
- 演示站部署在 Cloudflare Workers 上，域名 `quote.kernelranch.com`，HTTPS 已生效（`/2fa` 返回 200，响应体约 9.9KB）；服务端与页面的算法是两份独立实现，页面那份在浏览器里跑。
- 未做：没有做安全审计，没有测并发下的验证码重放防护（演示用，不存储状态）；没有验证 WebAuthn 的完整注册与断言流程。
