账号二次验证(2FA)
Issue #14 交付标准 TOTP 与一次性恢复码。账号安全页面可扫码或手动绑定身份验证器;输入新验证器的首次验证码后才启用。TOTP 采用 RFC 6238 / RFC 4226 的 SHA1、6位、30秒配置,容忍前后一个时间步;已经成功使用的时间步不能再用。
使用与恢复
- 在「账号安全 → 二次验证」输入当前密码,开始绑定。二维码完全在浏览器本地生成,不将密钥发送到第三方二维码服务。
- 在5分钟内输入新验证器的验证码。启用后显示10个恢复码,可下载保存;仅此次返回明文,以后只能查看剩余数量。
- 密码、第三方账号、小程序登录均先完成第一因素,再验证 TOTP 或恢复码。挑战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:
{
"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;帮助文档生成和一致性检查通过。默认全包测试中的其他集成测试因未设置环境变量而跳过,没有把它们计入已执行集成验证。核心测试命令:
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渠道、邮件发送和生产部署。