04 - 보안 인증: 세션, 쿠키 및 최신 ID
본인 인증 및 신청서의 입장 게이트입니다. 취약한 경우 기타 보안 조치
우회할 수 있습니다. Verizon DBIR에 따르면 2024년에는 데이터 침해의 74%는 자격 증명과 관련이 있습니다.
타협하다 또는 신원 관련 공격. 그러나 대부분의 애플리케이션은 여전히 구현됩니다.
2010 인증 패턴: 보호되지 않은 쿠키가 있는 서버 측 세션, 로그의 일반 텍스트 비밀번호,
MFA 없음 및 JWT 토큰이 저장됨 localStorage.
2025년 인증 환경은 급격하게 변화했습니다. OAuth 2.1 통합 모범 사례 모든 클라이언트에 대해 PKCE를 필수로 설정하여 보안을 강화합니다. WebAuthn 기반 암호키가 대체됩니다. Apple, Google 및 Microsoft에서 이를 표준으로 홍보하는 전통적인 비밀번호입니다. NIST SP 800-63B 주기적인 교체 요구 사항을 제거하여 비밀번호 지침을 개정했습니다. 이 글이 도움이 될 것입니다 실용적인 코드, 일반적인 함정 및 오류를 통해 전체 인증 공격 표면을 안내합니다. Node.js 애플리케이션에 대한 보안 체크리스트입니다.
무엇을 배울 것인가
- 안전한 세션 관리: HttpOnly, Secure, SameSite 쿠키 및 세션 고정 방지
- JWT 모범 사례와 토큰을 불안하게 만드는 5가지 치명적인 실수
- PKCE가 포함된 OAuth 2.1: SPA 및 모바일 애플리케이션에 대한 인증 코드 흐름
- WebAuthn 및 암호 키: SimpleWebAuthn을 사용한 실제 구현
- TOTP를 사용한 MFA: otpauth를 통해 Google Authenticator 및 Authy와 통합
- RBAC, ABAC 및 ReBAC: 최신 애플리케이션을 위한 인증 모델
- 속도 제한 및 감사 로그가 포함된 전체 Express.js 인증 미들웨어
- OWASP 체크리스트 A07:2021(식별 및 인증 실패)
세션 관리: 기본 사항
세션 관리 및 서버가 인증된 사용자를 "기억"하는 메커니즘 상태 비저장 HTTP 요청. 보안 세션에는 네 가지 기본 속성이 필요합니다. 예측할 수 없는 식별자, 안전한 전송, 통제된 만료 e 로그아웃 시 올바른 무효화.
주요 공격 벡터는 세션 고정: 공격자가 피해자에게 제공하는 것
로그인하기 전에 알려진 세션 ID를 사용하고, 인증 후 동일한 ID를 재사용하여 가장합니다.
사용자. 이에 대한 대책은 로그인 후 항상 세션 ID를 재생성하는 것입니다. 두 번째 벡터는
세션 하이재킹 플래그가 지정된 쿠키로 완화되는 XSS를 통해 HttpOnly. 세 번째
벡터는 고아 세션: 서버 측에서는 남아 있는 세션을 무효화하지 않았습니다.
클라이언트가 쿠키를 삭제한 후에도 활성화되어 도난당한 세션 ID를 재생할 수 있습니다.
// 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 });
});
});
안티 패턴: 프로덕션의 MemoryStore
프로덕션에서는 express-session의 기본 MemoryStore를 사용하지 마십시오.
메모리 누수를 유발하고 수평으로 확장되지 않습니다(노드를 다시 시작할 때 세션이 손실됨).
배포 간 세션을 유지하지 않습니다. 항상 외부 저장소 사용: Redis
(connect-redis), PostgreSQL(connect-pg-simple),
또는 몽고DB(connect-mongo). Redis는 성능 측면에서 선호되는 선택입니다.
만료된 키의 최적의 자동 TTL.
쿠키 보안: 중요한 플래그
세션 쿠키는 세 가지 이상의 보안 속성으로 구성되어야 합니다. 모두가 완화한다 특정 범주의 공격. 올바른 조합은 HttpOnly + Secure + SameSite입니다. 계층화된 보호를 위해 함께 구성:
| 기인하다 | 권장값 | 위협 완화 |
|---|---|---|
HttpOnly |
true |
XSS: JavaScript가 쿠키를 읽을 수 없습니다. |
Secure |
true |
MITM: 쿠키는 HTTPS를 통해서만 전송됩니다. |
SameSite |
Strict o Lax |
CSRF: 승인되지 않은 교차 출처 제출 차단 |
MaxAge |
일반 세션의 경우 8~24시간 | 고아 세션: 자동 브라우저 측 만료 |
Domain |
도메인별, 와일드카드 없음 | 손상된 하위 도메인의 쿠키 유출 |
사이의 선택 SameSite=Strict e SameSite=Lax 사용 사례에 따라 다릅니다.
엄격한 최대한의 보호를 제공하지만 사용자가 탐색할 때에도 쿠키를 차단합니다.
외부 링크(예: 이메일 또는 기타 사이트)에서 귀하의 사이트로 연결되어 매번 로그인하도록 리디렉션됩니다.
락스 최상위 탐색(링크 클릭)에서는 쿠키를 허용하지만 다음 경우에는 차단합니다.
POST/PUT/DELETE 교차 출처 요청은 보안과 유용성 간의 적절한 균형을 제공합니다.
다른 도메인의 SPA에서 사용하는 API의 경우 다음을 사용하세요. SameSite=None; Secure 보호와 함께
헤더의 토큰을 통한 CSRF.
JWT 모범 사례와 5가지 치명적인 실수
JSON 웹 토큰은 API의 무상태 인증을 위한 강력한 도구이지만 잘못된 구현과 심각한 취약점의 가장 일반적인 원인 중 하나입니다. NIST와 OWASP JWT의 보안을 완전히 무효화하는 5개의 치명적인 오류를 식별합니다. 생산 과정에서 악용될 경우 파괴적인 결과를 초래할 수 있습니다.
JWT의 5가지 치명적인 실수
- 실수 #1 - "없음" 알고리즘: 일부 서버는 다음과 같은 토큰을 허용합니다.
"alg": "none", 서명 없음. 공격자는 모든 페이로드를 위조할 수 있습니다. - 실수 #2 - 알고리즘 혼란: HS256을 허용하는 RS256 서버는 공격자가 (알려진) 공개 키로 서명할 수 있도록 허용합니다.
- 오류 #3 - localStorage의 JWT: 모든 JS 스크립트에서 액세스 가능합니다. npm 종속성에 대한 XSS는 토큰을 훔칠 수 있습니다.
- 실수 #4 - 만료되지 않는 토큰: 만료되지 않는 액세스 토큰은 손상되더라도 취소될 수 없습니다.
- 실수 #5 - HS256의 약한 비밀: 짧거나 예측 가능한 비밀은 오프라인 사전 공격이나 무차별 대입 공격에 취약합니다.
// 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;
}
JWT 저장소: 쿠키 HttpOnly 대 localStorage
많은 튜토리얼에서는 JWT를 다음 위치에 저장할 것을 권장합니다. localStorage 사용법이 간단하기 때문에
SPA와 함께. 인증 토큰에는 이 작업을 수행하지 마십시오. localStorage 및 액세스 가능
손상된 타사 라이브러리를 포함하여 페이지에서 실행되는 모든 JavaScript 스크립트에서.
npm 종속성에 대한 XSS 공격은 모든 토큰을 자동으로 훔칠 수 있습니다.
올바른 해결책은 다음을 사용하는 것입니다. 쿠키 HttpOnly + 보안 + SameSite=엄격:
JavaScript로 액세스할 수 없고 HTTPS를 통해서만 전송되며 CSRF 보호 기능이 내장되어 있습니다.
교차 출처 API 요청을 해야 하는 SPA의 경우 다음을 사용하세요. SameSite=None; Secure
요청 헤더에 CSRF 토큰을 사용합니다.
PKCE를 사용한 OAuth 2.1: 안전한 통합 인증
OAuth 2.1(RFC 초안, 2024년 통합)은 수년간 축적된 보안 모범 사례를 통합합니다. OAuth 2.0 배포, 렌더링 PKCE(코드 교환을 위한 증명 키) 필요 클라이언트 비밀이 있는 기밀 클라이언트를 포함한 모든 클라이언트에 대해. 또한 암시적 흐름을 제거합니다. 본질적으로 안전하지 않고 더 이상 사용되지 않는 것으로 간주되는 비밀번호 부여.
PKCE를 사용한 인증 코드 흐름은 다음과 같이 작동합니다. 클라이언트는 code_verifier 무작위(43-128자), 다음을 계산합니다. code_challenge SHA-256 Base64URL과 같은 인증 서버에 Challenge를 보내고 인증 코드를 받은 후 이를 교환합니다. 원본 검증자를 제시하여 토큰을 사용합니다. 공격자가 코드를 가로채더라도 리디렉션은 클라이언트에 비밀로 남아 있는 검증자 없이는 교환할 수 없습니다.
// 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 및 암호키: 비밀번호 없는 인증
WebAuthn(Web Authentication API, W3C 표준) 기반의 암호키는 변경 사항을 나타냅니다. 지난 20년 동안 인증 분야에서 가장 중요한 역할을 했습니다. 비밀번호와 달리 패스키는 비대칭 공개 키 암호화를 사용합니다. 개인 키는 사용자의 장치에 남아 있습니다. 장치의 생체 인식 또는 PIN으로 보호되는 반면 서버는 공개 키만 알고 있습니다.
결과는 하나 피싱에 대한 절대적인 저항 (키는 암호화 방식으로 linked to the original domain), no passwords to steal in database breaches, and superior UX with 비밀번호 + 2FA 대신 Face ID 또는 Touch ID. 2025년에는 그 이상 150억 개의 계정 패스키를 지원합니다. 도서관 SimpleWeb인증 엄청나게 단순화된다 구현.
// 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 및 TOTP: 두 번째 요소
MFA(Multi-Factor Authentication)는 크리덴셜 스터핑에 대한 가장 효과적인 대응책입니다. 전형적인 비밀번호 기반 피싱. Microsoft에 따르면 MFA는 다음을 차단합니다. 99.9%의 계정에 대한 자동화된 공격. TOTP(시간 기반 일회용 비밀번호, RFC 6238) 및 lo Google Authenticator, Authy와 호환되는 두 번째 요소 소프트웨어의 사실상 표준입니다. 1Password 및 모든 TOTP 호환 앱.
종종 간과되는 중요한 측면: MFA 흐름은 원자적이며 우회할 수 없습니다..
부분적으로 인증된 세션을 허용하는 첫 번째 요소 이후에는 절대로 부분적으로 인증된 세션을 생성하지 마십시오.
어떤 작업. 대신 플래그가 있는 임시 세션을 사용하세요. mfaPending: true,
두 번째 요소가 확인된 후에만 전체 액세스 권한을 부여합니다.
// 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('-')
);
}
승인: RBAC, ABAC 및 ReBAC
인증(당신은 누구입니까?)과 승인(당신은 무엇을 할 수 있습니까?)은 별개의 개념이지만 종종 동일한 미들웨어에서 함께 구현되어 테스트 및 유지 관리가 어려운 코드가 생성됩니다. 신원이 확인되면 시스템은 사용자에게 다음 권한이 있는지 여부를 결정해야 합니다. 필요한 특정 작업. 세 가지 주요 모델이 있습니다:
RBAC(역할 기반 액세스 제어) 사전 정의된 역할 및 역할에 권한 할당 사용자에게. 구현하기 가장 간단한 모델이며 대부분의 경우에 적합합니다. 응용 프로그램. 한계 및 경직성: 테넌트 또는 개인 리소스가 많을 경우 역할 수가 폭발한다.
ABAC(속성 기반 액세스 제어) 사용자 속성 평가 (role, department, level), of the resource (owner, classification, tenant) and of the 컨텍스트(시간, IP, 장치). RBAC보다 유연하지만 구현 및 디버그가 더 복잡합니다.
ReBAC(관계 기반 액세스 제어) 관계에 대한 권한을 기반으로 함 데이터 그래프의 개체 간(예: Google Zanzibar) Google Drive, GitHub, Notion에서 사용됩니다. 계층적 소유권 구조를 갖춘 애플리케이션에 이상적입니다.
// 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);
}
비밀번호 보안: 해싱 및 NIST 지침 2025
NIST SP 800-63B 지침(2024 개정판)은 비밀번호에 대한 접근 방식을 근본적으로 변경했습니다.
필수 주기적 순환은 이제 안녕(실제로는 다음과 같은 더 약하고 예측 가능한 비밀번호를 유발함)
MyApp2024! 이는 MyApp2025!). 엄격한 캐릭터 요구사항은 이제 안녕
특수(사용자는 다음과 같은 예측 가능한 패턴을 사용합니다. P@ssw0rd1!). 새로운 지침
초점: 최소 길이 8자, 최대 길이 최소 64자, 비교
비밀번호 데이터베이스가 손상되었으며 필수 전화 걸기 요구 사항이 없습니다.
bcrypt 대 Argon2id: 어느 것을 선택해야 합니까?
- 아르곤2id (새 프로젝트에 권장): 2015년 비밀번호 해싱 대회 우승자. GPU(메모리 하드) 및 부채널 공격에 강합니다. 권장 매개변수: memoryCost 64MB, timeCost 3, 병렬성 4.
- 비크립트 (레거시 시스템에 적합): 비용 요소 >= 12로 여전히 안전합니다. 경고: 72자를 초과하는 비밀번호는 자동으로 자릅니다. 항상 이 제한을 처리하는 사전 해시 또는 래퍼를 사용하십시오.
- 암호: Argon2에 대한 좋은 대안이지만 개발자를 위한 더 복잡하고 덜 문서화된 매개변수화입니다.
- 소금이 없는 MD5, SHA-1, SHA-256: 절대로 비밀번호로 사용하지 마세요. 이는 키 파생 함수가 아닌 빠른 해시 함수입니다. 최신 GPU를 갖춘 공격자는 초당 수십억 개의 해시를 확인할 수 있습니다.
// 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
);
}
전체 인증 스택: 속도 제한 및 감사 로그
프로덕션 준비가 완료된 인증 구현에는 이를 방지하기 위한 속도 제한이 포함되어야 합니다. 무차별 대입, 사용자 알림을 통한 계정 잠금, 규정 준수를 위한 감사 로깅 그리고 사고 대응. 모든 레이어를 통합하는 완전한 패턴은 다음과 같습니다.
// 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: 가드, 인터셉터 및 클라이언트 측 보안
Angular 애플리케이션에서 인증을 구현할 때 주요 차이점은 다음과 같습니다. 나 경로 가드는 UI 탐색을 보호합니다., not data security. 공격자는 URL을 수정하거나 API를 호출하여 항상 Angular를 우회할 수 있습니다. 컬(curl)이나 포스트맨(Postman)과 같은 도구에서 직접. 실제 보안은 항상 백엔드에만 있습니다.
루트 가드는 보안이 아닙니다
I CanActivate 각도 가드는 UI 탐색을 보호하지만 방해하지는 않습니다.
API에 직접 액세스합니다. 각 API 엔드포인트는 그에 따라 인증하고 권한을 부여해야 합니다.
프론트엔드와 완전히 독립되어 있습니다. 가드는 페이지를 표시하는 데에만 사용됩니다.
데이터를 보호하기 위해 대시보드 대신 사용자에게 로그인합니다.
// 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 체크리스트: 식별 및 인증 실패
OWASP A07:2021(식별 및 인증 실패)이 4위에서 7위로 이동합니다. 2017년과 비교하면 MFA 및 비밀번호 관리자 채택이 많아진 덕분입니다. 그러나 그는 여전히 비판적입니다. 다음 체크리스트에는 OWASP 2025 모범 사례를 준수하기 위한 최소 통제 사항이 요약되어 있습니다. 기억하세요: AI 생성 코드 중 45%가 보안 테스트에 실패 인증 시, GitHub Copilot 또는 ChatGPT와 같은 도구로 생성된 코드를 항상 테스트하세요.
보안 인증 체크리스트(OWASP 2025)
- 비밀번호 해싱: Argon2id 또는 bcrypt(비용 요소 >= 12), 결코 단순하지 않은 MD5/SHA
- 비밀번호 정책: 최소 길이 8자, 최대 64자 이상, HaveIBeenPwned 비교
- 세션 수정: 로그인할 때마다 세션 ID를 다시 생성합니다.
req.session.regenerate() - 쿠키 플래그: 모든 세션 및 인증 쿠키에 대한 HttpOnly + Secure + SameSite
- 로그아웃: 쿠키만 삭제하지 말고 서버 측 세션을 삭제하세요.
- 세션 시간 초과: 비활성 만료(민감한 데이터의 경우 30~60분)
- JWT 알고리즘: 명시적인 화이트리스트를 지정하고 "없음" 및 예상치 못한 알고리즘을 차단합니다.
- JWT 저장소: HttpOnly 쿠키, localStorage나 sessionStorage는 아님
- 무차별 대입: IP에 대한 속도 제한 + 사용자 알림을 통한 계정 잠금
- 사용자 열거: 잘못된 자격 증명에 대한 일반 응답, 타이밍에 안전한 비교
- MFA: TOTP는 모두가 사용할 수 있으며 관리자/권한 있는 역할에는 필수입니다.
- OAuth 2.1: PKCE 필요, 암시적 흐름 없음, 비밀번호 부여 없음
- 패스키: 새로운 프로젝트를 위한 비밀번호 없는 대안으로 고려
- 감사 로그: IP, 사용자 에이전트 및 타임스탬프와 함께 모든 인증 이벤트를 기록합니다.
- 각도 가드: UX에 대해서만 실제 보안은 항상 백엔드에서만 가능합니다.
- CSRF: 교차 출처 세션 쿠키를 사용하는 SPA용 CSRF 토큰
결론
2025년의 보안 인증에는 다층적인 전략이 필요합니다. Argon2id, HttpOnly+Secure+SameSite 쿠키를 사용한 올바른 세션 관리, 속도 제한 및 계정 무차별 대입 잠금, 모든 권한 있는 사용자를 위한 TOTP가 포함된 MFA 및 PKCE가 포함된 OAuth 2.1 연합 인증을 위해. 각 계층은 특정 범주의 공격을 완화합니다. 그것도 건너뛰세요 단 하나만 실제 취약점의 창을 엽니다.
향후 몇 년 동안 가장 중요한 변화는 패스키: 피싱 방지 설계, UX를 통해 데이터베이스에 기억하거나 해시할 비밀번호가 없음 Face ID와 Touch ID 덕분에 더욱 뛰어납니다. 2025년에 새로운 애플리케이션을 구축하는 경우 다음을 고려하세요. 첫 번째 인증 요소로 암호키를 심각하게 사용합니다.
마지막으로, AI 생성 코드에 대한 중요한 참고 사항: 인증 코드의 45%가 AI 생성 코드에 의해 생성됩니다. AI 도구가 보안 테스트에 실패했습니다. 가장 일반적인 문제는 이후에 재생성되지 않은 세션입니다. 로그인, localStorage에 저장된 JWT, 타이밍 안전 비교 부족, 쿠키 없음 올바른 보안 플래그. 항상 이 OWASP 체크리스트를 최종 코드 검토로 사용하세요. 누가 (또는 무엇을) 썼는지에 관계없이.
웹 보안 시리즈 계속하기
- 이전 기사: SQL 주입 및 입력 검증: 백엔드 보안
- 다음 기사: API 보안: OAuth 2.1, JWT 및 속도 제한
- 보안 관련: XSS, CSRF 및 CSP: 프런트엔드 보안
- 관련 암호화: 암호화 오류: 해싱, 암호화 및 토큰
- 관련 시리즈: DevOps 프런트엔드: DevSecOps 파이프라인







