Use Cases (更新: 2026/6/1)

用 Claude Code 实现 2FA:TOTP、备用码、恢复流程与 Step-Up Auth

用 Claude Code 在 Next.js 中实现安全的 2FA/MFA,覆盖 TOTP、备用码、限流、恢复、Passkeys 与审计日志。

用 Claude Code 实现 2FA:TOTP、备用码、恢复流程与 Step-Up Auth

2FA 不是“生成一个二维码”就结束的功能。真正能上线的 2FA/MFA 设计,需要同时处理 TOTP、备用码、敏感操作的 step-up auth、账号恢复、失败限流、可信设备、审计日志,以及将来升级到 WebAuthn/passkeys 的路径。

本文以 Next.js App Router、TypeScript、Prisma 为前提,说明如何把 Claude Code 当作实现助手,而不是让它一次性生成一个不可审查的大补丁。登录和会话基础可以参考 Claude Code JWT 认证指南,密码找回参考 Claude Code 密码重置实现,权限边界参考 Claude Code RBAC 实现指南,让 AI agent 动代码时的权限控制参考 Claude Code 安全最佳实践

外部依据建议同时阅读 OWASP MFA Cheat SheetOWASP WSTG MFA 测试项MDN Web Authentication APIW3C WebAuthn Level 3。这些资料强调的不只是启用 MFA,还包括何时要求 MFA、OTP 如何保存、失败尝试如何处理、恢复流程如何设计,以及更换认证要素时如何再次确认身份。

先定义安全边界

TOTP 是 time-based one-time password,也就是基于时间的一次性验证码。服务器和认证器 App 共享一个秘密,通常每 30 秒生成一个 6 位数字。MFA 提升的是会话可信度,但不等于授权。邀请管理员、修改账单、创建 API key、修改密码、导出个人数据等高风险操作,应该要求 step-up auth,也就是登录后仍然再次完成 MFA。

flowchart TD
  A["Password or SSO login"] --> B{"2FA enabled?"}
  B -->|No| C["Lower-assurance session"]
  B -->|Yes| D["TOTP or backup code challenge"]
  D --> E{"Valid and rate limit OK?"}
  E -->|No| F["Deny and audit"]
  E -->|Yes| G["Session with mfaAt"]
  G --> H{"Sensitive action?"}
  H -->|Yes| I{"mfaAt fresh?"}
  I -->|No| D
  I -->|Yes| J["Allow"]
  H -->|No| J

把这张边界图先交给 Claude Code。不要只说“实现 2FA”。更好的粒度是:数据库 schema、加密工具、setup API、enable API、登录挑战、恢复流程、remember device、审计日志、测试。生产密钥、KMS 配置、客服恢复策略、管理员权限,必须由人来定。

Implement 2FA for a Next.js App Router app with TypeScript and Prisma.
Scope: TOTP, backup codes, remembered devices, step-up auth, recovery events, audit logs.
Rules:
- Encrypt TOTP secrets with AES-256-GCM before saving.
- Hash backup codes and remembered device tokens with HMAC-SHA256.
- Apply rate limits to setup, enable, login challenge, and recovery.
- Do not make SMS the primary factor.
- Keep a migration path to WebAuthn/passkeys.
First output the Prisma schema and review checklist only.

评审时要重点找这些问题:TOTP secret 明文保存、备用码明文保存或写入日志、备用码没有 usedAt、6 位验证码没有限流、remember device token 原文入库、一个客服就能解除 MFA、启用、禁用、失败、恢复、更换要素没有审计日志。

数据模型

下面的 schema 将 TOTP secret 加密保存,将备用码和可信设备 token 只保存哈希,并为安全事件留出审计日志。

// prisma/schema.prisma
model User {
  id                         String              @id @default(cuid())
  email                      String              @unique
  passwordHash               String
  twoFactorEnabled           Boolean             @default(false)
  twoFactorEnabledAt         DateTime?
  twoFactorSecretCiphertext  String?
  twoFactorSecretIv          String?
  twoFactorSecretTag         String?
  backupCodes                BackupCode[]
  rememberedDevices          RememberedDevice[]
  auditLogs                  AuditLog[]
  createdAt                  DateTime            @default(now())
  updatedAt                  DateTime            @updatedAt
}

