# 账号二次验证（2FA）

Issue #14 交付标准 TOTP 与一次性恢复码。账号安全页面可扫码或手动绑定身份验证器；输入新验证器的首次验证码后才启用。TOTP 采用 [RFC 6238](https://www.rfc-editor.org/rfc/rfc6238) / [RFC 4226](https://www.rfc-editor.org/rfc/rfc4226) 的 SHA1、6位、30秒配置，容忍前后一个时间步；已经成功使用的时间步不能再用。

## 使用与恢复

1. 在「账号安全 → 二次验证」输入当前密码，开始绑定。二维码完全在浏览器本地生成，不将密钥发送到第三方二维码服务。
2. 在5分钟内输入新验证器的验证码。启用后显示10个恢复码，可下载保存；仅此次返回明文，以后只能查看剩余数量。
3. 密码、第三方账号、小程序登录均先完成第一因素，再验证 TOTP 或恢复码。挑战5分钟有效。验证码已使用时请等待下一组，或使用尚未使用的恢复码。
4. 丢失设备后，可用恢复码登录，再用当前密码和另一枚恢复码重绑。重绑开始时旧因素仍有效，新验证器验证通过后原密钥及旧恢复码才失效。
5. 恢复码重置、重绑和关闭都需要当次密码验证及现有 TOTP/恢复码；启用确认使用新验证器首次验证码。启用/重绑/关闭会撤销其他设备的会话，当前已验证会话保留。

邮箱密码重置不关闭2FA，并使旧登录挑战和所有旧会话失效。若设备及恢复码全部丢失，需要联系管理员核实身份，首期没有仅凭邮箱或管理员按钮直接关闭2FA的绕过入口。请在启用后保存恢复码；如果响应丢失，已配置的验证器仍可登录，并可重新生成恢复码。

## 接口契约与客户端兼容

以下路径加前缀 `/api/v1`；小程序使用 `/api/mini/v1`。浏览器沿用 Cookie、Origin 与 CSRF 校验；小程序仅使用 Bearer，不接受 Cookie。未启用2FA的账号保持已有成功响应。

| 方法和路径 | 请求 / 响应 |
| --- | --- |
| GET `/user/mfa` | 返回 `enabled`、`enabled_at`、`recovery_codes_remaining`，不返回已有密钥或恢复码 |
| POST `/user/mfa/setup` | `{password,code?}`；启用账号的 `code` 必填。返回 `secret`、`otpauth_uri`、`expires_in:300` |
| POST `/user/mfa/enable` | `{password,code}`；新验证器首次验证码。返回 `recovery_codes: string[10]` |
| POST `/user/mfa/recovery-codes` | `{password,code}`；原恢复码立即失效，返回新 `recovery_codes` |
| POST `/user/mfa/disable` | `{password,code}`；成功返回 `enabled:false` |
| POST `/auth/mfa/verify` | `{challenge_token,code}`；成功返回原登录成功响应，浏览器设置会话Cookie，小程序返回 tokens |

第一因素成功、第二因素未完成时，密码与小程序登录返回 HTTP 202：

```json
{
  "code": 20201,
  "message": "需要二次验证",
  "data": {
    "status": "mfa_required",
    "challenge_token": "opaque-one-time-token",
    "expires_in": 300,
    "methods": ["totp", "recovery_code"]
  }
}
```

此响应不是登录成功，不包含 user/session/access/refresh token，不创建完整会话。旧客户端不能把任意2xx当作完整认证；如果不支持挑战，应提示用户使用新版客户端，不能降级绕过。新网页与小程序均识别此状态。第三方回调用 HttpOnly 的 `ac_mfa_challenge` Cookie 保存挑战并跳转 `/login?mfa=1`；验证接口允许浏览器省略 body 的 token 并使用该 Cookie。挑战不得写入 URL、日志或持久化前端存储。前端在原页面继续绑定和 OAuth `return_to` 流程，验证前不跳转授权完成页。

