karbe/src/app/api/password/reset-request/route.ts
Claude Integration a373bd60ad
All checks were successful
CI / test (pull_request) Successful in 2m10s
feat(hardening): rate limit (signup/reset/bookings) + tâches cron + backup PostgreSQL nocturne
2026-06-01 20:16:57 +00:00

58 lines
1.8 KiB
TypeScript

import { NextResponse } from "next/server";
import { z } from "zod";
import { createPasswordResetToken } from "@/lib/password-reset";
import { prisma } from "@/lib/prisma";
import { sendPasswordReset } from "@/lib/email";
import { recordAudit } from "@/lib/admin/audit";
import { rateLimitRequest } from "@/lib/rate-limit";
export const runtime = "nodejs";
const schema = z.object({
email: z.string().trim().toLowerCase().email(),
});
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr";
export async function POST(req: Request) {
const rl = rateLimitRequest(req, "password-reset", 60 * 60 * 1000, 3);
if (!rl.ok) {
return NextResponse.json(
{ error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` },
{ status: 429, headers: { "Retry-After": String(rl.retryAfter) } },
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
}
const parsed = schema.safeParse(body);
if (!parsed.success) {
// Réponse générique pour ne pas leak la validité du format à un attaquant.
return NextResponse.json({ ok: true });
}
const user = await prisma.user.findUnique({
where: { email: parsed.data.email },
select: { id: true, email: true, firstName: true, isActive: true },
});
if (user && user.isActive) {
const token = await createPasswordResetToken(user.id);
const resetUrl = `${SITE_URL}/mot-de-passe-oublie/${token}`;
sendPasswordReset(user.email, resetUrl).catch(() => {});
await recordAudit({
scope: "public.password",
event: "reset.request",
target: user.id,
actorEmail: user.email,
details: {},
});
}
// Réponse identique que l'email existe ou non (énumération-safe).
return NextResponse.json({ ok: true });
}