04 - 安全な認証: セッション、Cookie、モダン ID
認証とアプリケーションの入場ゲート。脆弱な場合は、その他のセキュリティ対策
回避できます。 Verizon DBIR によると、2024 年には、 データ侵害の 74% には認証情報が関係していました
妥協した またはアイデンティティ関連の攻撃。それでも、ほとんどのアプリケーションはまだ実装しています
2010 認証パターン: 保護されていない Cookie を使用したサーバー側セッション、ログ内のプレーンテキスト パスワード、
MFA なし、JWT トークンは次の場所に保存されます。 localStorage.
2025 年の認証の状況は根本的に変わります。 OAuth 2.1 の統合ベスト プラクティス すべてのクライアントに PKCE を必須にすることでセキュリティを強化します。 WebAuthn ベースのパスキーが置き換えられています Apple、Google、Microsoft が従来のパスワードを標準として推進しています。 NIST SP 800-63B 定期的なローテーション要件を削除することにより、パスワード ガイドラインを改訂しました。この記事はあなたに役立ちます 実際のコード、一般的な落とし穴、および Node.js アプリケーションのセキュリティ チェックリスト。
何を学ぶか
- 安全なセッション管理: HttpOnly、Secure、SameSite Cookie およびセッション固定防止
- JWT のベスト プラクティスとトークンを不安定にする 5 つの致命的な間違い
- PKCE を使用した OAuth 2.1: SPA およびモバイル アプリケーションの認証コード フロー
- WebAuthn とパスキー: SimpleWebAuthn による実用的な実装
- TOTP による MFA: Google Authenticator および otpauth を介した Authy との統合
- RBAC、ABAC、および ReBAC: 最新のアプリケーションの認可モデル
- レート制限と監査ログを備えた完全な Express.js 認証ミドルウェア
- OWASP チェックリスト A07:2021 (識別および認証の失敗)
セッション管理: 基本
セッション管理と、サーバーが認証されたユーザーを「記憶」するメカニズム ステートレス HTTP リクエスト。安全なセッションには、次の 4 つの基本的なプロパティが必要です。 予測不可能な識別子, 安全な送信, 有効期限の管理 e ログアウト時の正しい無効化.
主な攻撃ベクトルは、 セッション固定: 攻撃者が被害者に提供する
ログインする前に既知のセッション ID を取得し、認証後にその同じ ID を再利用してなりすます
ユーザー。対策としては、ログイン後に必ずセッションIDを再生成することです。 2 番目のベクトルは
セッションハイジャック XSS 経由、フラグ付き Cookie によって軽減される HttpOnly。 3番目
ベクトルは 孤立セッション: サーバー側は残っているセッションを無効にしませんでした
クライアントが Cookie を削除した後でもアクティブであるため、盗まれたセッション 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)、
またはMongoDB (connect-mongo)。 Redis はパフォーマンスの観点から推奨される選択肢です
期限切れのキーの最適な自動 TTL。
Cookie のセキュリティ: 重要なフラグ
セッション Cookie は、少なくとも 3 つのセキュリティ属性を使用して構成する必要があります。みんなで緩和する 特定のカテゴリの攻撃。正しい組み合わせは HttpOnly + Secure + SameSite です。 階層化された保護のために一緒に構成されます。
| 属性 | 推奨値 | 脅威が軽減されました |
|---|---|---|
HttpOnly |
true |
XSS: JavaScript が Cookie を読み取ることができません |
Secure |
true |
MITM: Cookie は HTTPS 経由でのみ送信されます |
SameSite |
Strict o Lax |
CSRF: 不正なクロスオリジン送信をブロックする |
MaxAge |
通常セッションの場合は 8 ~ 24 時間 | 孤立セッション: ブラウザ側の自動有効期限切れ |
Domain |
ドメイン固有、ワイルドカードなし | 侵害されたサブドメインでの Cookie の漏洩 |
次の間の選択 SameSite=Strict e SameSite=Lax それはユースケースによって異なります。
厳しい 最大限の保護を提供しますが、ユーザーが閲覧している場合でも Cookie をブロックします
外部リンク (電子メールや他のサイトなど) からサイトにアクセスすると、毎回ログインがリダイレクトされます。
ラックス トップレベルのナビゲーション (リンクをクリック) で Cookie を許可しますが、次の期間はブロックします。
POST/PUT/DELETE クロスオリジンリクエストにより、セキュリティと使いやすさのバランスが取れています。
別のドメインの SPA で使用される API の場合は、次を使用します。 SameSite=None; Secure 保護付き
ヘッダー内のトークンを介した CSRF。
JWT のベスト プラクティスと 5 つの致命的な間違い
JSON Web トークンは、API でのステートレス認証のための強力なツールですが、 不適切な実装は、重大な脆弱性の最も一般的な原因の 1 つです。 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 ストレージ: Cookie HttpOnly と localStorage の比較
多くのチュートリアルでは、JWT を保存することを推奨しています。 localStorage 使い方が簡単なので
スパ付き。 認証トークンに対しては決してこれを行わないでください。 localStorage とアクセス可能
ページ上で実行されているあらゆる JavaScript スクリプト (侵害されたサードパーティのライブラリを含む) から。
npm 依存関係に対する XSS 攻撃は、すべてのトークンをサイレントに盗む可能性があります。
正しい解決策は使用することです cookie HttpOnly + Secure + SameSite=Strict:
JavaScript ではアクセスできず、HTTPS 経由でのみ送信され、CSRF 保護が組み込まれています。
クロスオリジン API リクエストを行う必要がある SPA の場合は、次を使用します。 SameSite=None; Secure
リクエストヘッダーにCSRFトークンを含めます。
PKCE を使用した OAuth 2.1: 安全なフェデレーション認証
OAuth 2.1 (RFC ドラフト、2024 年に統合) は、長年にわたって蓄積されたセキュリティのベスト プラクティスを統合します OAuth 2.0 導入、レンダリングの PKCE (コード交換用の証明キー) が必要です クライアント シークレットを持つ機密クライアントを含むすべてのクライアント。暗黙的なフローも排除します およびパスワード付与は、本質的に安全でなく、非推奨であると考えられています。
PKCE を使用した認証コード フローは次のように機能します。クライアントは コード検証者 ランダム (43 ~ 128 文字)、 コードチャレンジ SHA-256 Base64URL のような、 チャレンジを認可サーバーに送信し、認可コードを受信した後、それを交換します 元の検証者を提示してトークンを取得します。たとえ攻撃者が途中でコードを傍受したとしても、 リダイレクトは、クライアント内に秘密のままであるベリファイアなしでは交換できません。
// 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 によって保護されますが、サーバーは公開キーのみを知っています。
結果は1つです フィッシングに対する絶対的な耐性 (キーは暗号化されています) 元のドメインにリンクされています)、データベース侵害時にパスワードを盗む必要がなく、優れた UX を備えています。 パスワード + 2FA の代わりに Face ID または Touch ID。 2025年、その先へ 150億アカウント パスキーをサポートします。図書館 シンプルウェブ認証 大幅に簡素化されます 実装。
// 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: 2 番目の要素
多要素認証 (MFA) は、クレデンシャル スタッフィングに対する最も効果的な対策です。 古典的なパスワードベースのフィッシング。 Microsoft によると、MFA はブロックを超えます 99.9% アカウントに対する自動攻撃。 TOTP (時間ベースのワンタイム パスワード、RFC 6238) および lo 第二要素ソフトウェアのデファクトスタンダード、Google Authenticator、Authy、 1Password および TOTP 準拠のアプリ。
見落とされがちな重要な側面: MFA フローは次のとおりである必要があります。 アトミックであり回避できません。
部分的に認証されたセッションを許可する最初の要素の後には、決して作成しないでください。
あらゆる操作。代わりに、フラグ付きの一時セッションを使用してください。 mfaPending: true、
2 番目の要素が検証された後にのみ完全なアクセスを許可します。
// 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
認証 (あなたは誰ですか?) と認可 (何ができるの?) は別の概念ですが、多くの場合、 同じミドルウェアにまとめて実装されるため、テストと保守が困難なコードが作成されます。 ID が検証されると、システムはユーザーに次の権限があるかどうかを判断する必要があります。 必要な特定の操作。主要なモデルは 3 つあります。
RBAC (ロールベースのアクセス制御) 事前定義された役割と役割に権限を割り当てる ユーザーへ。これは実装が最も簡単なモデルであり、ほとんどの用途に適しています。 アプリケーション。制限と硬直性: テナントや個人リソースが多い場合、役割の数が増える 爆発する。
ABAC (属性ベースのアクセス制御) ユーザー属性を評価する (役割、部門、レベル)、リソース (所有者、分類、テナント)、および コンテキスト (時間、IP、デバイス)。 RBAC よりも柔軟性がありますが、実装とデバッグはより複雑です。
ReBAC (関係ベースのアクセス制御) 関係に基づく権限 データグラフ内のエンティティ間 (例: Google ザンジバル)。 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: どちらを選択するべきですか?
- Argon2id (新規プロジェクトに推奨): 2015 パスワード ハッシュ コンペティションの優勝者。 GPU (メモリハード) およびサイドチャネル攻撃に耐性があります。推奨パラメータ:memoryCost 64MB、timeCost 3、並列処理 4。
- bcrypt (レガシー システムに適しています): コスト係数 >= 12 であればまだ安全です。 警告: 72 文字を超えるパスワードはサイレントに切り捨てられます - この制限を処理するプレハッシュまたはラッパーを常に使用してください。
- 暗号: Argon2 の優れた代替品ですが、より複雑で開発者向けのパラメータ化があまり文書化されていません。
- MD5、SHA-1、SHA-256、塩なし: パスワードには決して使用しないでください。これらは高速ハッシュ関数であり、鍵導出関数ではありません。最新の GPU を備えた攻撃者は、1 秒あたり数十億のハッシュを検証できます。
// 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ナビゲーション、データセキュリティではありません。 攻撃者は URL を変更するか API を呼び出すことで、いつでも Angular をバイパスできます。 Curl や Postman などのツールから直接。本当のセキュリティは常にバックエンドにのみ存在します。
ルートガードはセキュリティではありません
I CanActivate Angular ガードは 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() - クッキーフラグ: すべてのセッションおよび認証 Cookie に対する HttpOnly + Secure + SameSite
- ログアウト: Cookie を削除するだけではなく、サーバー側のセッションを破棄します。
- セッションタイムアウト: 非アクティブの有効期限 (機密データの場合は 30 ~ 60 分)
- JWT アルゴリズム: 明示的なホワイトリストを指定し、「なし」および予期しないアルゴリズムをブロックします
- JWT ストレージ: HttpOnly Cookie、localStorage または sessionStorage は使用しない
- ブルートフォース: IP のレート制限 + ユーザー通知によるアカウント ロックアウト
- ユーザーの列挙: 不正な認証情報に対する一般的な応答、タイミングセーフな比較
- MFA: TOTP は全員が利用可能、管理者/特権ロールには必須
- OAuth 2.1: PKCE が必要、暗黙的なフローなし、パスワード付与なし
- パスキー: 新しいプロジェクトのパスワードレスの代替手段として検討してください
- 監査ログ: IP、ユーザーエージェント、タイムスタンプを含むすべての認証イベントをログに記録します
- 角度ガード: UX の場合のみ、真のセキュリティは常にバックエンドのみにあります
- CSRF: クロスオリジン セッション Cookie を使用する SPA の CSRF トークン
結論
2025 年の安全な認証には、多層戦略が必要です。パスワードを強力にハッシュ化することです。 Argon2id、HttpOnly+Secure+SameSite Cookie による正しいセッション管理、レート制限およびアカウント ブルート フォース ロックアウト、すべての特権ユーザーに対する TOTP を使用した MFA、PKCE を使用した OAuth 2.1 フェデレーション認証の場合。各層は特定のカテゴリの攻撃を軽減します。それもスキップしてください たった 1 つだけが本当の脆弱性の窓を開きます。
今後数年間で最も重要な変化は、 パスキー: 設計によりフィッシングに耐性があり、パスワードを覚えたりデータベースでハッシュしたりする必要がなく、UX を備えています Face ID と Touch ID のおかげで優れています。 2025 年に新しいアプリケーションを構築する場合は、次のことを検討してください。 パスキーは最初の認証要素として重要です。
最後に、AI によって生成されたコードに関する重要な注意事項: 認証コードの 45% は、AI によって生成されます。 AIツールはセキュリティテストに合格しません。最も一般的な問題は、セッションが再生成されないことです。 ログイン、localStorage に保存された JWT、タイミングセーフな比較の欠如、Cookie の欠如 正しいセキュリティフラグ。最終的なコード レビューとして、常にこの OWASP チェックリストを使用してください。 誰が(または何を)書いたかは関係ありません。
Webセキュリティシリーズの続き
- 前の記事: SQL インジェクションと入力検証: バックエンドのセキュリティ
- 次の記事: API セキュリティ: OAuth 2.1、JWT、レート制限
- セキュリティ関連: XSS、CSRF、および CSP: フロントエンド セキュリティ
- 関連する暗号化: 暗号エラー: ハッシュ、暗号化、トークン
- 関連シリーズ: DevOps フロントエンド: DevSecOps パイプライン







