JWT / Session / OAuth 2.0 / OIDC
Выбор механизма аутентификации — архитектурное решение с trade-offs между stateless (JWT) и stateful (session) подходами, делегированным доступом (OAuth 2.0) и федеративной идентификацией (OIDC); уязвимости в реализации JWT — постоянная тема security-интервью.
// ATTACK: JWT без корректной верификации — алгоритм confusion + "none" атака
// Структура JWT: base64url(header).base64url(payload).signature// Декодирование без проверки подписи:const [h64, p64] = token.split('.');const header = JSON.parse(atob(h64.replace(/-/g,'+').replace(/_/g,'/')));const payload = JSON.parse(atob(p64.replace(/-/g,'+').replace(/_/g,'/')));
// ❌ Уязвимо: доверяем payload без проверки подписиif (payload.role === 'admin') grantAccess();
// ❌ Алгоритм "none" (CVE-2015-9235):// Атакующий меняет header: { "alg": "none" } и убирает подпись// Некоторые библиотеки принимают такой токен как валидный!
// ❌ Algorithm confusion (RS256 → HS256):// RS256: подпись RSA private key, верификация public key// Атака: поменять alg на HS256, подписать публичным ключом как HMAC-secret// Сервер верифицирует HS256 с "публичным ключом" → принимает подделанный токен!jwt.verify(token, publicKey); // ❌ не передаём { algorithms }// MITIGATION: безопасная верификация JWTimport jwt from 'jsonwebtoken';
// ✅ Всегда явно указывать allowed algorithmsfunction verifyAccessToken(token) { return jwt.verify(token, process.env.JWT_PUBLIC_KEY, { algorithms: ['RS256'], // запрещаем "none" и HS256 issuer: 'https://auth.example.com', audience: 'https://api.example.com', });}
// ✅ Хранение токенов// Access token (TTL 15 мин): httpOnly cookie или in-memory (React state/Ref)// НЕ в localStorage — XSS-скрипт прочитает его// res.cookie('access_token', token, {// httpOnly: true, secure: true, sameSite: 'strict', maxAge: 900_000// });
// ✅ Refresh Token Rotation// Refresh token (TTL 7-30 дней): httpOnly cookie, one-time use// При использовании refresh token → выдать новый access + новый refresh// Если refresh token использован повторно → Reuse Detection → инвалидировать всю семью
// JWT vs Session:// JWT: stateless, горизонтальное масштабирование, но нельзя инвалидировать до exp// Session (Redis): stateful, мгновенный logout/ban, но нужен shared store// OAuth 2.0 PKCE flow (Authorization Code + Proof Key for Code Exchange)// Для SPA и мобильных: нет client_secret (нельзя хранить безопасно)
// 1. Генерируем code_verifier (случайная строка) и code_challengeconst verifier = crypto.getRandomValues(new Uint8Array(32));const verifierB64 = btoa(String.fromCharCode(...verifier)) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');const hashBuf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifierB64));const challenge = btoa(String.fromCharCode(...new Uint8Array(hashBuf))) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');sessionStorage.setItem('pkce_verifier', verifierB64);
// 2. Редирект на Authorization Serverconst url = new URL('https://auth.example.com/authorize');url.searchParams.set('response_type', 'code');url.searchParams.set('client_id', CLIENT_ID);url.searchParams.set('redirect_uri', REDIRECT_URI);url.searchParams.set('code_challenge', challenge);url.searchParams.set('code_challenge_method', 'S256');url.searchParams.set('scope', 'openid profile email'); // OIDC scopesurl.searchParams.set('state', randomState); // CSRF-защитаlocation.href = url.toString();
// 3. Callback: обмен code → tokens (verifier доказывает, что мы инициатор)const tokens = await fetch('https://auth.example.com/token', { method: 'POST', body: new URLSearchParams({ grant_type: 'authorization_code', code: authCode, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: sessionStorage.getItem('pkce_verifier'), }),}).then(r => r.json());// tokens.id_token → OIDC: JWT с sub, email, name — идентификация пользователя// tokens.access_token → OAuth2: для API-запросов (Bearer token)Итог: JWT stateless, не инвалидируется до exp — используйте короткий TTL + refresh rotation; OAuth 2.0 PKCE для делегированного доступа; OIDC id_token для идентификации.