04 - Autentificare sigură: sesiune, cookie-uri și identitate modernă
Autentificarea și poarta de intrare a aplicației dvs. Dacă este vulnerabil, orice alte măsuri de securitate
poate fi ocolită. În 2024, conform Verizon DBIR, 74% dintre încălcările de date au implicat acreditări
compromisă sau atacuri legate de identitate. Cu toate acestea, majoritatea aplicațiilor încă implementează
Model de autentificare 2010: sesiuni pe server cu module cookie neprotejate, parole cu text simplu în jurnale,
fără MFA și jetoane JWT stocate localStorage.
Peisajul de autentificare în 2025 s-a schimbat radical. Cele mai bune practici consolidate OAuth 2.1 securitate făcând PKCE obligatoriu pentru toți clienții. Cheile de acces bazate pe WebAuthn se înlocuiesc parolele tradiționale, Apple, Google și Microsoft promovându-le ca standard. NIST SP 800-63B orientări revizuite pentru parole prin eliminarea cerințelor de rotație periodică. Acest articol vă va ajuta vă ghidează prin întreaga suprafață de atac de autentificare cu cod practic, capcane comune și o listă de verificare a securității pentru aplicațiile Node.js.
Ce vei învăța
- Gestionare securizată a sesiunii: cookie-uri HttpOnly, Secure, SameSite și prevenirea fixării sesiunii
- Cele mai bune practici JWT și cele 5 greșeli fatale care fac ca jetoanele să fie nesigure
- OAuth 2.1 cu PKCE: flux de coduri de autorizare pentru SPA-uri și aplicații mobile
- WebAuthn și chei de acces: implementare practică cu SimpleWebAuthn
- MFA cu TOTP: integrare cu Google Authenticator și Authy prin otpauth
- RBAC, ABAC și ReBAC: modele de autorizare pentru aplicații moderne
- Middleware complet de autentificare Express.js cu limitare a ratei și jurnal de audit
- Lista de verificare OWASP A07:2021 (Eșecuri de identificare și autentificare)
Managementul sesiunii: elementele fundamentale
Managementul sesiunii și mecanismul prin care serverul „își amintește” un utilizator autentificat Solicitări HTTP fără stat. O sesiune sigură necesită patru proprietăți fundamentale: identificatori imprevizibili, transmisie sigură, expirare controlată e invalidarea corectă la deconectare.
Principalul vector de atac este fixarea sesiunii: Atacatorul oferă victimei
un ID de sesiune cunoscut înainte de a vă conecta, apoi reutilizați același ID după autentificare pentru a uzurpa identitatea
utilizatorul. Contramăsura este de a regenera întotdeauna ID-ul sesiunii după conectare. Un al doilea vector este
deturnarea sesiunii prin XSS, atenuat de cookie-uri marcate HttpOnly. Un al treilea
vectori sunt sesiuni orfane: Sesiunile rămase nu au fost niciodată invalidate de partea serverului
activ chiar și după ce clientul a șters cookie-ul, permițând reluarea ID-urilor de sesiune furate.
// Session management sicuro con express-session
// npm install express-session connect-pg-simple @types/express-session
import session from 'express-session';
import pgSession from 'connect-pg-simple';
import crypto from 'crypto';
const PgSession = pgSession(session);
// Configurazione sicura della sessione
app.use(session({
// Store PostgreSQL invece di MemoryStore (non usare in produzione)
store: new PgSession({
pool: dbPool,
tableName: 'user_sessions',
createTableIfMissing: true,
}),
// Secret forte: usa variabile d'ambiente, mai hardcoded
secret: process.env.SESSION_SECRET!, // min 32 caratteri random
// Non salvare sessioni non modificate
resave: false,
// Non creare sessioni vuote per utenti non autenticati
saveUninitialized: false,
// Configurazione cookie sicura
cookie: {
httpOnly: true, // Blocca accesso JavaScript (XSS protection)
secure: true, // Solo HTTPS (usa false solo in sviluppo locale)
sameSite: 'strict', // Blocca CSRF cross-origin
maxAge: 8 * 60 * 60 * 1000, // 8 ore in millisecondi
domain: process.env.COOKIE_DOMAIN, // Limita al dominio specifico
path: '/',
},
// Nome custom invece di 'connect.sid' (riduce fingerprinting)
name: '__session',
// Genera ID sicuro con crypto.randomBytes
genid: () => crypto.randomBytes(32).toString('hex'),
}));
// CRITICO: Rigenera session ID dopo login (previene session fixation)
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await authenticateUser(username, password);
if (!user) {
return res.status(401).json({ error: 'Credenziali non valide' });
}
// CRITICO: rigenerare SEMPRE il session ID dopo autenticazione
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Session error' });
req.session.userId = user.id;
req.session.userRole = user.role;
req.session.loginTime = Date.now();
req.session.save((err) => {
if (err) return res.status(500).json({ error: 'Session save error' });
res.json({ success: true });
});
});
});
// Logout sicuro: distruggi sessione lato server
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) return res.status(500).json({ error: 'Logout error' });
// Elimina il cookie dal browser
res.clearCookie('__session', {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
res.json({ success: true });
});
});
Anti-Pattern: MemoryStore în producție
Nu utilizați niciodată MemoryStore implicit al sesiunii expres în producție.
Provoacă scurgeri de memorie, nu se scalează pe orizontală (sesiuni pierdute la repornirea unui nod),
și nu persistă sesiunile între implementări. Utilizați întotdeauna un magazin extern: Redis
(connect-redis), PostgreSQL (connect-pg-simple),
sau MongoDB (connect-mongo). Redis este alegerea preferată pentru performanță
TTL optim și automat al cheilor expirate.
Securitatea cookie-urilor: steaguri care contează
Cookie-urile de sesiune trebuie configurate cu cel puțin trei atribute de securitate. Toată lumea atenuează o categorie specifică de atacuri. Combinația corectă este HttpOnly + Secure + SameSite, configurate împreună pentru protecție stratificată:
| Atribut | Valoare recomandată | Amenințare atenuată |
|---|---|---|
HttpOnly |
true |
XSS: JavaScript nu poate citi cookie-ul |
Secure |
true |
MITM: cookie transmis numai prin HTTPS |
SameSite |
Strict o Lax |
CSRF: Blocați trimiterea neautorizată cu origini încrucișate |
MaxAge |
8-24 ore pentru sesiunile normale | Sesiuni orfane: expirare automată în partea browserului |
Domain |
Specific domeniului, fără caractere metalice | Scurgerea cookie-urilor pe subdomeniile compromise |
Alegerea între SameSite=Strict e SameSite=Lax Depinde de cazul de utilizare.
Strict oferă protecție maximă, dar blochează cookie-urile chiar și atunci când utilizatorul navighează
către site-ul dvs. dintr-un link extern (de exemplu, e-mail sau alt site), provocând conectarea de fiecare dată a unei redirecționări.
Lax permite cookie-ul în navigarea de nivel superior (faceți clic pe link), dar îl blochează pentru
POST/PUT/DELETE solicitări de origine încrucișată, oferind un echilibru bun între securitate și utilizare.
Pentru API-urile utilizate de SPA pe un domeniu diferit, utilizați SameSite=None; Secure cu protectie
CSRF prin token în antet.
Cele mai bune practici JWT și cele 5 greșeli fatale
Tokenurile web JSON sunt un instrument puternic pentru autentificarea fără stat în API-uri, dar acestea implementare incorectă și printre cele mai comune cauze ale vulnerabilităților critice. NIST și OWASP identificați cinci erori fatale care anulează complet securitatea JWT-urilor, fiecare cu consecinţe devastatoare dacă sunt exploatate în producţie.
Cele 5 greșeli fatale ale lui JWT
- Greșeala #1 - algoritmul „niciunul”: Unele servere acceptă jetoane cu
"alg": "none", fără semnătură. Un atacator poate falsifica orice sarcină utilă. - Greșeala nr. 2 - Confuzia de algoritm: Un server RS256 care acceptă HS256 permite atacatorului să semneze cu cheia publică (cunoscută).
- Eroare #3 - JWT în localStorage: Accesibil din orice script JS. Un XSS pe orice dependență npm poate fura jetonul.
- Greșeala #4 - Jetoanele nu expiră: Un token de acces care nu expiră niciodată nu poate fi revocat dacă este compromis.
- Greșeala #5 - Secret slab pentru HS256: Secretele scurte sau previzibile sunt vulnerabile la atacurile de dicționar offline sau de forță brută.
// JWT sicuro con jsonwebtoken - Node.js TypeScript
// npm install jsonwebtoken @types/jsonwebtoken
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
const JWT_CONFIG = {
// Usa RS256 o EdDSA per produzione, non HS256 se il secret può trapelare
// HS256 va bene solo se il secret e condiviso tra servizi fidati interni
algorithm: 'RS256' as const,
accessTokenExpiry: '15m', // Token di accesso: vita breve (15 minuti)
refreshTokenExpiry: '7d', // Refresh token: vita lunga, salvato in DB
};
// Carica chiave privata RSA da variabile d'ambiente
const PRIVATE_KEY = process.env.JWT_PRIVATE_KEY!;
const PUBLIC_KEY = process.env.JWT_PUBLIC_KEY!;
// Genera access token
function generateAccessToken(userId: string, role: string): string {
return jwt.sign(
{
sub: userId, // Subject: ID utente
role: role,
iat: Math.floor(Date.now() / 1000),
jti: crypto.randomUUID(), // JWT ID univoco (per blacklist/revoca)
},
PRIVATE_KEY,
{
algorithm: JWT_CONFIG.algorithm,
expiresIn: JWT_CONFIG.accessTokenExpiry,
issuer: 'https://auth.myapp.com',
audience: 'https://api.myapp.com',
}
);
}
// Verifica token - SPECIFICA SEMPRE l'algoritmo atteso
function verifyAccessToken(token: string): jwt.JwtPayload {
try {
return jwt.verify(token, PUBLIC_KEY, {
// CRITICO: whitelist di algoritmi, mai lasciare aperto o accettare "none"
algorithms: ['RS256'],
issuer: 'https://auth.myapp.com',
audience: 'https://api.myapp.com',
}) as jwt.JwtPayload;
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
throw new Error('TOKEN_EXPIRED');
}
if (err instanceof jwt.JsonWebTokenError) {
throw new Error('TOKEN_INVALID');
}
throw err;
}
}
// Refresh token rotation: ogni uso genera un nuovo refresh token
// Salva il refresh token nel DB con hash bcrypt (non in chiaro)
import bcrypt from 'bcrypt';
async function storeRefreshToken(
userId: string,
token: string
): Promise<void> {
const tokenHash = await bcrypt.hash(token, 12);
await db.query(
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at, created_at)
VALUES ($1, $2, NOW() + INTERVAL '7 days', NOW())`,
[userId, tokenHash]
);
}
// Middleware di autenticazione Express
export const authenticateJWT = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
// Leggi token da cookie HttpOnly (preferibile) o Authorization header
// EVITA localStorage: vulnerabile a XSS
const token = req.cookies['access_token'] ?? extractBearerToken(req);
if (!token) {
res.status(401).json({ error: 'Authentication required' });
return;
}
try {
const payload = verifyAccessToken(token);
req.user = { id: payload.sub!, role: payload.role };
next();
} catch (err) {
if ((err as Error).message === 'TOKEN_EXPIRED') {
res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
} else {
res.status(401).json({ error: 'Invalid token' });
}
}
};
function extractBearerToken(req: Request): string | null {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
return authHeader.slice(7);
}
return null;
}
Stocare JWT: Cookie HttpOnly vs localStorage
Multe tutoriale recomandă salvarea JWT în localStorage deoarece este simplu de utilizat
cu SPA-uri. Nu faceți niciodată acest lucru pentru jetoanele de autentificare. Local Stocare și accesibil
din orice script JavaScript care rulează pe pagină, inclusiv biblioteci terță parte compromise.
Un atac XSS asupra oricărei dependențe npm poate fura în tăcere toate jetoanele.
Soluția corectă este utilizarea cookie HttpOnly + Secure + SameSite=Strict:
inaccesibil de JavaScript, transmis numai prin HTTPS și cu protecție CSRF încorporată.
Pentru SPA-urile care trebuie să facă solicitări API de origine încrucișată, utilizați SameSite=None; Secure
cu token CSRF în antetul cererii.
OAuth 2.1 cu PKCE: Secure Federated Authentication
OAuth 2.1 (schița RFC, consolidată în 2024) unifică cele mai bune practici de securitate acumulate de-a lungul anilor de implementare OAuth 2.0, randare Este necesar PKCE (Proof Key for Code Exchange). pentru toți clienții, inclusiv clienții confidențiali cu secrete ale clienților. De asemenea, elimină fluxul implicit și acordarea parolei, care sunt considerate în mod inerent nesigure și depreciate.
Fluxul codului de autorizare cu PKCE funcționează astfel: clientul generează un cod_verifier aleatoriu (43-128 de caractere), calculează code_challenge cum ar fi SHA-256 Base64URL, trimite provocarea către serverul de autorizare, iar după primirea codului de autorizare, îl schimbă cu jetonul prin prezentarea verificatorului original. Chiar dacă un atacator interceptează codul în timpul redirecționarea, nu o poate schimba fără verificatorul care rămâne secret în client.
// OAuth 2.1 PKCE flow - Client SPA TypeScript
interface PKCEChallenge {
codeVerifier: string;
codeChallenge: string;
state: string;
}
// Genera PKCE challenge e state anti-CSRF
async function generatePKCEChallenge(): Promise<PKCEChallenge> {
// code_verifier: stringa random 64 caratteri (range accettato: 43-128)
const codeVerifier = generateRandomString(64);
// code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await window.crypto.subtle.digest('SHA-256', data);
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
// state: token anti-CSRF univoco per questa richiesta di autorizzazione
const state = generateRandomString(32);
return { codeVerifier, codeChallenge, state };
}
function generateRandomString(length: number): string {
const array = new Uint8Array(length);
window.crypto.getRandomValues(array);
return Array.from(array, (byte) =>
byte.toString(36).padStart(2, '0')
).join('').slice(0, length);
}
// Avvia il flusso OAuth 2.1 con redirect all'authorization server
async function startOAuthFlow(): Promise<void> {
const { codeVerifier, codeChallenge, state } = await generatePKCEChallenge();
// Salva temporaneamente in sessionStorage (non localStorage)
// sessionStorage viene cancellato alla chiusura del tab: meno rischio di leak
sessionStorage.setItem('pkce_verifier', codeVerifier);
sessionStorage.setItem('oauth_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env['VITE_OAUTH_CLIENT_ID']!,
redirect_uri: `${window.location.origin}/auth/callback`,
scope: 'openid profile email',
code_challenge: codeChallenge,
code_challenge_method: 'S256', // Sempre S256, mai 'plain'
state: state,
});
window.location.href = `${AUTH_SERVER_URL}/authorize?${params}`;
}
// Callback handler: scambia authorization code con access token
async function handleOAuthCallback(): Promise<void> {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const returnedState = params.get('state');
const error = params.get('error');
if (error) {
throw new Error(`OAuth error: ${error}: ${params.get('error_description')}`);
}
// Verifica state anti-CSRF prima di procedere
const savedState = sessionStorage.getItem('oauth_state');
if (!code || returnedState !== savedState) {
throw new Error('Invalid OAuth callback: state mismatch (possibile attacco CSRF)');
}
const codeVerifier = sessionStorage.getItem('pkce_verifier');
if (!codeVerifier) {
throw new Error('Missing PKCE verifier');
}
// Pulizia immediata dei dati temporanei
sessionStorage.removeItem('pkce_verifier');
sessionStorage.removeItem('oauth_state');
// Scambia authorization code con access token
const response = await fetch(`${AUTH_SERVER_URL}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: process.env['VITE_OAUTH_CLIENT_ID']!,
redirect_uri: `${window.location.origin}/auth/callback`,
code: code,
code_verifier: codeVerifier, // Prova che siamo noi i legittimi richiedenti
}),
});
if (!response.ok) {
const body = await response.json();
throw new Error(`Token exchange failed: ${body.error}`);
}
const tokens = await response.json();
// Passa i token al backend via endpoint sicuro per setting cookie HttpOnly
await securelyStoreTokens(tokens);
}
WebAuthn și chei de acces: autentificare fără parolă
Cheile de acces bazate pe WebAuthn (Web Authentication API, standard W3C) reprezintă schimbarea cel mai semnificativ în autentificare din ultimii douăzeci de ani. Spre deosebire de parole, chei de acces folosesc criptografia cu cheie publică asimetrică: cheia privată rămâne pe dispozitivul utilizatorului protejat de codul biometric sau PIN al dispozitivului, în timp ce serverul cunoaște doar cheia publică.
Rezultatul este unul rezistență absolută la phishing (cheia este criptografic conectat la domeniul inițial), fără parole de furat în încălcări ale bazei de date și cu UX superioară Face ID sau Touch ID în loc de parolă + 2FA. În 2025, dincolo 15 miliarde de conturi suport cheile de acces. Biblioteca SimpleWebAuthn simplifică enorm implementarea.
// WebAuthn / Passkeys con SimpleWebAuthn - Node.js Backend
// npm install @simplewebauthn/server @simplewebauthn/browser @simplewebauthn/types
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import type {
RegistrationResponseJSON,
AuthenticationResponseJSON,
} from '@simplewebauthn/types';
const RP_NAME = 'MyApp';
const RP_ID = process.env.RP_ID ?? 'myapp.com'; // Il tuo dominio (no https://)
const ORIGIN = `https://${RP_ID}`;
// STEP 1: Registrazione - genera challenge per il client
app.post('/auth/webauthn/register/start', requireAuth, async (req, res) => {
const user = req.user!;
// Recupera passkeys esistenti per escluderle (no duplicati)
const existingPasskeys = await getPasskeysByUser(user.id);
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userName: user.email,
userDisplayName: user.name,
excludeCredentials: existingPasskeys.map((pk) => ({
id: pk.credentialId,
})),
authenticatorSelection: {
// 'platform': usa Face ID/Touch ID/Windows Hello (passkey nativa)
// 'cross-platform': usa YubiKey/chiavi hardware esterne
authenticatorAttachment: 'platform',
residentKey: 'required', // Passkey discoverable: login senza username
userVerification: 'required', // Richiedi biometrico o PIN obbligatoriamente
},
attestationType: 'none', // 'direct' per scenari enterprise con audit
});
// Salva challenge in sessione (valido per una sola verifica, poi cancellato)
req.session.currentChallenge = options.challenge;
res.json(options);
});
// STEP 2: Registrazione - verifica risposta del client
app.post('/auth/webauthn/register/verify', requireAuth, async (req, res) => {
const body: RegistrationResponseJSON = req.body;
const expectedChallenge = req.session.currentChallenge;
if (!expectedChallenge) {
return res.status(400).json({ error: 'No challenge in session' });
}
try {
const { verified, registrationInfo } = await verifyRegistrationResponse({
response: body,
expectedChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
requireUserVerification: true,
});
if (!verified || !registrationInfo) {
return res.status(400).json({ error: 'Verification failed' });
}
// Salva le credenziali nel database
await savePasskey({
userId: req.user!.id,
credentialId: registrationInfo.credential.id,
publicKey: Buffer.from(registrationInfo.credential.publicKey),
counter: registrationInfo.credential.counter,
deviceType: registrationInfo.credentialDeviceType,
backedUp: registrationInfo.credentialBackedUp,
});
req.session.currentChallenge = undefined;
res.json({ verified: true });
} catch (err) {
res.status(400).json({ error: 'Registration verification failed' });
}
});
// STEP 3: Login con passkey - genera challenge di autenticazione
app.post('/auth/webauthn/authenticate/start', async (req, res) => {
const { email } = req.body;
const user = await getUserByEmail(email);
if (!user) {
// Non rivelare se l'utente esiste (user enumeration prevention)
return res.status(400).json({ error: 'Authentication failed' });
}
const passkeys = await getPasskeysByUser(user.id);
const options = await generateAuthenticationOptions({
rpID: RP_ID,
allowCredentials: passkeys.map((pk) => ({ id: pk.credentialId })),
userVerification: 'required',
});
req.session.currentChallenge = options.challenge;
req.session.pendingUserId = user.id;
res.json(options);
});
// STEP 4: Verifica autenticazione e crea sessione
app.post('/auth/webauthn/authenticate/verify', async (req, res) => {
const body: AuthenticationResponseJSON = req.body;
const expectedChallenge = req.session.currentChallenge;
const userId = req.session.pendingUserId;
if (!expectedChallenge || !userId) {
return res.status(400).json({ error: 'Invalid session state' });
}
const passkey = await getPasskeyByCredentialId(body.id);
if (!passkey || passkey.userId !== userId) {
return res.status(400).json({ error: 'Passkey not found' });
}
try {
const { verified, authenticationInfo } = await verifyAuthenticationResponse({
response: body,
expectedChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
credential: {
id: passkey.credentialId,
publicKey: passkey.publicKey,
counter: passkey.counter, // Verifica replay attack tramite counter crescente
},
});
if (!verified) {
return res.status(401).json({ error: 'Authentication failed' });
}
// Aggiorna counter per replay attack prevention
await updatePasskeyCounter(passkey.credentialId, authenticationInfo.newCounter);
// Crea sessione autenticata rigenerando il session ID
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Session error' });
req.session.userId = userId;
req.session.currentChallenge = undefined;
req.session.pendingUserId = undefined;
res.json({ verified: true });
});
} catch (err) {
res.status(401).json({ error: 'Authentication verification failed' });
}
});
MFA și TOTP: al doilea factor
Autentificarea cu mai mulți factori (MFA) este cea mai eficientă măsură împotriva umplerii acreditărilor și phishing clasic bazat pe parole. Potrivit Microsoft, MFA blochează 99,9% din atacuri automate asupra conturilor. TOTP (Parolă unică bazată pe timp, RFC 6238) și iată standard de facto pentru software cu factor al doilea, compatibil cu Google Authenticator, Authy, 1Parolă și orice aplicație compatibilă TOTP.
Un aspect critic adesea trecut cu vederea: fluxul AMF trebuie să fie atomic și nu poate fi ocolit.
Nu creați niciodată o sesiune parțial autentificată după primul factor care vă permite să faceți acest lucru
orice operatie. În schimb, utilizați o sesiune temporară cu un steag mfaPending: true,
și acordați acces complet numai după ce al doilea factor este verificat.
// MFA TOTP implementation - Node.js
// npm install otpauth qrcode
// NOTA: speakeasy non e più mantenuto dal 2017, usa 'otpauth' invece
import * as OTPAuth from 'otpauth';
import QRCode from 'qrcode';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
// STEP 1: Abilita 2FA - genera secret e QR code per l'utente
app.post('/auth/2fa/setup', requireAuth, async (req, res) => {
const user = req.user!;
// Genera nuovo secret TOTP con randomness crittografico
const totp = new OTPAuth.TOTP({
issuer: 'MyApp',
label: user.email,
algorithm: 'SHA1', // SHA1 e lo standard TOTP (RFC 6238)
digits: 6,
period: 30, // 30 secondi per OTP (standard)
secret: OTPAuth.Secret.fromRandom(20), // 160 bit di entropia
});
// Salva secret temporaneo (non confermato ancora)
const secretBase32 = totp.secret.base32;
await savePending2FASecret(user.id, secretBase32);
// Genera QR code per l'app authenticator
const otpAuthUrl = totp.toString();
const qrCodeDataUrl = await QRCode.toDataURL(otpAuthUrl);
res.json({
secret: secretBase32, // Mostra per inserimento manuale
qrCode: qrCodeDataUrl, // URL del QR code (base64 data URL)
});
});
// STEP 2: Verifica e conferma attivazione 2FA
app.post('/auth/2fa/verify-setup', requireAuth, async (req, res) => {
const { token } = req.body;
const user = req.user!;
const pendingSecret = await getPending2FASecret(user.id);
if (!pendingSecret) {
return res.status(400).json({ error: '2FA setup not initiated' });
}
const totp = new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(pendingSecret),
algorithm: 'SHA1',
digits: 6,
period: 30,
});
// Valida il codice con window=1 (accetta ±30 secondi per clock skew)
const delta = totp.validate({ token, window: 1 });
if (delta === null) {
return res.status(400).json({ error: 'Codice TOTP non valido' });
}
// Genera codici di recovery monouso
const recoveryCodes = generateRecoveryCodes(8);
const hashedCodes = await Promise.all(
recoveryCodes.map((code) => bcrypt.hash(code, 12))
);
// Attiva 2FA: salva secret definitivo e recovery codes hashati
await enable2FA(user.id, pendingSecret, hashedCodes);
await deletePending2FASecret(user.id);
// I recovery codes in chiaro vengono mostrati UNA SOLA VOLTA
res.json({ success: true, recoveryCodes });
});
// STEP 3: Login con 2FA - verifica secondo fattore
app.post('/auth/2fa/challenge', async (req, res) => {
const { token } = req.body;
// Verifica stato sessione: deve essere in pending MFA
if (!req.session.mfaPending || !req.session.pendingUserId) {
return res.status(401).json({ error: 'Invalid session state' });
}
const user = await getUserById(req.session.pendingUserId);
if (!user?.totpSecret) {
return res.status(400).json({ error: '2FA not configured' });
}
const totp = new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(user.totpSecret),
algorithm: 'SHA1',
digits: 6,
period: 30,
});
const delta = totp.validate({ token, window: 1 });
if (delta === null) {
// Rate limiting: incrementa tentativi falliti per questo account
await incrementFailedMFAAttempts(user.id);
return res.status(401).json({ error: 'Codice 2FA non valido' });
}
// TOTP valido: promuovi a sessione completamente autenticata
await resetFailedMFAAttempts(user.id);
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Session error' });
req.session.userId = user.id;
req.session.mfaPending = false;
req.session.pendingUserId = undefined;
res.json({ success: true });
});
});
// Genera recovery codes in formato leggibile (es. ABCD-EFGH-IJKL)
function generateRecoveryCodes(count: number): string[] {
return Array.from({ length: count }, () =>
Array.from({ length: 3 }, () =>
crypto.randomBytes(2).toString('hex').toUpperCase()
).join('-')
);
}
Autorizație: RBAC, ABAC și ReBAC
Autentificarea (cine sunteți?) și autorizarea (ce puteți face?) sunt concepte distincte, dar adesea implementate împreună în același middleware, creând cod dificil de testat și întreținut. Odată ce identitatea este verificată, sistemul trebuie să decidă dacă utilizatorul are permisiunea operațiunea specifică necesară. Există trei modele principale:
RBAC (controlul accesului bazat pe roluri) atribuiți permisiuni pentru roluri și roluri predefinite către utilizatori. Este cel mai simplu model de implementat și potrivit pentru majoritatea aplicatii. Limita și rigiditatea: cu mulți chiriași sau resurse personale, numărul de roluri explodează.
ABAC (control de acces bazat pe atribute) evaluează atributele utilizatorului (rol, departament, nivel), a resursei (proprietar, clasificare, chiriaș) și a context (timp, IP, dispozitiv). Mai flexibil decât RBAC, dar mai complex de implementat și de depanat.
ReBAC (Control de acces bazat pe relații) bazează permisiunile pe relații între entitățile din graficul de date (de exemplu, Google Zanzibar). Folosit de Google Drive, GitHub, Notion. Ideal pentru aplicații cu structuri de proprietate ierarhice.
// Middleware RBAC + ABAC ibrido - Node.js Express TypeScript
// Definizione dei permessi per ruolo
const ROLE_PERMISSIONS: Record<string, string[]> = {
admin: ['users:read', 'users:write', 'users:delete', 'posts:*'],
editor: ['posts:read', 'posts:write', 'posts:delete'],
viewer: ['posts:read', 'users:read'],
};
// RBAC Middleware: verifica permesso base del ruolo
function requirePermission(permission: string) {
return (req: Request, res: Response, next: NextFunction) => {
const userRole = req.user?.role;
if (!userRole) {
return res.status(401).json({ error: 'Not authenticated' });
}
const permissions = ROLE_PERMISSIONS[userRole] ?? [];
const hasPermission = permissions.some((p) => {
if (p.endsWith(':*')) {
return permission.startsWith(p.slice(0, -1));
}
return p === permission;
});
if (!hasPermission) {
return res.status(403).json({ error: 'Permessi insufficienti' });
}
next();
};
}
// ABAC: verifica proprietà della risorsa (ownership check)
function requireOwnership<T extends { authorId: string }>(
getResource: (id: string) => Promise<T | null>
) {
return async (req: Request, res: Response, next: NextFunction) => {
const resourceId = req.params.id;
const resource = await getResource(resourceId);
if (!resource) {
return res.status(404).json({ error: 'Risorsa non trovata' });
}
// Admin bypassa il check di proprietà per operazioni di supporto
if (req.user?.role === 'admin') {
req.resource = resource;
return next();
}
// Gli altri devono essere proprietari della risorsa
if (resource.authorId !== req.user?.id) {
return res.status(403).json({ error: 'Accesso negato' });
}
req.resource = resource;
next();
};
}
// Composizione: RBAC + ABAC applicati in sequenza sulla stessa route
app.put(
'/api/posts/:id',
authenticateJWT, // 1. Autenticazione: chi sei?
requirePermission('posts:write'), // 2. RBAC: il tuo ruolo può scrivere posts?
requireOwnership(getPostById), // 3. ABAC: sei il proprietario di questo post?
async (req: Request, res: Response) => {
// req.resource e il post verificato e disponibile
const updatedPost = await updatePost(req.params.id, req.body);
res.json(updatedPost);
}
);
// Esempio con Casbin per sistemi multi-tenant più complessi
// npm install casbin
import { newEnforcer } from 'casbin';
const enforcer = await newEnforcer(
'rbac_with_domains_model.conf',
'policy.csv'
);
// Verifica permesso con contesto di dominio/tenant
async function checkTenantPermission(
userId: string,
tenantId: string,
resource: string,
action: string
): Promise<boolean> {
return enforcer.enforce(userId, tenantId, resource, action);
}
Securitatea parolei: hashing și ghiduri NIST 2025
Orientările NIST SP 800-63B (reviziunea 2024) au schimbat fundamental abordarea parolelor.
Adio rotației periodice obligatorii (de fapt provoacă parole mai slabe și mai previzibile, cum ar fi
MyApp2024! care devine MyApp2025!). Adio cerințelor rigide de caracter
special (utilizatorii folosesc modele previzibile, cum ar fi P@ssw0rd1!). Noile linii directoare
focus pe: lungime minimă 8 caractere, lungime maximă minim 64 caractere, comparație
cu baze de date de parole compromise și fără cerințe obligatorii de apelare.
bcrypt vs Argon2id: pe care ar trebui să-l alegeți?
- Argon2id (recomandat pentru proiecte noi): Câștigător al Concursului Password Hashing 2015. Rezistent la atacurile GPU (cu memorie dure) și pe canalele laterale. Parametri recomandați: costul memoriei 64MB, costul timpului 3, paralelismul 4.
- bcrypt (potrivit pentru sistemele moștenite): încă sigur, cu un factor de cost >= 12. Atenție: trunchiați în tăcere parolele mai lungi de 72 de caractere - utilizați întotdeauna un pre-hash sau un wrapper care gestionează această limită.
- cripta: alternativă bună la Argon2, dar parametrizare mai complexă și mai puțin documentată pentru dezvoltatori.
- MD5, SHA-1, SHA-256 fără sare: NU utilizați NICIODATĂ pentru parolă. Sunt funcții hash rapide, nu funcții de derivare a cheilor. Un atacator cu un GPU modern poate verifica miliarde de hashe-uri pe secundă.
// Password hashing sicuro con Argon2id + controllo HaveIBeenPwned
// npm install argon2
import argon2 from 'argon2';
const ARGON2_OPTIONS: argon2.Options = {
type: argon2.argon2id, // argon2id: bilanciamento ottimale sicurezza/performance
memoryCost: 64 * 1024, // 64 MB di RAM richiesta (difesa contro GPU farms)
timeCost: 3, // 3 iterazioni sequenziali
parallelism: 4, // 4 thread paralleli
saltLength: 32, // Salt da 32 byte (generato automaticamente da argon2)
};
// Hash password durante registrazione
async function hashPassword(password: string): Promise<string> {
if (password.length < 8) {
throw new Error('Password troppo corta (minimo 8 caratteri)');
}
if (password.length > 128) {
throw new Error('Password troppo lunga (massimo 128 caratteri)');
}
// Verifica contro HaveIBeenPwned prima di salvare
const isPwned = await checkHaveIBeenPwned(password);
if (isPwned) {
throw new Error(
'Questa password e stata trovata in un data breach. Scegli una password diversa.'
);
}
return argon2.hash(password, ARGON2_OPTIONS);
}
// Verifica password durante login (timing-safe grazie ad argon2.verify)
async function verifyPassword(
plainPassword: string,
hashedPassword: string
): Promise<boolean> {
try {
return await argon2.verify(hashedPassword, plainPassword);
} catch {
return false;
}
}
// Controllo HaveIBeenPwned con k-anonymity
// Non invia la password intera: solo i primi 5 caratteri dello SHA-1 hash
async function checkHaveIBeenPwned(password: string): Promise<boolean> {
const encoder = new TextEncoder();
const data = encoder.encode(password);
const hashBuffer = await crypto.subtle.digest('SHA-1', data);
const hashHex = Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
.toUpperCase();
// Invia solo i primi 5 caratteri dell'hash (k-anonymity model)
const prefix = hashHex.slice(0, 5);
const suffix = hashHex.slice(5);
const response = await fetch(
`https://api.pwnedpasswords.com/range/${prefix}`,
{ headers: { 'Add-Padding': 'true' } } // Padding contro traffic analysis
);
const text = await response.text();
// Verifica se il nostro suffisso e nel risultato (>0 occorrenze)
return text.split('\n').some((line) =>
line.split(':')[0].trim() === suffix
);
}
Stiva de autentificare completă: limitarea ratei și jurnalul de audit
O implementare de autentificare pregătită pentru producție trebuie să includă limitarea ratei pentru a preveni aceasta forță brută, blocarea contului cu notificarea utilizatorului și înregistrarea de audit pentru conformitatea cu reglementările și răspuns la incident. Iată modelul complet care integrează toate straturile:
// Login completo con tutti i layer di sicurezza - Express.js
// npm install express-rate-limit rate-limit-redis ioredis
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
// Rate limiter per endpoint di login (brute force protection a livello IP)
export const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // Finestra di 15 minuti
max: 5, // Max 5 tentativi per IP per finestra
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args: string[]) => (redis as any).call(...args),
}),
handler: (req, res) => {
res.status(429).json({
error: 'Troppi tentativi di login. Riprova tra 15 minuti.',
retryAfter: res.getHeader('Retry-After'),
});
},
});
// Account lockout (distinto dal rate limiting per IP: blocca l'account specifico)
async function checkAccountLockout(email: string): Promise<void> {
const key = `lockout:${email}`;
const attempts = await redis.get(key);
if (attempts && parseInt(attempts) >= 10) {
throw new Error('ACCOUNT_LOCKED');
}
}
async function recordFailedLogin(email: string): Promise<void> {
const key = `lockout:${email}`;
const pipeline = redis.pipeline();
pipeline.incr(key);
pipeline.expire(key, 30 * 60); // Reset dopo 30 minuti
await pipeline.exec();
}
// Audit log per conformità e incident response
async function logAuthEvent(event: {
userId?: string;
email: string;
action: 'LOGIN' | 'LOGOUT' | 'REGISTER' | 'PASSWORD_CHANGE' | 'MFA_VERIFY';
success: boolean;
ip: string;
userAgent: string;
}): Promise<void> {
await db.query(
`INSERT INTO auth_audit_log
(user_id, email, action, success, ip_address, user_agent, created_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())`,
[event.userId, event.email, event.action, event.success, event.ip, event.userAgent]
);
}
// Endpoint di login: combina tutti i layer
app.post('/auth/login', loginRateLimiter, async (req: Request, res: Response) => {
const { email, password } = req.body;
const ip = req.ip ?? 'unknown';
const userAgent = req.get('User-Agent') ?? 'unknown';
if (!email || !password || typeof email !== 'string') {
return res.status(400).json({ error: 'Email e password richiesti' });
}
try {
await checkAccountLockout(email);
const user = await getUserByEmail(email.toLowerCase().trim());
// CRITICO: verifica la password SEMPRE, anche se l'utente non esiste
// Previene timing attack che rivelano se un utente e registrato o no
const passwordValid = user
? await verifyPassword(password, user.passwordHash)
: await verifyPassword(password, '$argon2id$v=19$m=65536,t=3,p=4$fake$fake');
if (!user || !passwordValid) {
await recordFailedLogin(email);
await logAuthEvent({ email, action: 'LOGIN', success: false, ip, userAgent });
// Risposta generica: non rivelare se l'utente esiste
return res.status(401).json({ error: 'Credenziali non valide' });
}
if (user.mfaEnabled) {
// Sessione parziale: blocca fino al completamento del secondo fattore
req.session.mfaPending = true;
req.session.pendingUserId = user.id;
return res.json({ mfaRequired: true });
}
// Login completo (senza MFA)
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Session error' });
req.session.userId = user.id;
req.session.userRole = user.role;
logAuthEvent({ userId: user.id, email, action: 'LOGIN', success: true, ip, userAgent });
res.json({ success: true });
});
} catch (err) {
if ((err as Error).message === 'ACCOUNT_LOCKED') {
return res.status(429).json({
error: 'Account temporaneamente bloccato. Contatta il supporto o riprova tra 30 minuti.',
});
}
console.error('Login error:', err);
res.status(500).json({ error: 'Errore interno del server' });
}
});
Angular: Guard, Interceptor și Client-Side Security
Când implementați autentificarea într-o aplicație Angular, distincția cheie este: i paza rutei protejează navigarea UI, nu securitatea datelor. Un atacator poate ocoli oricând Angular modificând adresa URL sau apelând API-urile direct din instrumente precum curl sau Postman. Securitatea reală rezidă întotdeauna doar în backend.
Gărzile de rută nu sunt securitate
I CanActivate Gărzile unghiulare protejează navigația în UI, dar nu împiedică
acces direct la API-uri. Fiecare punct final API trebuie să se autentifice și să autorizeze în consecință
complet independent de front-end. Gărzile sunt folosite doar pentru a afișa pagina
conectați-vă la utilizator în loc de tabloul de bord, nu pentru a proteja datele.
// auth.guard.ts - Guard per navigazione UI (non sicurezza API)
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
import { map, take } from 'rxjs/operators';
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.isAuthenticated$.pipe(
take(1),
map((isAuthenticated) => {
if (isAuthenticated) {
return true;
}
// Salva URL per redirect post-login
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url }
});
})
);
};
// auth.interceptor.ts - CSRF token e refresh automatico
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
// Aggiunge CSRF token per richieste mutative (POST/PUT/PATCH/DELETE)
const csrfToken = getCookie('XSRF-TOKEN');
const modifiedReq = csrfToken && isStatefulMethod(req.method)
? req.clone({
headers: req.headers.set('X-XSRF-TOKEN', csrfToken),
withCredentials: true, // Invia cookie di sessione con le richieste
})
: req.clone({ withCredentials: true });
return next(modifiedReq).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
// Prova refresh automatico del token
return auth.refreshToken().pipe(
switchMap(() => next(modifiedReq)),
catchError((refreshError) => {
// Refresh fallito: forza logout e redirect al login
auth.logout();
return throwError(() => refreshError);
})
);
}
return throwError(() => error);
})
);
};
function isStatefulMethod(method: string): boolean {
return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method.toUpperCase());
}
function getCookie(name: string): string | null {
const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
return match ? decodeURIComponent(match[2]) : null;
}
OWASP A07 Lista de verificare: Eșecuri de identificare și autentificare
OWASP A07:2021 (Eșecuri de identificare și autentificare) trece de pe poziția a patra la a șaptea comparativ cu 2017, datorită adoptării mai mari a MFA și a managerilor de parole. Cu toate acestea, el rămâne critic: Următoarea listă de verificare rezumă controalele minime pentru a respecta cele mai bune practici OWASP 2025. Amintiți-vă: cel 45% din codul generat de AI eșuează testele de securitate la autentificare, Testați întotdeauna codul generat de instrumente precum GitHub Copilot sau ChatGPT.
Lista de verificare pentru autentificare securizată (OWASP 2025)
- Hashing parole: Argon2id sau bcrypt (factor de cost >= 12), niciodată simplu MD5/SHA
- Politica parolelor: Lungime minimă 8, maxim cel puțin 64 de caractere, comparație HaveIBeenPwned
- Fixarea sesiunii: Regenerați ID-ul sesiunii după fiecare conectare cu
req.session.regenerate() - Steaguri cookie: HttpOnly + Secure + SameSite pe toate modulele cookie de sesiune și de autentificare
- Deconectare: Distrugeți sesiunea de pe partea serverului, nu doar ștergeți cookie-ul
- Timp de expirare a sesiunii: Expirare de inactivitate (30-60 min pentru date sensibile)
- Algoritmul JWT: Specificați lista albă explicită, blocați „niciunul” și algoritmii neaștepți
- Stocare JWT: Cookie HttpOnly, niciodată localStorage sau sessionStorage
- Forța brută: Limitarea ratei pentru IP + blocarea contului cu notificare utilizator
- Enumerarea utilizatorilor: Răspuns generic pentru acreditări proaste, comparație sigură în timp
- MAE: TOTP disponibil tuturor, obligatoriu pentru rolurile de administrator/privilegiate
- OAuth 2.1: PKCE necesar, fără flux implicit, fără acordare de parolă
- Cheile de acces: Considerați ca o alternativă fără parolă pentru proiecte noi
- Jurnalele de audit: Înregistrați toate evenimentele de autentificare cu IP, agent de utilizator și marcaj de timp
- Apărătoare unghiulare: Numai pentru UX, securitate reală întotdeauna și numai în backend
- CSRF: Indicativ CSRF pentru SPA-uri care utilizează cookie-uri de sesiune de origine încrucișată
Concluzii
Autentificarea sigură în 2025 necesită o strategie cu mai multe straturi: parole puternice de hashing cu Argon2id, gestionarea corectă a sesiunii cu cookie-uri HttpOnly+Secure+SameSite, limitarea ratei și conturi blocare prin forță brută, MFA cu TOTP pentru toți utilizatorii privilegiați și OAuth 2.1 cu PKCE pentru autentificare federală. Fiecare strat atenuează o categorie specifică de atacuri; sari peste el si tu doar unul deschide ferestre de vulnerabilități reale.
Cea mai semnificativă schimbare pentru următorii câțiva ani este trecerea la cheile de acces: Rezistent la phishing prin design, fără parole de reținut sau hash în baza de date, cu UX superior datorită Face ID și Touch ID. Dacă construiți o nouă aplicație în 2025, luați în considerare serios cheile de acces ca prim factor de autentificare.
În cele din urmă, o notă critică privind codul generat de AI: 45% din codul de autentificare este generat de Instrumentele AI eșuează testele de securitate. Cele mai frecvente probleme sunt sedintele fara regenerare dupa autentificare, JWT stocat în localStorage, lipsa unei comparații sigure pentru sincronizare și cookie-uri fără steagurile de securitate corecte. Utilizați întotdeauna această listă de verificare OWASP ca revizuire finală a codului, indiferent de cine (sau ce) a scris-o.
Continuați seria Web Security
- Articolul precedent: Injecție SQL și validare a intrărilor: securitate backend
- Articolul următor: Securitate API: OAuth 2.1, JWT și Rate Limiting
- Legat de securitate: XSS, CSRF și CSP: Securitate Frontend
- Criptare asociată: Erori criptografice: hashing, criptare și token
- Serii înrudite: DevOps Frontend: conductă DevSecOps