无效/过期/已使用挑战及错误验证码返回400；账号级10分钟最多10次二次验证或管理尝试，验证IP级10分钟最多30次，超过返回429。每个挑战最多5次验证，重新发起挑战不会重置账号限制。每账号最多10个未清理的挑战，创建时清理该账号过期/已消费行。

## 存储、并发与安全边界

- 迁移 `019_mfa.sql` 按仓库现有文件名顺序加载，建立因素、恢复码、挑战专表，并向 sessions 添加 `mfa_verified_at`。不存在因素记录的旧账号默认关闭2FA。
- 密钥使用现有 `DATA_KEY_FILE` 的 AES-GCM 加密，使用独立 MFA 域和 user ID 作为关联数据，避免跨账号替换密文。备份数据库时必须同时妥善备份现有数据密钥；密钥缺失或损坏时失败关闭，不自动清除2FA。
- 每枚恢复码含130位随机熵，只存账号绑定的 SHA-256 哈希。明文仅在生成时返回，使用时原子删除；TOTP 记录最新成功时间步。两者消费均与受保护操作在同一事务中。
- 全部 MFA 流程先锁定 users 行，串行验证、恢复码消费、挑战消费及会话创建；失败次数单独提交，Redis 账号限制在事务回滚后仍有效。敏感管理操作在锁内重新验证当前密码，重绑的临时配置绑定当前会话、凭据版本和5分钟期限。
- 挑战绑定账号 `auth_version` 和 web/miniapp 客户端类型。改密码、重置密码、改邮箱和因素变更使旧证明失效。小程序新微信身份在二次验证完成前不会写入账号绑定。
- 会话中间件及最终 token 签发校验 MFA 标记。OAuth 同意写入授权码前、token兑换/刷新、资源 token 校验均检查会话；其他会话被因素变更撤销后，旧 OAuth 授权码/令牌也不能继续使用。
- 审计事件包括 `mfa.challenge`、`mfa.verified`、`mfa.rejected`、`mfa.management_rejected`、`mfa.recovery_used`、`mfa.setup`、`mfa.enable`、`mfa.recovery`、`mfa.disable`。日志不含密钥、验证码、恢复码或挑战明文；审计失败回滚操作。

## 与 #12 / #13 及长期路线的合并边界

`completeAuthentication(c, proofUser, remember, source, redirect, returnTo...)` 是统一第一因素后入口；proofUser 包含验证当时的 `AuthVersion`。风险验证/快捷登录必须在自身第一因素及风险步骤完成后调用此入口，禁止直接 INSERT sessions 或调用 `issue` 绕过挑战。只有 `finishAuthentication` 在所有因素通过后签发会话、记录登录统计。可选 returnTo 只接受本地 `/authorize?...`，无MFA时直接返回此目标，有MFA时转至 `/login?mfa=1&return_to=...`，网页验证后返回原授权请求，保留 state/PKCE；省略参数保持原 `/dashboard` 行为。

已完成MFA的小程序扫码确认属于会话转换，不重复发起挑战。#13 的 poll 消费一次性扫码凭证后，在数据库事务中调用 `createAuthenticatedWebSessionFromMini(tx,c,record.UserID,record.SessionID,&user,&session)`；该 helper 按 users → source session 顺序加锁，复核账号、来源miniapp会话、撤销/过期与 `mfaSessionAllowed`，再创建 source=`oauth-scan`、verified=true 的Web会话。事务成功后调用 `finishAuthentication(c,user,session,false,"oauth-scan",false)`，返回 `{status:"authenticated",user}` 并设置Web会话Cookie。原小程序MFA未完成或已失效时拒绝转换。本分支只提供兼容helper，没有实现 #13 的扫码登录产品功能。

