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

用 Claude Code 安全实现密码重置:Next.js、Prisma 与 Token 设计

用 Claude Code 实现安全密码重置:随机 Token、哈希保存、限流、邮件模板、会话失效、MFA 分离与审计日志。

用 Claude Code 安全实现密码重置:Next.js、Prisma 与 Token 设计

密码重置不是一个简单的“忘记密码”表单,而是一条账号恢复通道。如果这条通道比登录流程更弱,攻击者就会绕过登录,转而利用重置邮件、泄露的 Token、重复使用的链接或没有失效的旧会话来接管账号。

Claude Code 很适合把这类功能拆成可审查的实现任务。但提示词必须足够具体:不能只说“做一个密码重置页面”,而要要求它同时处理用户存在性不泄露、随机 Token、Token 哈希保存、短有效期、一次使用、rate limit、邮件模板、Referrer-Policynoindex、会话失效、审计日志,以及 MFA 重置必须分离。

本文参考 OWASP 的 Forgot Password Cheat SheetAuthentication Cheat SheetTesting for Weak Password Change or Reset Functionalities。如果要把整个认证体系一起整理,可以继续看 Claude Code 安全最佳实践Claude Code 环境变量管理

安全流程总览

重置 Token 可以理解为“一把临时钥匙”。用户通过邮件拿到它,但数据库里不应该保存原始钥匙,只保存哈希值。这样即使数据库备份或只读账号泄露,攻击者也无法直接拼出重置链接。

flowchart TD
  A[用户输入邮箱] --> B[无论账号是否存在都返回同一提示]
  B --> C{rate limit 是否通过}
  C -- 否 --> Z[不暴露账号是否存在]
  C -- 是 --> D{用户是否存在}
  D -- 否 --> Z
  D -- 是 --> E[用 Node crypto 生成随机 Token]
  E --> F[只保存 SHA-256 Token 哈希]
  F --> G[通过邮件发送 HTTPS 重置链接]
  G --> H[重置页设置 noindex 和 Referrer-Policy]
  H --> I[用户提交新密码]
  I --> J[事务中一次性消费 Token]
  J --> K[哈希新密码并失效旧会话]
  K --> L[写入审计日志并要求重新登录]
检查点推荐做法原因
用户存在性存在和不存在邮箱返回同样信息防止邮箱枚举
Token 生成crypto.randomBytes(32).toString("base64url")保证不可预测
Token 保存只保存 SHA-256 哈希数据库泄露时不暴露可用链接
有效期30 分钟兼顾邮件延迟和风险窗口
使用次数只能使用一次防止转发、历史记录、日志中的链接被重用
rate limit按 IP 和邮箱限制防止轰炸邮箱和暴力尝试
邮件内容不包含密码邮箱不是密码保管箱
重置页面noindexReferrer-Policy: no-referrer防止搜索收录和 Referer 泄露 Token
重置后失效所有旧会话,不自动登录清理可能已被盗的 session
MFA不在此流程重置 MFAMFA 恢复需要更强的身份确认

交给 Claude Code 的任务粒度

把任务拆小,审查才有抓手。建议分成 5 个提交或 5 个 Claude Code 请求。

  1. 给 Prisma schema 增加 PasswordResetTokenSessionAuditLog
  2. 实现服务层:Node crypto 生成 Token,哈希保存,过期,一次使用,rate limit。
  3. 实现 Next.js App Router 的 request 与 confirm API。
  4. 实现邮件模板、重置页面、Referrer-PolicyX-Robots-Tag 与缓存头。
  5. 增加测试:邮箱枚举防护、过期 Token、重复使用 Token、session revoke。

可以这样要求 Claude Code:

在 Next.js App Router + TypeScript + Prisma 项目中实现密码重置。
约束:
- 存在和不存在的邮箱必须返回同样 JSON,并保持接近的响应时间
- 使用 Node crypto 生成至少 32 bytes 的 reset token,只在 DB 保存 SHA-256 hash
- Token 30 分钟过期,只能使用一次
- request API 按 IP 和标准化邮箱做 rate limit
- reset 页面设置 noindex 和 Referrer-Policy no-referrer
- 重置成功后撤销所有旧 session,不自动登录
- 不实现 MFA reset;MFA 恢复必须是单独流程
- 不记录原始 token、reset URL、password
只修改认证、Prisma、邮件和测试相关文件,并解释每个安全约束如何满足。