model BackupCode {
  id        String    @id @default(cuid())
  userId    String
  codeHash  String
  usedAt    DateTime?
  createdAt DateTime  @default(now())
  user      User      @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId, usedAt])
  @@unique([userId, codeHash])
}

model RememberedDevice {
  id          String   @id @default(cuid())
  userId      String
  deviceHash  String
  userAgent   String?
  expiresAt   DateTime
  lastUsedAt  DateTime?
  createdAt   DateTime @default(now())
  user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId, expiresAt])
  @@unique([userId, deviceHash])
}

model AuditLog {
  id        String   @id @default(cuid())
  userId    String?
  action    String
  metadata  Json?
  ipAddress String?
  userAgent String?
  createdAt DateTime @default(now())
  user      User?    @relation(fields: [userId], references: [id], onDelete: SetNull)

  @@index([userId, createdAt])
  @@index([action, createdAt])
}
# .env.example
DATABASE_URL="postgresql://user:password@localhost:5432/app"
TWO_FACTOR_ENCRYPTION_KEY="base64-encoded-32-byte-key"
TWO_FACTOR_HASH_SECRET="long-random-hmac-secret"

加密、哈希和限流工具

TOTP secret 之后还要解密验证,所以用 AES-256-GCM 加密。备用码和 remember device token 只需要比对,所以用 HMAC-SHA256 哈希。这样即使数据库泄露,攻击者也不能直接生成验证码或伪造可信设备 cookie。

// src/lib/two-factor.ts
import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { authenticator } from "otplib";

authenticator.options = { step: 30, window: 1 };

const buckets = new Map<string, { count: number; resetAt: number }>();

function encryptionKey() {
  const key = Buffer.from(process.env.TWO_FACTOR_ENCRYPTION_KEY ?? "", "base64");
  if (key.length !== 32) throw new Error("TWO_FACTOR_ENCRYPTION_KEY must be 32 bytes in base64.");
  return key;
}

function hmacSecret() {
  const secret = process.env.TWO_FACTOR_HASH_SECRET;
  if (!secret || secret.length < 32) throw new Error("TWO_FACTOR_HASH_SECRET is too short.");
  return secret;
}

export function encryptSecret(secret: string) {
  const iv = randomBytes(12);
  const cipher = createCipheriv("aes-256-gcm", encryptionKey(), iv);
  const ciphertext = Buffer.concat([cipher.update(secret, "utf8"), cipher.final()]);
  return { ciphertext: ciphertext.toString("base64"), iv: iv.toString("base64"), tag: cipher.getAuthTag().toString("base64") };
}

export function decryptSecret(input: { ciphertext: string; iv: string; tag: string }) {
  const decipher = createDecipheriv("aes-256-gcm", encryptionKey(), Buffer.from(input.iv, "base64"));
  decipher.setAuthTag(Buffer.from(input.tag, "base64"));
  return Buffer.concat([decipher.update(Buffer.from(input.ciphertext, "base64")), decipher.final()]).toString("utf8");
}

export function verifyTotp(token: string, secret: string) {
  return /^\d{6}$/.test(token) && authenticator.verify({ token, secret });
}

export function generateBackupCodes(count = 10) {
  return Array.from({ length: count }, () => {
    const raw = randomBytes(5).toString("hex").toUpperCase();
    return `${raw.slice(0, 5)}-${raw.slice(5)}`;
  });
}

export function hashBackupCode(userId: string, code: string) {
  const normalized = code.replace(/[^a-zA-Z0-9]/g, "").toUpperCase();
  return createHmac("sha256", hmacSecret()).update(`backup:${userId}:${normalized}`).digest("hex");
}

export function hashRememberedDevice(userId: string, token: string) {
  return createHmac("sha256", hmacSecret()).update(`remember:${userId}:${token}`).digest("hex");
}

