8158038145
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>
100 lines
3.4 KiB
TypeScript
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;
|
|
}
|