Prisma schema

下面是最小模型。已有 UserSession 的项目可以合并字段,不要重复建表。

// prisma/schema.prisma
model User {
  id                String               @id @default(cuid())
  email             String               @unique
  passwordHash      String
  mfaEnabled        Boolean              @default(false)
  passwordChangedAt DateTime?
  createdAt         DateTime             @default(now())
  resetTokens       PasswordResetToken[]
  sessions          Session[]
  auditLogs         AuditLog[]
}

model PasswordResetToken {
  id                 String    @id @default(cuid())
  userId             String
  tokenHash          String    @unique
  expiresAt          DateTime
  usedAt             DateTime?
  createdAt          DateTime  @default(now())
  requestedIp        String?
  requestedUserAgent String?
  user               User      @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId, expiresAt])
  @@index([createdAt])
}

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

  @@index([userId, revokedAt])
}

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

  @@index([userId, createdAt])
  @@index([event, createdAt])
}

服务层代码

下面代码包含随机 Token、哈希保存、内存版 rate limit、邮件模板、一次性消费 Token、密码哈希、会话失效和审计日志。生产环境请把 Map 换成 Redis 或 Upstash 这类共享存储。

// src/lib/password-reset.ts
import crypto from "node:crypto";
import bcrypt from "bcryptjs";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();
const RESET_TTL_MINUTES = 30;
const TOKEN_BYTES = 32;
const MIN_RESPONSE_MS = 450;
export const REQUEST_RESPONSE = {
  message: "If an account exists for that email, a password reset link has been sent.",
};

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

function normalizeEmail(email: string) {
  return email.trim().toLowerCase();
}

function hashToken(token: string) {
  return crypto.createHash("sha256").update(token, "utf8").digest("hex");
}

function appOrigin() {
  const origin = process.env.APP_ORIGIN;
  if (!origin || !origin.startsWith("https://")) {
    throw new Error("APP_ORIGIN must be an HTTPS origin");
  }
  return origin.replace(/\/$/, "");
}

function isLimited(key: string, max: number, windowMs: number) {
  const now = Date.now();
  const current = memoryLimits.get(key);
  if (!current || current.resetAt <= now) {
    memoryLimits.set(key, { count: 1, resetAt: now + windowMs });
    return false;
  }
  current.count += 1;
  return current.count > max;
}

async function padResponse(startedAt: number) {
  const elapsed = Date.now() - startedAt;
  if (elapsed < MIN_RESPONSE_MS) {
    await new Promise((resolve) => setTimeout(resolve, MIN_RESPONSE_MS - elapsed));
  }
}

async function sendPasswordResetEmail(input: { to: string; resetUrl: string }) {
  const html = `
    <div style="font-family:Arial,sans-serif;line-height:1.6;color:#111827">
      <h1 style="font-size:20px">Reset your password</h1>
      <p>This link expires in 30 minutes. If you did not request it, ignore this email.</p>
      <p><a href="${input.resetUrl}" style="display:inline-block;background:#2563eb;color:#fff;padding:12px 18px;border-radius:6px;text-decoration:none">Reset password</a></p>
    </div>
  `;

  if (!process.env.RESEND_API_KEY) {
    console.info("Password reset email skipped locally", { to: input.to, resetUrl: input.resetUrl });
    return;
  }

  const response = await fetch("https://api.resend.com/emails", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      from: process.env.MAIL_FROM ?? "support@example.com",
      to: input.to,
      subject: "Reset your password",
      html,
    }),
  });

  if (!response.ok) throw new Error(`Failed to send reset email: ${response.status}`);
}

