用 Claude Code 安全实现密码重置:Next.js、Prisma 与 Token 设计
用 Claude Code 实现安全密码重置:随机 Token、哈希保存、限流、邮件模板、会话失效、MFA 分离与审计日志。
密码重置不是一个简单的“忘记密码”表单,而是一条账号恢复通道。如果这条通道比登录流程更弱,攻击者就会绕过登录,转而利用重置邮件、泄露的 Token、重复使用的链接或没有失效的旧会话来接管账号。
Claude Code 很适合把这类功能拆成可审查的实现任务。但提示词必须足够具体:不能只说“做一个密码重置页面”,而要要求它同时处理用户存在性不泄露、随机 Token、Token 哈希保存、短有效期、一次使用、rate limit、邮件模板、Referrer-Policy、noindex、会话失效、审计日志,以及 MFA 重置必须分离。
本文参考 OWASP 的 Forgot Password Cheat Sheet、Authentication Cheat Sheet 和 Testing 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 和邮箱限制 | 防止轰炸邮箱和暴力尝试 |
| 邮件内容 | 不包含密码 | 邮箱不是密码保管箱 |
| 重置页面 | noindex 与 Referrer-Policy: no-referrer | 防止搜索收录和 Referer 泄露 Token |
| 重置后 | 失效所有旧会话,不自动登录 | 清理可能已被盗的 session |
| MFA | 不在此流程重置 MFA | MFA 恢复需要更强的身份确认 |
交给 Claude Code 的任务粒度
把任务拆小,审查才有抓手。建议分成 5 个提交或 5 个 Claude Code 请求。
- 给 Prisma schema 增加
PasswordResetToken、Session、AuditLog。 - 实现服务层:Node crypto 生成 Token,哈希保存,过期,一次使用,rate limit。
- 实现 Next.js App Router 的 request 与 confirm API。
- 实现邮件模板、重置页面、
Referrer-Policy、X-Robots-Tag与缓存头。 - 增加测试:邮箱枚举防护、过期 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
下面是最小模型。已有 User 或 Session 的项目可以合并字段,不要重复建表。
// 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 只有 tokenHash;expiresAt 与 usedAt 同时生效;重置成功后撤销 session;reset 页面有 Referrer-Policy、X-Robots-Tag、Cache-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 做实现支援时,也会先手动确认这四点,再把它们写成自动化测试。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。