feat(auth): gate OTP email sul portale cliente (v2.3 Phases 23-25)
Il portale /client/<slug> era protetto dal solo token in URL: chiunque ricevesse o intercettasse il link entrava, per sempre, senza identificarsi. Ora l'admin registra le email autorizzate per cliente e il cliente si identifica con un codice usa-e-getta prima di vedere qualsiasi dato. - Resend 6.18.1 + src/lib/mailer.ts (Result tipizzato, mai catch silenzioso) - migration 0015 (gia applicata a prod): client_emails, otp_codes, clients.sessions_valid_from. Additiva pura, conteggi verificati pre/post - admin: sezione "Accessi al portale" in /admin/clients/[id] con whitelist e revoca sessioni in blocco - gate: codice 6 cifre CSPRNG, hash SHA-256 (mai il codice in chiaro), TTL 15 min, max 5 tentativi, rate limit su entrambi gli endpoint, risposta identica per email in whitelist e non (no enumeration) - sessione: cookie HMAC per-cliente, 90 giorni, httpOnly/secure/lax Il gate sta in cima alla page, NON nel layout: nell'App Router il segmento page viene renderizzato in parallelo al layout, quindi gattare nel layout nascondeva la dashboard a schermo ma lasciava fasi, task e pagamenti nel payload RSC dell'HTML (46907 byte -> 17594 dopo il fix). Verificato. Verifica: build OK, 9/9 test E2E in locale contro il DB di produzione. NON DEPLOYARE prima di: RESEND_API_KEY+RESEND_FROM su Coolify e whitelist popolata per i 3 clienti reali (oggi vuota) - altrimenti il gate li chiude fuori dal loro portale. Checklist in .planning/STATE.md. SEND-01/02 (invio preventivo via email) rinviati a v2.4 su richiesta. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
// Sessione del portale cliente dopo la verifica OTP (v2.3).
|
||||
//
|
||||
// Stateless e firmata, come la sessione admin: nessuna tabella sessions da
|
||||
// mantenere. Il payload porta client_id + email + istante di emissione, il tutto
|
||||
// firmato HMAC-SHA256 con NEXTAUTH_SECRET — non falsificabile senza il segreto.
|
||||
//
|
||||
// La revoca lato admin non cancella cookie (non si può): alza
|
||||
// clients.sessions_valid_from, e il gate scarta ogni sessione emessa prima.
|
||||
// Per questo `iat` fa parte del payload firmato.
|
||||
//
|
||||
// Web Crypto, come admin-gate.ts e otp.ts: stesso modulo in edge e node.
|
||||
|
||||
export const SESSION_MAX_AGE_SECONDS = 90 * 24 * 60 * 60; // 90 giorni
|
||||
|
||||
export type ClientSession = { clientId: string; email: string; iat: number };
|
||||
|
||||
/**
|
||||
* Un cookie per cliente: aprire il portale del cliente B non sloggia dal
|
||||
* portale del cliente A. clientId è un nanoid — caratteri già validi in un
|
||||
* nome di cookie.
|
||||
*/
|
||||
export function sessionCookieName(clientId: string): string {
|
||||
return `ch_sess_${clientId}`;
|
||||
}
|
||||
|
||||
function b64urlEncode(s: string): string {
|
||||
const bytes = new TextEncoder().encode(s);
|
||||
let binary = "";
|
||||
for (const b of bytes) binary += String.fromCharCode(b);
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function b64urlDecode(s: string): string {
|
||||
const binary = atob(s.replace(/-/g, "+").replace(/_/g, "/"));
|
||||
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
async function sign(payload: string): Promise<string> {
|
||||
const secret = process.env.NEXTAUTH_SECRET;
|
||||
if (!secret) throw new Error("NEXTAUTH_SECRET must be set");
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(`${secret}:client-session:v1`),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
|
||||
return Array.from(new Uint8Array(sig))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Confronto a tempo costante — evita di far trapelare la firma dai tempi di risposta. */
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
export async function createSessionValue(clientId: string, email: string): Promise<string> {
|
||||
const payload = b64urlEncode(
|
||||
JSON.stringify({ clientId, email, iat: Date.now() } satisfies ClientSession)
|
||||
);
|
||||
return `${payload}.${await sign(payload)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ritorna la sessione solo se: la firma è valida, il cookie appartiene a QUESTO
|
||||
* cliente, e non ha superato la durata massima. Il confronto con
|
||||
* sessions_valid_from (revoca admin) resta al chiamante, che ha già il record cliente.
|
||||
*/
|
||||
export async function verifySessionValue(
|
||||
value: string | undefined,
|
||||
clientId: string
|
||||
): Promise<ClientSession | null> {
|
||||
if (!value) return null;
|
||||
|
||||
const [payload, signature] = value.split(".");
|
||||
if (!payload || !signature) return null;
|
||||
|
||||
if (!safeEqual(await sign(payload), signature)) return null;
|
||||
|
||||
let session: ClientSession;
|
||||
try {
|
||||
session = JSON.parse(b64urlDecode(payload));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (session.clientId !== clientId) return null;
|
||||
if (typeof session.iat !== "number") return null;
|
||||
if (Date.now() - session.iat > SESSION_MAX_AGE_SECONDS * 1000) return null;
|
||||
|
||||
return session;
|
||||
}
|
||||
Reference in New Issue
Block a user