export async function requestPasswordReset(input: { email: string; ip?: string; userAgent?: string }) {
  const startedAt = Date.now();
  const email = normalizeEmail(input.email);
  const ip = input.ip ?? "unknown";
  const limited =
    isLimited(`password-reset:ip:${ip}`, 20, 60 * 60 * 1000) ||
    isLimited(`password-reset:email:${email}`, 5, 15 * 60 * 1000);

  if (limited) {
    await padResponse(startedAt);
    return REQUEST_RESPONSE;
  }

  const user = await prisma.user.findUnique({ where: { email }, select: { id: true, email: true } });

  if (user) {
    const token = crypto.randomBytes(TOKEN_BYTES).toString("base64url");
    const expiresAt = new Date(Date.now() + RESET_TTL_MINUTES * 60 * 1000);

    await prisma.passwordResetToken.create({
      data: {
        userId: user.id,
        tokenHash: hashToken(token),
        expiresAt,
        requestedIp: ip,
        requestedUserAgent: input.userAgent?.slice(0, 300),
      },
    });

    await prisma.auditLog.create({
      data: {
        userId: user.id,
        event: "password_reset_requested",
        ip,
        userAgent: input.userAgent?.slice(0, 300),
        metadata: { expiresAt: expiresAt.toISOString() },
      },
    });

    await sendPasswordResetEmail({
      to: user.email,
      resetUrl: `${appOrigin()}/reset-password?token=${encodeURIComponent(token)}`,
    });
  }

  await padResponse(startedAt);
  return REQUEST_RESPONSE;
}

export async function resetPassword(input: { token: string; newPassword: string; ip?: string; userAgent?: string }) {
  if (input.newPassword.length < 12 || input.newPassword.length > 128) {
    throw new Error("invalid_password");
  }

  const tokenHash = hashToken(input.token);
  const now = new Date();
  const passwordHash = await bcrypt.hash(input.newPassword, 12);

  return prisma.$transaction(async (tx) => {
    const resetToken = await tx.passwordResetToken.findFirst({
      where: { tokenHash, expiresAt: { gt: now }, usedAt: null },
      select: { id: true, userId: true },
    });
    if (!resetToken) throw new Error("invalid_or_expired_token");

    const claimed = await tx.passwordResetToken.updateMany({
      where: { id: resetToken.id, usedAt: null },
      data: { usedAt: now },
    });
    if (claimed.count !== 1) throw new Error("invalid_or_expired_token");

    await tx.user.update({
      where: { id: resetToken.userId },
      data: { passwordHash, passwordChangedAt: now },
    });
    await tx.session.updateMany({
      where: { userId: resetToken.userId, revokedAt: null },
      data: { revokedAt: now },
    });
    await tx.auditLog.create({
      data: {
        userId: resetToken.userId,
        event: "password_reset_completed",
        ip: input.ip,
        userAgent: input.userAgent?.slice(0, 300),
        metadata: { sessionsRevoked: true },
      },
    });

    return { message: "Password updated. Please sign in again." };
  });
}

Next.js API 与页面头

request API 不能泄露账号是否存在。confirm API 可以提示链接无效或过期,但不要暴露用户信息。

// src/app/api/auth/password-reset/request/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
import { REQUEST_RESPONSE, requestPasswordReset } from "@/lib/password-reset";

const bodySchema = z.object({ email: z.string().email().max(320) });

function clientIp(request: Request) {
  return request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
}

export async function POST(request: Request) {
  const parsed = bodySchema.safeParse(await request.json().catch(() => null));
  if (!parsed.success) return NextResponse.json(REQUEST_RESPONSE, { status: 202 });

  await requestPasswordReset({
    email: parsed.data.email,
    ip: clientIp(request),
    userAgent: request.headers.get("user-agent") ?? undefined,
  });

  return NextResponse.json(REQUEST_RESPONSE, { status: 202 });
}
// src/app/api/auth/password-reset/confirm/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
import { resetPassword } from "@/lib/password-reset";

const bodySchema = z
  .object({
    token: z.string().min(32).max(256),
    password: z.string().min(12).max(128),
    confirmPassword: z.string().min(12).max(128),
  })
  .refine((value) => value.password === value.confirmPassword);