export function safeEqualHex(a: string, b: string) {
  const left = Buffer.from(a, "hex");
  const right = Buffer.from(b, "hex");
  return left.length === right.length && timingSafeEqual(left, right);
}

export function consumeRateLimit(key: string, limit: number, windowMs: number) {
  const now = Date.now();
  const bucket = buckets.get(key);
  if (!bucket || bucket.resetAt <= now) {
    buckets.set(key, { count: 1, resetAt: now + windowMs });
    return true;
  }
  bucket.count += 1;
  return bucket.count <= limit;
}

这个 Map 版限流只适合本地和单进程。上线到多实例、Serverless 或容器环境时,让 Claude Code 在保持函数签名不变的前提下替换成 Redis 或 Upstash。

启用 API 与一次性备用码

setup API 只生成 secret 和 QR code,不立即启用 2FA。用户输入第一个 TOTP 并验证通过后,才生成备用码并启用。备用码只在响应中显示一次,数据库只保存哈希。

// src/app/api/account/2fa/enable/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/require-user";
import { decryptSecret, generateBackupCodes, hashBackupCode, consumeRateLimit, verifyTotp } from "@/lib/two-factor";

const schema = z.object({ token: z.string().regex(/^\d{6}$/) });

export async function POST(request: NextRequest) {
  const user = await requireUser();
  const { token } = schema.parse(await request.json());

  if (!consumeRateLimit(`2fa-enable:${user.id}`, 5, 10 * 60 * 1000)) {
    return NextResponse.json({ error: "Too many attempts." }, { status: 429 });
  }

  const record = await prisma.user.findUniqueOrThrow({ where: { id: user.id } });
  if (!record.twoFactorSecretCiphertext || !record.twoFactorSecretIv || !record.twoFactorSecretTag) {
    return NextResponse.json({ error: "2FA setup has not started." }, { status: 400 });
  }

  const secret = decryptSecret({
    ciphertext: record.twoFactorSecretCiphertext,
    iv: record.twoFactorSecretIv,
    tag: record.twoFactorSecretTag,
  });

  if (!verifyTotp(token, secret)) {
    await prisma.auditLog.create({ data: { userId: user.id, action: "2fa.enable.failed" } });
    return NextResponse.json({ error: "Invalid code." }, { status: 400 });
  }

  const backupCodes = generateBackupCodes();
  await prisma.$transaction([
    prisma.backupCode.deleteMany({ where: { userId: user.id } }),
    prisma.user.update({ where: { id: user.id }, data: { twoFactorEnabled: true, twoFactorEnabledAt: new Date() } }),
    prisma.backupCode.createMany({ data: backupCodes.map((code) => ({ userId: user.id, codeHash: hashBackupCode(user.id, code) })) }),
    prisma.auditLog.create({ data: { userId: user.id, action: "2fa.enabled", metadata: { backupCodeCount: backupCodes.length } } }),
  ]);

  return NextResponse.json({ backupCodes });
}

登录挑战应允许 TOTP 或未使用的备用码。备用码成功后必须立即写入 usedAt。remember device 则生成随机 token,把 HMAC 后的值存入数据库,把原始 token 写入 httpOnlysecuresameSite cookie,并设置 30 天左右的过期时间和可撤销页面。

前端组件

// src/components/TwoFactorSetupPanel.tsx
"use client";

import { useState } from "react";

