src/app/api/auth/client/magic-link/route.ts
Annotation non disponible
Lance npm run annotate (nécessite ANTHROPIC_API_KEY dans .env.local) pour générer une annotation française par Claude Haiku 4.5.
Concepts détectés — comprends la théorie
ORM Prisma
4 occurrencesCe fichier accède à la base de données via Prisma. Prisma est l'ORM utilisé côté backend pour les requêtes typées sur PostgreSQL.
Voir l'article général
Route API Next.js
3 occurrencesCe fichier est une route API Next.js (App Router). Voir le contrat API complet pour les conventions de réponse et d'auth.
Voir l'article général
1 export
POST
Code source· typescript
import { NextRequest, NextResponse } from "next/server";
import { envoyerMagicLink } from "@/lib/email";
import { prisma } from "@/lib/prisma/client";
import { redis } from "@/lib/redis";
import { createHash, randomUUID } from "crypto";
import { captureError } from "@/lib/sentry";
const TTL = parseInt(process.env.MAGIC_LINK_TTL_SECONDS || "900");
const BASE_URL = process.env.MAGIC_LINK_BASE_URL || "http://localhost:3000";
const SUBDOMAIN_REGEX = /^[a-z0-9-]{1,63}$/;
// P0-7 — Rate-limit OTP émission (1/min + 5/h par identifier)
const RATE_LIMIT_MIN = 1;
const RATE_LIMIT_HOUR = 5;
const RATE_TTL_MIN = 60;
const RATE_TTL_HOUR = 3600;
function genOtp(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}
export async function POST(req: NextRequest) {
try {
const { identifier, identifierType, tenantId, source } = await req.json();
if (!identifier || !identifierType || !tenantId) {
return NextResponse.json({ error: "Parametres manquants" }, { status: 400 });
}
if (!["phone", "email"].includes(identifierType)) {
return NextResponse.json({ error: "identifierType invalide" }, { status: 400 });
}
const normalizedIdentifier = identifierType === "email"
? identifier.trim().toLowerCase()
: identifier.trim();
// P0-3 — Validation stricte tenantId + lookup DB obligatoire (open redirect)
const normalizedTenantId = typeof tenantId === "string" ? tenantId.trim().toLowerCase() : "";
if (!normalizedTenantId) {
return NextResponse.json({ error: "tenantId requis" }, { status: 400 });
}
let dbTenantSubdomain: string | null = null;
let dbTenantNom = "wari.pro";
if (normalizedTenantId !== "wari") {
if (!SUBDOMAIN_REGEX.test(normalizedTenantId)) {
return NextResponse.json({ error: "tenantId invalide" }, { status: 400 });
}
const tenant = await prisma.tenant.findUnique({
where: { subdomain: normalizedTenantId },
select: { subdomain: true, nom: true, actif: true },
});
if (!tenant || !tenant.actif) {
return NextResponse.json({ error: "Vitrine introuvable" }, { status: 404 });
}
dbTenantSubdomain = tenant.subdomain; // DB-validé
dbTenantNom = tenant.nom ?? "wari.pro";
}
// P0-7 — Rate-limit avant tout work serveur
const keyMin = `magic_link:emit:${normalizedIdentifier}`;
const keyHour = `magic_link:hour:${normalizedIdentifier}`;
const countMin = await redis.incr(keyMin);
if (countMin === 1) await redis.expire(keyMin, RATE_TTL_MIN);
if (countMin > RATE_LIMIT_MIN) {
const ttl = await redis.ttl(keyMin);
return NextResponse.json(
{ error: `Trop de tentatives, réessaie dans ${Math.max(ttl, 1)} secondes` },
{ status: 429 },
);
}
const countHour = await redis.incr(keyHour);
if (countHour === 1) await redis.expire(keyHour, RATE_TTL_HOUR);
if (countHour > RATE_LIMIT_HOUR) {
const ttl = await redis.ttl(keyHour);
const minutes = Math.max(Math.ceil(ttl / 60), 1);
return NextResponse.json(
{ error: `Trop de tentatives, réessaie dans ${minutes} minutes` },
{ status: 429 },
);
}
const existingClient = await prisma.clientAccount.findFirst({
where: identifierType === "email" ? { email: normalizedIdentifier } : { phone: normalizedIdentifier },
});
// Générer OTP 6 chiffres (mobile) + token lien web
const otp = genOtp();
const rawToken = randomUUID();
const hashedToken = createHash("sha256").update(rawToken).digest("hex");
const expiresAt = new Date(Date.now() + TTL * 1000);
// Invalider les anciens tokens en attente
await prisma.magicToken.updateMany({
where: { identifier: normalizedIdentifier, usedAt: null },
data: { usedAt: new Date() },
});
await prisma.magicToken.create({
data: {
token: hashedToken,
clientId: existingClient?.id ?? null,
tenantId: normalizedTenantId,
identifier: normalizedIdentifier,
identifierType,
expiresAt,
},
});
// Stocker OTP dans Redis (TTL = 900s)
await redis.set(`otp:${normalizedIdentifier}`, otp, "EX", TTL);
// Compteur tentatives (pour blocage après 3 erreurs)
await redis.del(`otp_attempts:${normalizedIdentifier}`);
// P0-3 — Utiliser le subdomain DB-validé pour construire l'URL, JAMAIS la valeur user
const tenantBaseUrl = dbTenantSubdomain
? BASE_URL.replace("https://", "https://" + dbTenantSubdomain + ".")
: BASE_URL;
const magicLink = tenantBaseUrl + "/auth/client/verify?token=" + rawToken + (source ? "&source=" + source : "");
if (identifierType === "email") {
try {
await envoyerMagicLink({
identifier: normalizedIdentifier,
magicLink,
otp,
tenantNom: dbTenantNom,
expiresInMinutes: Math.round(TTL / 60),
});
} catch (e) {
captureError(e, {
route: "/api/auth/client/magic-link",
extra: { phase: "envoi_magic_link_email", identifierType, tenantId: normalizedTenantId },
});
console.error("Erreur envoi magic-link email:", e);
}
}
// P0-7 — Réponse uniforme (cacher isNewUser, CWE-204 énumération de comptes)
return NextResponse.json({ ok: true, sentAt: Date.now() });
} catch (error) {
captureError(error, {
route: "/api/auth/client/magic-link",
});
console.error("magic-link error:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}
import { NextRequest, NextResponse } from "next/server";
import { envoyerMagicLink } from "@/lib/email";
import { prisma } from "@/lib/prisma/client";
import { redis } from "@/lib/redis";
import { createHash, randomUUID } from "crypto";
import { captureError } from "@/lib/sentry";
const TTL = parseInt(process.env.MAGIC_LINK_TTL_SECONDS || "900");
const BASE_URL = process.env.MAGIC_LINK_BASE_URL || "http://localhost:3000";
const SUBDOMAIN_REGEX = /^[a-z0-9-]{1,63}$/;
// P0-7 — Rate-limit OTP émission (1/min + 5/h par identifier)
const RATE_LIMIT_MIN = 1;
const RATE_LIMIT_HOUR = 5;
const RATE_TTL_MIN = 60;
const RATE_TTL_HOUR = 3600;
function genOtp(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}
export async function POST(req: NextRequest) {
try {
const { identifier, identifierType, tenantId, source } = await req.json();
if (!identifier || !identifierType || !tenantId) {
return NextResponse.json({ error: "Parametres manquants" }, { status: 400 });
}
if (!["phone", "email"].includes(identifierType)) {
return NextResponse.json({ error: "identifierType invalide" }, { status: 400 });
}
const normalizedIdentifier = identifierType === "email"
? identifier.trim().toLowerCase()
: identifier.trim();
// P0-3 — Validation stricte tenantId + lookup DB obligatoire (open redirect)
const normalizedTenantId = typeof tenantId === "string" ? tenantId.trim().toLowerCase() : "";
if (!normalizedTenantId) {
return NextResponse.json({ error: "tenantId requis" }, { status: 400 });
}
let dbTenantSubdomain: string | null = null;
let dbTenantNom = "wari.pro";
if (normalizedTenantId !== "wari") {
if (!SUBDOMAIN_REGEX.test(normalizedTenantId)) {
return NextResponse.json({ error: "tenantId invalide" }, { status: 400 });
}
const tenant = await prisma.tenant.findUnique({
where: { subdomain: normalizedTenantId },
select: { subdomain: true, nom: true, actif: true },
});
if (!tenant || !tenant.actif) {
return NextResponse.json({ error: "Vitrine introuvable" }, { status: 404 });
}
dbTenantSubdomain = tenant.subdomain; // DB-validé
dbTenantNom = tenant.nom ?? "wari.pro";
}
// P0-7 — Rate-limit avant tout work serveur
const keyMin = `magic_link:emit:${normalizedIdentifier}`;
const keyHour = `magic_link:hour:${normalizedIdentifier}`;
const countMin = await redis.incr(keyMin);
if (countMin === 1) await redis.expire(keyMin, RATE_TTL_MIN);
if (countMin > RATE_LIMIT_MIN) {
const ttl = await redis.ttl(keyMin);
return NextResponse.json(
{ error: `Trop de tentatives, réessaie dans ${Math.max(ttl, 1)} secondes` },
{ status: 429 },
);
}
const countHour = await redis.incr(keyHour);
if (countHour === 1) await redis.expire(keyHour, RATE_TTL_HOUR);
if (countHour > RATE_LIMIT_HOUR) {
const ttl = await redis.ttl(keyHour);
const minutes = Math.max(Math.ceil(ttl / 60), 1);
return NextResponse.json(
{ error: `Trop de tentatives, réessaie dans ${minutes} minutes` },
{ status: 429 },
);
}
const existingClient = await prisma.clientAccount.findFirst({
where: identifierType === "email" ? { email: normalizedIdentifier } : { phone: normalizedIdentifier },
});
// Générer OTP 6 chiffres (mobile) + token lien web
const otp = genOtp();
const rawToken = randomUUID();
const hashedToken = createHash("sha256").update(rawToken).digest("hex");
const expiresAt = new Date(Date.now() + TTL * 1000);
// Invalider les anciens tokens en attente
await prisma.magicToken.updateMany({
where: { identifier: normalizedIdentifier, usedAt: null },
data: { usedAt: new Date() },
});
await prisma.magicToken.create({
data: {
token: hashedToken,
clientId: existingClient?.id ?? null,
tenantId: normalizedTenantId,
identifier: normalizedIdentifier,
identifierType,
expiresAt,
},
});
// Stocker OTP dans Redis (TTL = 900s)
await redis.set(`otp:${normalizedIdentifier}`, otp, "EX", TTL);
// Compteur tentatives (pour blocage après 3 erreurs)
await redis.del(`otp_attempts:${normalizedIdentifier}`);
// P0-3 — Utiliser le subdomain DB-validé pour construire l'URL, JAMAIS la valeur user
const tenantBaseUrl = dbTenantSubdomain
? BASE_URL.replace("https://", "https://" + dbTenantSubdomain + ".")
: BASE_URL;
const magicLink = tenantBaseUrl + "/auth/client/verify?token=" + rawToken + (source ? "&source=" + source : "");
if (identifierType === "email") {
try {
await envoyerMagicLink({
identifier: normalizedIdentifier,
magicLink,
otp,
tenantNom: dbTenantNom,
expiresInMinutes: Math.round(TTL / 60),
});
} catch (e) {
captureError(e, {
route: "/api/auth/client/magic-link",
extra: { phase: "envoi_magic_link_email", identifierType, tenantId: normalizedTenantId },
});
console.error("Erreur envoi magic-link email:", e);
}
}
// P0-7 — Réponse uniforme (cacher isNewUser, CWE-204 énumération de comptes)
return NextResponse.json({ ok: true, sentAt: Date.now() });
} catch (error) {
captureError(error, {
route: "/api/auth/client/magic-link",
});
console.error("magic-link error:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}