// Emissione e verifica dei codici OTP che aprono il portale cliente (v2.3). // // Il codice in chiaro esiste solo nell'email: in DB va l'hash. Chi legge otp_codes // (backup, dump, accesso al Postgres) non ottiene un codice utilizzabile. // // Web Crypto invece di node:crypto — stesso motivo di src/lib/admin-gate.ts: // il modulo deve funzionare identico in edge e node runtime. import { customAlphabet } from "nanoid"; import { and, desc, eq, isNull, sql } from "drizzle-orm"; import { db } from "@/db"; import { client_emails, otp_codes } from "@/db/schema"; import { safeEqual } from "@/lib/admin-gate"; export const OTP_TTL_MS = 15 * 60 * 1000; export const OTP_MAX_ATTEMPTS = 5; // 6 cifre. customAlphabet è CSPRNG-backed, mai Math.random(). // Lo spazio è piccolo (1M) di proposito — è compensato da TTL 15 min, // max 5 tentativi per codice e rate limit sull'endpoint di verifica. const randomCode = customAlphabet("0123456789", 6); async function hashCode(code: string, clientId: string): Promise { const secret = process.env.NEXTAUTH_SECRET; if (!secret) throw new Error("NEXTAUTH_SECRET must be set"); // clientId nel materiale: lo stesso codice per due clienti dà hash diversi, // quindi un hash rubato non è riutilizzabile altrove. const data = new TextEncoder().encode(`${secret}:otp:v1:${clientId}:${code}`); const digest = await crypto.subtle.digest("SHA-256", data); return Array.from(new Uint8Array(digest)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } /** L'email è nella whitelist di questo cliente? Confronto case-insensitive. */ export async function isEmailWhitelisted(clientId: string, email: string): Promise { const rows = await db .select({ id: client_emails.id }) .from(client_emails) .where( and( eq(client_emails.client_id, clientId), sql`lower(${client_emails.email}) = ${email.trim().toLowerCase()}` ) ) .limit(1); return rows.length > 0; } /** * Genera un codice, ne salva l'hash e lo restituisce in chiaro al chiamante * (che lo manda via email e poi lo dimentica). * I codici precedenti ancora aperti per la stessa coppia vengono consumati: * richiedere un nuovo codice invalida il vecchio. */ export async function issueOtp(clientId: string, email: string): Promise { const normalized = email.trim().toLowerCase(); await db .update(otp_codes) .set({ consumed_at: new Date() }) .where( and( eq(otp_codes.client_id, clientId), sql`lower(${otp_codes.email}) = ${normalized}`, isNull(otp_codes.consumed_at) ) ); const code = randomCode(); await db.insert(otp_codes).values({ client_id: clientId, email: normalized, code_hash: await hashCode(code, clientId), expires_at: new Date(Date.now() + OTP_TTL_MS), }); return code; } export type VerifyResult = { ok: true } | { ok: false; reason: "invalid" | "expired" | "attempts" }; /** * Verifica il codice contro l'ultimo emesso per (cliente, email). * In caso di successo lo marca consumato — un codice vale una volta sola. */ export async function verifyOtp( clientId: string, email: string, code: string ): Promise { const normalized = email.trim().toLowerCase(); const rows = await db .select() .from(otp_codes) .where( and( eq(otp_codes.client_id, clientId), sql`lower(${otp_codes.email}) = ${normalized}`, isNull(otp_codes.consumed_at) ) ) .orderBy(desc(otp_codes.created_at)) .limit(1); const row = rows[0]; if (!row) return { ok: false, reason: "invalid" }; if (row.expires_at.getTime() < Date.now()) return { ok: false, reason: "expired" }; if (row.attempts >= OTP_MAX_ATTEMPTS) { // Bruciato: consumalo, così il prossimo tentativo non riparte da questo. await db.update(otp_codes).set({ consumed_at: new Date() }).where(eq(otp_codes.id, row.id)); return { ok: false, reason: "attempts" }; } const candidate = await hashCode(code.trim(), clientId); if (!safeEqual(candidate, row.code_hash)) { await db .update(otp_codes) .set({ attempts: row.attempts + 1 }) .where(eq(otp_codes.id, row.id)); return { ok: false, reason: "invalid" }; } await db.update(otp_codes).set({ consumed_at: new Date() }).where(eq(otp_codes.id, row.id)); return { ok: true }; }