export function TwoFactorSetupPanel() {
  const [qrCodeDataUrl, setQrCodeDataUrl] = useState("");
  const [manualKey, setManualKey] = useState("");
  const [token, setToken] = useState("");
  const [backupCodes, setBackupCodes] = useState<string[]>([]);
  const [error, setError] = useState("");

  async function startSetup() {
    const response = await fetch("/api/account/2fa/setup", { method: "POST" });
    const data = await response.json();
    if (!response.ok) return setError(data.error ?? "Setup failed.");
    setQrCodeDataUrl(data.qrCodeDataUrl);
    setManualKey(data.manualKey);
  }

  async function enable() {
    const response = await fetch("/api/account/2fa/enable", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ token }),
    });
    const data = await response.json();
    if (!response.ok) return setError(data.error ?? "Invalid code.");
    setBackupCodes(data.backupCodes);
  }

  return (
    <section aria-label="Two-factor authentication">
      {!qrCodeDataUrl && <button onClick={startSetup}>Set up 2FA</button>}
      {qrCodeDataUrl && backupCodes.length === 0 && (
        <>
          <img src={qrCodeDataUrl} alt="Authenticator app QR code" width={220} height={220} />
          <p>Manual key: <code>{manualKey}</code></p>
          <input inputMode="numeric" autoComplete="one-time-code" value={token} onChange={(event) => setToken(event.target.value)} maxLength={6} />
          <button onClick={enable}>Verify and enable</button>
        </>
      )}
      {backupCodes.length > 0 && (
        <ul>{backupCodes.map((code) => <li key={code}><code>{code}</code></li>)}</ul>
      )}
      {error && <p role="alert">{error}</p>}
    </section>
  );
}

恢复、SMS 与 Passkeys

恢复流程经常成为 MFA 的薄弱点。优先让用户使用备用码。若所有要素都丢失,应结合邮件确认、已有可信会话、B2B 场景下的组织 owner 批准,以及恢复后的冷却期。恢复完成后要通知用户并撤销旧会话。

SMS 在部分消费级场景中比没有第二要素更好,但会受 SIM swap、号码回收、运营商客服社工影响。管理员、账单、API key 等场景不应把 SMS 作为首选因素。

WebAuthn/passkeys 使用与域名绑定的公开密钥凭证,是更抗钓鱼的方向。实际迁移可以先把 TOTP 和备用码做稳,再添加 WebAuthn credential 表,并复用同一套审计、撤销和 step-up 策略。

// src/lib/step-up.ts
export function assertFreshMfa(session: { mfaAt?: Date | null }, maxAgeMinutes = 15) {
  if (!session.mfaAt) throw new Error("MFA is required.");
  if (Date.now() - session.mfaAt.getTime() > maxAgeMinutes * 60 * 1000) {
    throw new Error("MFA challenge is too old.");
  }
}

实例与常见坑

实例一是管理员邀请。邀请 owner 或 admin 前要求 15 分钟内的 mfaAt,这样即使 session cookie 被盗,也不能直接接管组织。

实例二是账单修改。修改信用卡、发票邮箱、退款信息、税务资料时要求 step-up auth,并记录审计日志,但不要把卡号、备用码或恢复链接写入日志。

实例三是 API key 创建。创建或扩大 API key scope 前要求 MFA,并结合 RBAC,只有拥有 api_key:create 权限的人才能进入流程。

实例四是客服恢复。客服不应直接关闭 MFA,而是创建恢复请求,经过二人审批、用户通知、等待期和审计日志后再解除。

常见坑包括:TOTP secret 明文保存、备用码可重复使用、6 位验证码无限尝试、remember device token 原文入库、关闭 2FA 不需要 step-up、恢复链接有效期太长、审计日志泄露 secret。让 Claude Code 每次改动后用 OWASP MFA Cheat Sheet 和 WSTG 的检查项反向审查,可以明显降低这些问题。

结语

可靠的 2FA 是会话可信度、恢复、审计和运营策略的组合,不是一个表单。Claude Code 的价值在于把每个小任务快速实现并生成测试,但边界和权限要由你控制。ClaudeCodeLab 可以协助审查现有 MFA 流程、为团队做 Claude Code 安全开发培训,或帮助 Next.js 产品从 TOTP 逐步迁移到 passkeys。

实际试用本文方案时,请确认不同环境使用不同加密密钥、备用码只显示一次、TOTP 失败会触发限流、remember device cookie 可撤销且不可被数据库泄露直接伪造、关闭 2FA 需要 step-up auth、审计日志只记录事件不记录秘密值。

#Claude Code #2FA #MFA #TOTP #安全
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。