Files
clienthub/src/lib/client-session.ts
T
simone 8158038145 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>
2026-07-29 12:10:33 +02:00

100 lines
3.4 KiB
TypeScript

// 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;
}