#13 合并时需保留其 providerState.ReturnTo 的安全保存，在 provider callback 将原签发逻辑替换为 `completeAuthentication(c,user,false,"provider:"+rec.Provider,true,rec.ReturnTo)`。AuthForm的第三方按钮使用 `'/start'+flow`，两分支保留一次此改动即可。先合入 #14 基础提交及兼容补丁，再合 #13 并按上述调用接线；无需新增迁移。兼容回归覆盖安全/恶意目标、授权参数原样往返、miniapp→Web的成功/未验证/撤销/过期路径，以及普通登录与现有小程序登录。

公共修改：`app.go`（字段、路由、会话校验）、`identity.go`（密码入口/最终签发/改密码版本）、`providers.go`、`miniapp.go`、`oauth.go`、网页 AuthForm/账号安全入口以及小程序登录/API。其余实现放在专用 MFA 模块中。#12/#13 合并需人工核对这些登录文件及统一响应，019 不依赖它们的018等迁移。

本次只交付 #3 长期路线中重叠的 TOTP/MFA、恢复码与相关近期认证部分。WebAuthn/Passkey 需要独立的凭据注册、来源/RP校验及设备恢复设计，不属于本期必要范围；JWT轮换、自检等其他项目不在本次范围，不能据此关闭整个 #3。

## 本机验证

遵循根目录 AGENTS.md，Go 测试直接在 Windows 运行。连接前已确认既有 Postgres18 和 RedisPwd 健康，分别映射本机5432与6379；没有使用 MYSQL8、其他测试容器或 Compose，也没有创建/重建/替换容器。数据库测试创建随机 `mfa_test_*` schema 并清理，仅删除本测试产生的 Redis key，不清空共享Redis。

后端运行：使用原项目忽略的本地配置提供 DATABASE_URL、REDIS_URL、JWT_PRIVATE_KEY_FILE、DATA_KEY_FILE 等变量；先在选定隔离数据库/schema执行 `go run ./cmd/server migrate`，再以未占用的 `LISTEN_ADDR` 和匹配的 `APP_ORIGIN` 执行 `go run ./cmd/server`。测试通过 Go `httptest` 直接运行完整 HTTP Router，不启动邮件/支付后台任务。前端可 `npm run dev -- --port 13214`（先确认未占用）；API_URL 指向本机后端。本次 UI 验证使用本 worktree 的 Next 生产构建及独占端口13214，API fixtures 不修改真实账号。

2026-09-10 验证结果：Go 全包默认测试、go vet、后端编译通过；5项明确启用数据库的认证集成测试通过（MFA、第三方、小程序、旧机密OAuth、OAuth撤销）；网页生产构建和TypeScript检查通过；小程序TypeScript与32项测试通过；Playwright的1440/390像素界面流程通过，截图位于忽略目录 `artifacts/mfa`；帮助文档生成和一致性检查通过。默认全包测试中的其他集成测试因未设置环境变量而跳过，没有把它们计入已执行集成验证。核心测试命令：

```text
go test ./...
go vet ./...
go test ./internal/app -run 'TestMFAIntegration|TestProviderLoginIntegration|TestMini|TestOAuth.*Integration|TestLegacyConfidentialOAuthIntegration' -count=1 -v
npm run build -- --webpack                      # web
npm run typecheck && npm test                   # miniapp
node scripts/mfa-ui-check.cjs                   # UI_BASE_URL 指向本机服务
node scripts/build-help.cjs --check
```

集成测试需设置 TEST_DATABASE_URL、TEST_REDIS_URL 为用户既有服务的本地连接，缺少时明确跳过。TOTP有RFC向量及时间偏移/重放单测，集成覆盖实际密码/第三方/小程序认证、OAuth旁路、并发TOTP和恢复码、次数/过期、密钥损坏、审计失败回滚、密码恢复后仍要求MFA、绑定/重绑/关闭及旧会话撤销。未执行 Go race instrumentation、微信小程序打包/真机验收、外部身份验证器真机扫码、真实微信/GitHub渠道、邮件发送和生产部署。
