04 - Bezpečná autentizace: relace, soubory cookie a moderní identita
Autentizace a vstupní brána vaší aplikace. Pokud je zranitelný, jakákoli další bezpečnostní opatření
lze obejít. V roce 2024, podle Verizon DBIR, 74 % úniků dat se týkalo přihlašovacích údajů
kompromitován nebo útoky související s identitou. Přesto většina aplikací stále implementuje
Vzor ověřování 2010: relace na straně serveru s nechráněnými soubory cookie, hesla v prostém textu v protokolech,
nejsou uloženy žádné MFA a tokeny JWT localStorage.
Krajina ověřování v roce 2025 se radikálně změnila. OAuth 2.1 konsolidované doporučené postupy zabezpečení tím, že se PKCE stane povinným pro všechny klienty. Přístupové klíče založené na WebAuthn se nahrazují tradiční hesla, přičemž Apple, Google a Microsoft je propagují jako standard. NIST SP 800-63B revidované pokyny pro hesla odstraněním požadavků na pravidelné střídání. Tento článek vám pomůže vás provede celým autentizačním útokem s praktickým kódem, běžnými úskalími a kontrolní seznam zabezpečení pro aplikace Node.js.
Co se naučíte
- Zabezpečená správa relací: HttpOnly, Secure, SameSite cookies a prevence fixace relací
- Osvědčené postupy JWT a 5 fatálních chyb, kvůli kterým jsou tokeny nejisté
- OAuth 2.1 s PKCE: tok autorizačního kódu pro SPA a mobilní aplikace
- WebAuthn a přístupové klíče: Praktická implementace s SimpleWebAuthn
- MFA s TOTP: Integrace s Google Authenticator a Authy přes otpauth
- RBAC, ABAC a ReBAC: autorizační modely pro moderní aplikace
- Plný autentizační middleware Express.js s omezením rychlosti a protokolem auditu
- Kontrolní seznam OWASP A07:2021 (Selhání identifikace a ověření)
Řízení relace: Základy
Správa relací a mechanismus, pomocí kterého si server „pamatuje“ ověřeného uživatele Bezstavové požadavky HTTP. Bezpečná relace vyžaduje čtyři základní vlastnosti: nepředvídatelné identifikátory, bezpečný přenos, kontrolované vypršení platnosti e správné zneplatnění při odhlášení.
Hlavním vektorem útoku je fixace relace: Útočník poskytuje oběť
známé ID relace před přihlášením a poté znovu použije stejné ID po ověření k předstírání identity
uživatele. Protiopatřením je vždy po přihlášení znovu vygenerovat ID relace. Druhý vektor je
únos relace přes XSS, zmírněno označenými soubory cookie HttpOnly. Třetí
vektor jsou sirotčí sezení: Na straně serveru nikdy nezrušené relace, které zůstávají
aktivní i poté, co klient soubor cookie smazal, což umožňuje přehrávání odcizených ID relace.
// 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 ve výrobě
V produkci nikdy nepoužívejte výchozí MemoryStore Express-session.
Způsobuje únik paměti, neškáluje horizontálně (ztráta relací při restartování uzlu),
a nepřetrvává relace mezi nasazeními. Vždy používejte externí obchod: Redis
(connect-redis), PostgreSQL (connect-pg-simple),
nebo MongoDB (connect-mongo). Redis je preferovanou volbou pro výkon
optimální a automatické TTL prošlých klíčů.
Zabezpečení souborů cookie: Vlajky, na kterých záleží
Soubory cookie relace musí být nakonfigurovány s alespoň třemi atributy zabezpečení. Všichni zmírňují specifická kategorie útoků. Správná kombinace je HttpOnly + Secure + SameSite, společně nakonfigurované pro vrstvenou ochranu:
| Atribut | Doporučená hodnota | Hrozba zmírněna |
|---|---|---|
HttpOnly |
true |
XSS: JavaScript nemůže číst cookie |
Secure |
true |
MITM: cookie přenášené pouze přes HTTPS |
SameSite |
Strict o Lax |
CSRF: Blokujte neoprávněné odeslání napříč původem |
MaxAge |
8-24 hodin pro normální sezení | Osiřelé relace: automatické vypršení platnosti na straně prohlížeče |
Domain |
Specifické pro doménu, žádné zástupné znaky | Únik souborů cookie na kompromitovaných subdoménách |
Volba mezi SameSite=Strict e SameSite=Lax Záleží na případu použití.
Přísný nabízí maximální ochranu, ale blokuje soubory cookie, i když uživatel prohlíží
na váš web z externího odkazu (např. e-mailu nebo jiného webu), což způsobí přesměrování při každém přihlášení.
Laxní povolí soubor cookie v navigaci na nejvyšší úrovni (klikněte na odkaz), ale zablokuje jej
POST/PUT/DELETE cross-origin požadavky, které nabízejí dobrou rovnováhu mezi bezpečností a použitelností.
Pro API používaná SPA v jiné doméně použijte SameSite=None; Secure s ochranou
CSRF prostřednictvím tokenu v záhlaví.
JWT Best Practices a 5 fatálních chyb
Webové tokeny JSON jsou výkonným nástrojem pro bezstavovou autentizaci v rozhraních API, ale jejich nesprávná implementace a patří mezi nejčastější příčiny kritických zranitelností. NIST a OWASP identifikovat pět fatálních chyb, které zcela ruší bezpečnost JWT, každou z nich s ničivými následky, pokud by byly využívány ve výrobě.
5 fatálních chyb JWT
- Chyba #1 – „žádný“ algoritmus: Některé servery přijímají tokeny s
"alg": "none", bez podpisu. Útočník může zfalšovat jakékoli užitečné zatížení. - Chyba #2 – Záměna algoritmů: Server RS256, který přijímá HS256, umožňuje útočníkovi podepsat se (známým) veřejným klíčem.
- Chyba #3 – JWT v localStorage: Přístupné z libovolného skriptu JS. XSS na jakékoli závislosti npm může ukrást token.
- Chyba #4 – Tokeny nevyprší: Přístupový token, jehož platnost nikdy nevyprší, nelze v případě kompromitace odvolat.
- Chyba #5 – slabé tajemství pro HS256: Krátká nebo předvídatelná tajemství jsou zranitelná vůči offline slovníkovým útokům nebo útokům hrubou silou.
// 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;
}
Úložiště JWT: Cookie HttpOnly vs localStorage
Mnoho tutoriálů doporučuje uložit JWT do localStorage protože se snadno používá
s SPA. Nikdy to nedělejte pro ověřovací tokeny. localStorage a přístupné
z jakéhokoli skriptu JavaScript spuštěného na stránce, včetně kompromitovaných knihoven třetích stran.
Útok XSS na jakoukoli závislost npm může tiše ukrást všechny tokeny.
Správným řešením je použití cookie HttpOnly + Secure + SameSite=Přísné:
nepřístupné pro JavaScript, přenášené pouze přes HTTPS a s vestavěnou ochranou CSRF.
Pro SPA, která potřebují provádět cross-origin API požadavky, použijte SameSite=None; Secure
s CSRF tokenem v hlavičce požadavku.
OAuth 2.1 s PKCE: Secure Federated Authentication
OAuth 2.1 (koncept RFC, konsolidovaný v roce 2024) sjednocuje osvědčené bezpečnostní postupy nashromážděné v průběhu let nasazení OAuth 2.0, vykreslování Vyžaduje se PKCE (Proof Key for Code Exchange). pro všechny klienty, včetně důvěrných klientů s klientskými tajemstvími. Také eliminuje implicitní tok a udělení hesla, které jsou ze své podstaty považovány za nezabezpečené a zastaralé.
Tok autorizačního kódu s PKCE funguje takto: klient vygeneruje a ověřovatel_kódu náhodný (43-128 znaků), vypočítá code_challenge jako SHA-256 Base64URL, odešle výzvu autorizačnímu serveru a po obdržení autorizačního kódu jej vymění s tokenem předložením původního ověřovatele. I když útočník zachytí kód během přesměrování, nemůže jej vyměnit bez ověřovače, který zůstává v klientovi tajný.
// 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 a přístupové klíče: Autentizace bez hesla
Přístupové klíče založené na WebAuthn (Web Authentication API, standard W3C) představují změnu nejvýznamnější v autentizaci za posledních dvacet let. Na rozdíl od hesel, přístupových klíčů používají asymetrickou kryptografii veřejného klíče: soukromý klíč zůstává na zařízení uživatele chráněný biometrickým údajem nebo PINem zařízení, zatímco server zná pouze veřejný klíč.
Výsledek je jeden absolutní odolnost vůči phishingu (klíč je kryptografický propojené s původní doménou), žádná hesla, která by bylo možné ukrást při narušení databáze, a vynikající uživatelské rozhraní Face ID nebo Touch ID místo hesla + 2FA. V roce 2025, dále 15 miliard účtů podporovat přístupové klíče. Knihovna SimpleWebAuthn nesmírně zjednodušuje implementaci.
// 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 a TOTP: Druhý faktor
Vícefaktorová autentizace (MFA) je nejúčinnějším protiopatřením proti nacpávání pověření a klasický phishing založený na hesle. Podle Microsoftu MFA blokuje 99,9 % z automatizované útoky na účty. TOTP (Time-based One-Time Password, RFC 6238) a podobně de facto standard pro software druhého faktoru, kompatibilní s Google Authenticator, Authy, 1Heslo a jakákoli aplikace kompatibilní s TOTP.
Často přehlížený kritický aspekt: tok MFA musí být atomové a nelze je obejít.
Nikdy nevytvářejte částečně ověřenou relaci po prvním faktoru, který vám to umožňuje
jakákoli operace. Místo toho použijte dočasnou relaci s příznakem mfaPending: true,
a udělit plný přístup až po ověření druhého faktoru.
// 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('-')
);
}
Autorizace: RBAC, ABAC a ReBAC
Autentizace (kdo jste?) a autorizace (co můžete dělat?) jsou odlišné pojmy, ale často implementovány společně ve stejném middlewaru a vytvářet kód, který je obtížné testovat a udržovat. Jakmile je identita ověřena, musí systém rozhodnout, zda k tomu má uživatel oprávnění konkrétní požadovanou operaci. Existují tři hlavní modely:
RBAC (Role-Based Access Control) přidělovat oprávnění předdefinovaným rolím a rolím uživatelům. Je to nejjednodušší model na implementaci a vhodný pro většinu aplikací. Limit a rigidita: s mnoha nájemníky nebo osobními zdroji, počtem rolí exploduje.
ABAC (řízení přístupu na základě atributů) vyhodnocovat uživatelské atributy (role, oddělení, úroveň), zdroje (vlastník, klasifikace, nájemce) a kontextu (čas, IP, zařízení). Flexibilnější než RBAC, ale složitější na implementaci a ladění.
ReBAC (Relationship-Based Access Control) zakládá oprávnění na vztazích mezi entitami v datovém grafu (např. Google Zanzibar). Používá Google Drive, GitHub, Notion. Ideální pro aplikace s hierarchickou vlastnickou strukturou.
// 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);
}
Zabezpečení heslem: Hašování a směrnice NIST 2025
Pokyny NIST SP 800-63B (revize z roku 2024) zásadně změnily přístup k heslům.
Sbohem povinné periodické rotaci (ve skutečnosti způsobuje slabší a předvídatelnější hesla jako např
MyApp2024! která se stává MyApp2025!). Sbohem přísným požadavkům na charakter
speciální (uživatelé používají předvídatelné vzory jako P@ssw0rd1!). Nové pokyny
zaměřit se na: minimální délka 8 znaků, maximální délka alespoň 64 znaků, srovnání
s kompromitovanými databázemi hesel a bez povinných požadavků na vytáčení.
bcrypt vs Argon2id: Který z nich byste si měli vybrat?
- Argon2id (doporučeno pro nové projekty): Vítěz soutěže o hashování hesel v roce 2015. Odolné vůči GPU (paměťově pevnému) a útokům postranním kanálem. Doporučené parametry: memoryCost 64MB, timeCost 3, paralelismus 4.
- bcrypt (vhodné pro starší systémy): stále bezpečné s nákladovým faktorem >= 12. Upozornění: Tiše zkraťte hesla delší než 72 znaků – vždy používejte pre-hash nebo wrapper, který tento limit zvládne.
- scrypt: dobrá alternativa k Argon2, ale složitější a méně zdokumentovaná parametrizace pro vývojáře.
- MD5, SHA-1, SHA-256 bez soli: NIKDY nepoužívejte pro heslo. Jsou to rychlé hašovací funkce, nikoli funkce odvození klíčů. Útočník s moderním GPU může ověřit miliardy hashů za sekundu.
// 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
);
}
Full Auth Stack: Omezení rychlosti a protokol auditu
Produkční implementace autentizace musí zahrnovat omezení rychlosti, aby se tomu zabránilo hrubá síla, uzamčení účtu s upozorněním uživatele a protokolování auditu pro dodržování předpisů a reakce na incidenty. Zde je kompletní vzor, který integruje všechny vrstvy:
// 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' });
}
});
Úhlové: Ochrana, Interceptor a Zabezpečení na straně klienta
Když implementujete ověřování v aplikaci Angular, hlavní rozdíl je: i Route guard protect UI navigaci, nikoli zabezpečení dat. Útočník může vždy obejít Angular úpravou adresy URL nebo voláním rozhraní API přímo z nástrojů jako curl nebo Postman. Skutečná bezpečnost je vždy pouze v backendu.
Traťové stráže nejsou bezpečností
I CanActivate Úhlové kryty chrání navigaci v uživatelském rozhraní, ale nebrání
přímý přístup k API. Každý koncový bod API se musí odpovídajícím způsobem ověřit a autorizovat
zcela nezávislé na frontendu. Ochrany slouží pouze k zobrazení stránky
přihlašte se k uživateli místo řídicího panelu, nikoli k ochraně dat.
// 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;
}
Kontrolní seznam OWASP A07: Selhání identifikace a autentizace
OWASP A07:2021 (Identification and Authentication Failures) se posouvá ze čtvrté na sedmou pozici ve srovnání s rokem 2017 díky většímu přijetí MFA a správců hesel. Přesto zůstává kritický: Následující kontrolní seznam shrnuje minimální kontroly pro splnění osvědčených postupů OWASP 2025. Pamatujte: 45 % kódu generovaného umělou inteligencí neprošlo bezpečnostními testy na autentizaci, Vždy testujte kód generovaný nástroji jako GitHub Copilot nebo ChatGPT.
Kontrolní seznam bezpečné autentizace (OWASP 2025)
- Hašování hesla: Argon2id nebo bcrypt (nákladový faktor >= 12), nikdy jednoduché MD5/SHA
- Zásady hesel: Minimální délka 8, maximální alespoň 64 znaků, srovnání HaveIBeenPwned
- Oprava relace: Obnovte ID relace po každém přihlášení pomocí
req.session.regenerate() - Příznaky souborů cookie: HttpOnly + Secure + SameSite ve všech souborech cookie relace a ověřování
- Odhlášení: Zničte relaci na straně serveru, nejen smažte soubor cookie
- Časový limit relace: Vypršení platnosti nečinnosti (30–60 minut pro citlivá data)
- Algoritmus JWT: Zadejte explicitní seznam povolených, blokujte „žádné“ a neočekávané algoritmy
- Úložiště JWT: Soubor cookie HttpOnly, nikdy localStorage nebo sessionStorage
- Hrubá síla: Omezení rychlosti pro IP + uzamčení účtu s upozorněním uživatele
- Výčet uživatelů: Obecná odpověď na špatné přihlašovací údaje, srovnání bezpečné načasování
- MFA: TOTP dostupné všem, povinné pro administrátorské/privilegované role
- OAuth 2.1: Vyžaduje se PKCE, žádný implicitní tok, žádné udělení hesla
- Přístupové klíče: Zvažte jako alternativu bez hesla pro nové projekty
- Protokoly auditu: Zaznamenejte všechny události ověření pomocí IP, uživatelského agenta a časového razítka
- Úhlové chrániče: Pouze pro UX, skutečné zabezpečení vždy a pouze v backendu
- CSRF: CSRF token pro SPA, které používají soubory cookie relace mezi původy
Závěry
Bezpečné ověřování v roce 2025 vyžaduje vícevrstvou strategii: silné hashování hesel Argon2id, správná správa relací pomocí souborů cookie HttpOnly+Secure+SameSite, omezení sazeb a účtů Uzamčení hrubou silou, MFA s TOTP pro všechny privilegované uživatele a OAuth 2.1 s PKCE pro federované ověřování. Každá vrstva zmírňuje specifickou kategorii útoků; přeskočte to taky pouze jeden otevírá okna skutečných zranitelností.
Nejvýznamnější změnou pro příštích několik let je přechod na přístupové klíče: Designově odolné proti phishingu, žádná hesla k zapamatování nebo hašování v databázi, s UX lepší díky Face ID a Touch ID. Pokud v roce 2025 vytváříte novou aplikaci, zvažte vážně přístupové klíče jako první autentizační faktor.
Nakonec kritická poznámka ke kódu generovanému AI: 45 % ověřovacího kódu je generováno společností Nástroje umělé inteligence neprošly bezpečnostními testy. Nejčastějšími problémy jsou sezení bez regenerace po přihlášení, JWT uložené v localStorage, nedostatek časově bezpečného srovnání a soubory cookie bez správné bezpečnostní příznaky. Vždy použijte tento kontrolní seznam OWASP jako konečnou kontrolu kódu, bez ohledu na to, kdo (nebo co) to napsal.
Pokračujte v sérii Web Security Series
- Předchozí článek: SQL Injection a Input Validation: Backend Security
- Další článek: Zabezpečení API: OAuth 2.1, JWT a Rate Limiting
- související s bezpečností: XSS, CSRF a CSP: Zabezpečení frontendu
- Související šifrování: Kryptografické chyby: hašování, šifrování a token
- Související série: Frontend DevOps: kanál DevSecOps







