import { and, eq, isNull, sql, lt } from "drizzle-orm"; import { db } from "@/db/client"; import { otpChallenges, users, userProfiles } from "@/db/schema"; import { generateOtp, otpDigest, safeEqualHex, normalizeEmail, ipHash } from "./crypto"; import { sendMail, otpMail } from "./email"; import { hit } from "./ratelimit"; import { audit } from "./audit"; const TTL_MIN = 10, MAX_ATTEMPTS = 5, RESEND_DELAY_SEC = 60; export type RequestResult = { ok: true; challengeId: string; retryAfterSec: number } | { ok: false; reason: "invalid_email" | "rate_limited" | "cooldown"; retryAfterSec: number }; /** Demande de code : réponse neutre côté API (n'indique pas si le compte existe). */ export async function requestCode(rawEmail: string, ip: string | null, purpose: "LOGIN" | "EMAIL_CHANGE" = "LOGIN", meta: Record = {}): Promise { const email = normalizeEmail(rawEmail); if (!email) return { ok: false, reason: "invalid_email", retryAfterSec: 0 }; const iph = ipHash(ip); const [byIp, byEmailHour, byEmailDay, byIpDay] = await Promise.all([hit(`otp:ip:${iph}`, 10, 600), hit(`otp:email:${email}`, 5, 3600), hit(`otp:email:day:${email}`, 12, 86400), hit(`otp:ip:day:${iph}`, 60, 86400)]); if (!byIp.ok || !byEmailHour.ok || !byEmailDay.ok || !byIpDay.ok) { await audit("otp.rate_limited", { ipHash: iph }); return { ok: false, reason: "rate_limited", retryAfterSec: Math.max(byIp.retryAfterSec, byEmailHour.retryAfterSec, 60) }; } const d = db(); const [recent] = await d.select({ createdAt: otpChallenges.createdAt }).from(otpChallenges).where(and(eq(otpChallenges.emailNormalized, email), eq(otpChallenges.purpose, purpose), sql`${otpChallenges.createdAt} > now() - interval '${sql.raw(String(RESEND_DELAY_SEC))} seconds'`)).orderBy(sql`${otpChallenges.createdAt} desc`).limit(1); if (recent) return { ok: false, reason: "cooldown", retryAfterSec: Math.max(1, RESEND_DELAY_SEC - Math.floor((Date.now() - recent.createdAt.getTime()) / 1000)) }; // Invalide les défis précédents encore ouverts pour cette adresse/finalité await d.update(otpChallenges).set({ consumedAt: new Date() }).where(and(eq(otpChallenges.emailNormalized, email), eq(otpChallenges.purpose, purpose), isNull(otpChallenges.consumedAt))); const code = generateOtp(); const [ch] = await d.insert(otpChallenges).values({ emailNormalized: email, purpose, secretDigest: "pending", expiresAt: new Date(Date.now() + TTL_MIN * 60000), requestedIpHash: iph, meta }).returning({ id: otpChallenges.id }); await d.update(otpChallenges).set({ secretDigest: otpDigest(code, ch.id) }).where(eq(otpChallenges.id, ch.id)); await sendMail(otpMail(email, code)); await audit("otp.requested", { objectType: "otp_challenge", objectId: ch.id, ipHash: iph, meta: { purpose } }); if (Math.random() < 0.05) await d.delete(otpChallenges).where(lt(otpChallenges.expiresAt, new Date(Date.now() - 86400000))); return { ok: true, challengeId: ch.id, retryAfterSec: RESEND_DELAY_SEC }; } export type VerifyResult = { ok: true; userId: string; email: string; created: boolean } | { ok: false; reason: "invalid" | "expired" | "too_many_attempts" | "not_found" | "terms_required" }; /** Vérification atomique : une seule transaction, verrou de ligne, compteur de tentatives, usage unique. */ export async function verifyCode(challengeId: string, code: string, ip: string | null, acceptTerms: boolean): Promise { if (!/^\d{6}$/.test(code) || !/^[0-9a-f-]{36}$/.test(challengeId)) return { ok: false, reason: "invalid" }; const d = db(); const iph = ipHash(ip); return d.transaction(async (tx) => { const [ch] = await tx.select().from(otpChallenges).where(eq(otpChallenges.id, challengeId)).for("update"); if (!ch) return { ok: false, reason: "not_found" as const }; if (ch.consumedAt) { await audit("otp.reuse_blocked", { objectId: ch.id, ipHash: iph }); return { ok: false, reason: "invalid" as const }; } if (ch.expiresAt.getTime() < Date.now()) { await tx.update(otpChallenges).set({ consumedAt: new Date() }).where(eq(otpChallenges.id, ch.id)); return { ok: false, reason: "expired" as const }; } if (ch.attempts >= MAX_ATTEMPTS) { await tx.update(otpChallenges).set({ consumedAt: new Date() }).where(eq(otpChallenges.id, ch.id)); await audit("otp.too_many_attempts", { objectId: ch.id, ipHash: iph }); return { ok: false, reason: "too_many_attempts" as const }; } const good = safeEqualHex(ch.secretDigest, otpDigest(code, ch.id)); if (!good) { const attempts = ch.attempts + 1; await tx.update(otpChallenges).set({ attempts, consumedAt: attempts >= MAX_ATTEMPTS ? new Date() : null }).where(eq(otpChallenges.id, ch.id)); await audit("otp.failed", { objectId: ch.id, ipHash: iph, meta: { attempts } }); return { ok: false, reason: attempts >= MAX_ATTEMPTS ? ("too_many_attempts" as const) : ("invalid" as const) }; } if (ch.purpose === "LOGIN") { const [exists] = await tx.select({ id: users.id }).from(users).where(eq(users.email, ch.emailNormalized)).limit(1); if (!exists && !acceptTerms) return { ok: false, reason: "terms_required" as const }; // code correct mais non consommé : l'utilisateur doit accepter les conditions } await tx.update(otpChallenges).set({ consumedAt: new Date(), attempts: ch.attempts + 1 }).where(eq(otpChallenges.id, ch.id)); if (ch.purpose !== "LOGIN") return { ok: true as const, userId: String(ch.meta && (ch.meta as { userId?: string }).userId), email: ch.emailNormalized, created: false }; const [existing] = await tx.select({ id: users.id, status: users.status }).from(users).where(eq(users.email, ch.emailNormalized)).limit(1); if (existing) { if (existing.status !== "ACTIVE") return { ok: false, reason: "invalid" as const }; await tx.update(users).set({ emailVerifiedAt: sql`coalesce(${users.emailVerifiedAt}, now())`, updatedAt: new Date() }).where(eq(users.id, existing.id)); await audit("auth.login", { actorId: existing.id, ipHash: iph }); return { ok: true as const, userId: existing.id, email: ch.emailNormalized, created: false }; } const [u] = await tx.insert(users).values({ email: ch.emailNormalized, emailVerifiedAt: new Date(), prefs: { termsAcceptedAt: new Date().toISOString(), notifications: { inquiries: true, viewings: true, alerts: true, marketing: false } } }).returning({ id: users.id }); await tx.insert(userProfiles).values({ userId: u.id }); await audit("auth.signup", { actorId: u.id, ipHash: iph }); return { ok: true as const, userId: u.id, email: ch.emailNormalized, created: true }; }); }