export async function POST(request: Request) {
  const parsed = bodySchema.safeParse(await request.json().catch(() => null));
  if (!parsed.success) {
    return NextResponse.json({ message: "The reset link is invalid or the password policy was not met." }, { status: 400 });
  }

  try {
    const result = await resetPassword({
      token: parsed.data.token,
      newPassword: parsed.data.password,
      userAgent: request.headers.get("user-agent") ?? undefined,
    });
    return NextResponse.json(result, { status: 200 });
  } catch {
    return NextResponse.json({ message: "The reset link is invalid or expired." }, { status: 400 });
  }
}
// next.config.mjs
const nextConfig = {
  async headers() {
    return [
      {
        source: "/reset-password",
        headers: [
          { key: "Referrer-Policy", value: "no-referrer" },
          { key: "X-Robots-Tag", value: "noindex, nofollow" },
          { key: "Cache-Control", value: "no-store" },
        ],
      },
    ];
  },
};

export default nextConfig;

三个实战场景

第一个场景是普通 SaaS 用户忘记密码。短有效期、一次使用和 session revoke 可以覆盖最常见风险:旧手机、共享浏览器、被盗 session 在重置后仍然有效。

第二个场景是管理员账号。管理员重置密码后仍应走正常登录和 MFA。密码重置不能顺手关闭 MFA。丢失认证器时,应参考 二要素认证实现 设计单独的 MFA 恢复流程。

第三个场景是客服排查“没有收到邮件”。审计日志应能看到请求时间、IP、User-Agent 和邮件发送状态,但不能看到原始 Token。这样既能支持用户,又不会给客服后台增加接管账号的能力。

第四个常见场景是 B2B 租户被大量发送重置邮件。按 IP 和邮箱限流可以在工单爆发前先看到异常信号。

常见失败与审查清单

最危险的失败是返回“该邮箱未注册”。这会变成邮箱枚举接口。响应时间差异也会泄露同样的信息。

第二个失败是保存原始 Token。数据库、日志、错误监控或客服工具里出现 Token,都意味着读权限可能变成账号接管权限。

第三个失败是 Token 没有过期或可以重复使用。邮件会被转发,浏览器历史会同步,截图也可能泄露。应在事务中把 usedAt 写入,只允许第一次成功。

第四个失败是用请求的 Host header 拼接重置 URL。应使用固定的 HTTPS APP_ORIGIN,并把它纳入环境变量审查。

审查 Claude Code 输出时,至少确认这些点:存在和不存在邮箱返回一致;没有记录原始 Token、URL、密码;DB 只有 tokenHashexpiresAtusedAt 同时生效;重置成功后撤销 session;reset 页面有 Referrer-PolicyX-Robots-TagCache-Control;MFA 恢复没有混入这次改动;审计日志包含 requested 和 completed 两类事件。

npm install @prisma/client bcryptjs zod
npx prisma migrate dev --name add_password_reset
npm run lint
npm test

结论

密码重置是一个小型认证系统,不是普通表单。把它交给 Claude Code 时,要把“不可枚举、随机 Token、哈希保存、短有效期、一次使用、限流、安全邮件、noindex、Referrer-Policy、session revoke、审计日志、MFA 分离”写成明确验收条件。

ClaudeCodeLab 可以协助团队做 Claude Code 认证实现评审、培训和落地支持。准备好现有登录流程、session 模型、邮件服务商和 MFA 策略,就能很快判断哪些部分适合自动生成,哪些部分必须人工审查。

实际试用本文方案时,请先确认四件事:真实邮箱和不存在邮箱返回一致;数据库没有原始 Token;过期和已使用 Token 会失败;密码重置后旧 session 不能再访问受保护 API。Masa 做实现支援时,也会先手动确认这四点,再把它们写成自动化测试。

#Claude Code #密码重置 #认证 #安全 #邮件
免费

免费 PDF: Claude Code 速查表

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

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

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

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

Masa

关于作者

Masa

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