SPB Git forge

spb/toveli

Public
14commits 1branches 0releases
1.1 MBsize
maindefault branch
3 h agolast push
TypeScript 50.6% JavaScript 46.1% SQL 2.1% Shell 0.6% CSS 0.5%
6.4 KB · 60 lines typescript
Raw Blame History
1import { and, eq, isNull, sql, lt } from "drizzle-orm";2import { db } from "@/db/client";3import { otpChallenges, users, userProfiles } from "@/db/schema";4import { generateOtp, otpDigest, safeEqualHex, normalizeEmail, ipHash } from "./crypto";5import { sendMail, otpMail } from "./email";6import { hit } from "./ratelimit";7import { audit } from "./audit";89const TTL_MIN = 10, MAX_ATTEMPTS = 5, RESEND_DELAY_SEC = 60;10export type RequestResult = { ok: true; challengeId: string; retryAfterSec: number } | { ok: false; reason: "invalid_email" | "rate_limited" | "cooldown"; retryAfterSec: number };1112/** Demande de code : réponse neutre côté API (n'indique pas si le compte existe). */13export async function requestCode(rawEmail: string, ip: string | null, purpose: "LOGIN" | "EMAIL_CHANGE" = "LOGIN", meta: Record<string, unknown> = {}): Promise<RequestResult> {14  const email = normalizeEmail(rawEmail); if (!email) return { ok: false, reason: "invalid_email", retryAfterSec: 0 };15  const iph = ipHash(ip);16  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)]);17  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) }; }18  const d = db();19  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);20  if (recent) return { ok: false, reason: "cooldown", retryAfterSec: Math.max(1, RESEND_DELAY_SEC - Math.floor((Date.now() - recent.createdAt.getTime()) / 1000)) };21  // Invalide les défis précédents encore ouverts pour cette adresse/finalité22  await d.update(otpChallenges).set({ consumedAt: new Date() }).where(and(eq(otpChallenges.emailNormalized, email), eq(otpChallenges.purpose, purpose), isNull(otpChallenges.consumedAt)));23  const code = generateOtp();24  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 });25  await d.update(otpChallenges).set({ secretDigest: otpDigest(code, ch.id) }).where(eq(otpChallenges.id, ch.id));26  await sendMail(otpMail(email, code));27  await audit("otp.requested", { objectType: "otp_challenge", objectId: ch.id, ipHash: iph, meta: { purpose } });28  if (Math.random() < 0.05) await d.delete(otpChallenges).where(lt(otpChallenges.expiresAt, new Date(Date.now() - 86400000)));29  return { ok: true, challengeId: ch.id, retryAfterSec: RESEND_DELAY_SEC };30}3132export type VerifyResult = { ok: true; userId: string; email: string; created: boolean } | { ok: false; reason: "invalid" | "expired" | "too_many_attempts" | "not_found" | "terms_required" };3334/** Vérification atomique : une seule transaction, verrou de ligne, compteur de tentatives, usage unique. */35export async function verifyCode(challengeId: string, code: string, ip: string | null, acceptTerms: boolean): Promise<VerifyResult> {36  if (!/^\d{6}$/.test(code) || !/^[0-9a-f-]{36}$/.test(challengeId)) return { ok: false, reason: "invalid" };37  const d = db(); const iph = ipHash(ip);38  return d.transaction(async (tx) => {39    const [ch] = await tx.select().from(otpChallenges).where(eq(otpChallenges.id, challengeId)).for("update");40    if (!ch) return { ok: false, reason: "not_found" as const };41    if (ch.consumedAt) { await audit("otp.reuse_blocked", { objectId: ch.id, ipHash: iph }); return { ok: false, reason: "invalid" as const }; }42    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 }; }43    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 }; }44    const good = safeEqualHex(ch.secretDigest, otpDigest(code, ch.id));45    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) }; }46    if (ch.purpose === "LOGIN") {47      const [exists] = await tx.select({ id: users.id }).from(users).where(eq(users.email, ch.emailNormalized)).limit(1);48      if (!exists && !acceptTerms) return { ok: false, reason: "terms_required" as const }; // code correct mais non consommé : l'utilisateur doit accepter les conditions49    }50    await tx.update(otpChallenges).set({ consumedAt: new Date(), attempts: ch.attempts + 1 }).where(eq(otpChallenges.id, ch.id));51    if (ch.purpose !== "LOGIN") return { ok: true as const, userId: String(ch.meta && (ch.meta as { userId?: string }).userId), email: ch.emailNormalized, created: false };52    const [existing] = await tx.select({ id: users.id, status: users.status }).from(users).where(eq(users.email, ch.emailNormalized)).limit(1);53    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 }; }54    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 });55    await tx.insert(userProfiles).values({ userId: u.id });56    await audit("auth.signup", { actorId: u.id, ipHash: iph });57    return { ok: true as const, userId: u.id, email: ch.emailNormalized, created: true };58  });59}60