Guida Completa all'Autenticazione con JSON Web Token (JWT): Architettura, Implementazione e Sicurezza

Intermedio
Sicurezza web

Scopri come implementare l'autenticazione stateless utilizzando i JSON Web Token (JWT). Analisi approfondita di struttura, flussi di lavoro e best practice di sicurezza.

Pubblicato
Tag
Sicurezza web API REST backend nodejs Autenticazione JWT

Introduzione ai JSON Web Token (JWT)

Nel panorama moderno dello sviluppo web, l'autenticazione è passata da un modello centralizzato basato sulle sessioni (stateful) a un modello distribuito e scalabile (stateless). Il protagonista di questa evoluzione è il JSON Web Token (JWT).

Un JWT è uno standard aperto (RFC 7519) che definisce un modo compatto e autonomo per trasmettere informazioni tra due parti come un oggetto JSON. Questa informazione può essere verificata e trustata perché è digitalmente firmata. A differenza delle sessioni tradizionali, dove il server deve memorizzare l'ID della sessione in un database o in una cache (come Redis) per ogni utente connesso, il JWT sposta la responsabilità dello stato dal server al client.

Perché questo è fondamentale? In un'architettura a microservizi o in applicazioni che scalano orizzontalmente su più server, sincronizzare le sessioni tra diverse istanze diventa un incubo tecnico. Il JWT risolve questo problema: ogni richiesta del client contiene tutto ciò di cui il server ha bisogno per autenticare l'utente, eliminando la necessità di query al database per ogni singola chiamata API.

Anatomia di un JWT: Come funziona internamente

Un token JWT non è una stringa casuale, ma è composto da tre parti separate da un punto (.), codificate in Base64Url: Header, Payload e Signature.

1. L'Header

L'header tipicamente consiste di due parti: il tipo del token (che è JWT) e l'algoritmo di hashing utilizzato per firmare il token, come HMAC SHA256 (HS256) o RSA.

2. Il Payload

Il payload contiene i cosiddetti "claims". I claims sono dichiarazioni riguardanti un'entità (tipicamente l'utente) e dati aggiuntivi. Esistono tre tipi di claims:

  • Registered claims: Claims predefiniti come iss (issuer), exp (expiration time), sub (subject).
  • Public claims: Definiti dagli utenti, ma dovrebbero essere univoci per evitare collisioni.
  • Private claims: Informazioni personalizzate condivise tra le parti (es. user_role o user_id).

3. La Firma (Signature)

La firma è la parte più critica. Viene creata prendendo l'header e il payload codificati, unendo loro una chiave segreta (secret key) e applicando l'algoritmo specificato nell'header. Questo garantisce che il token non sia stato alterato durante il trasporto.

Ecco un esempio concettuale di come viene generata la firma: HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

Implementazione Pratica con Node.js e Express

Per implementare l'autenticazione JWT, utilizzeremo la libreria jsonwebtoken, lo standard de facto per l'ecosistema Node.js. In questo esempio, creeremo un sistema semplice di login che genera un token e un middleware per proteggere le rotte.

const express = require('express');
const jwt = require('jsonwebtoken');
const bodyParser = require('body-parser');

const app = express();
const SECRET_KEY = 'la_tua_chiave_segretissima_123'; // In produzione usa variabili d'ambiente

app.use(bodyParser.json());

// Rotta di Login: Verifica le credenziali e genera il token
app.post('/login', (req, res) => {
    const { username, password } = req.body;

    // Simulazione verifica utente nel database
    if (username === 'admin' && password === 'password123') {
        const payload = {
            userId: 1,
            username: 'admin',
            role: 'administrator'
        };

        // Generazione del token con scadenza di 1 ora
        const token = jwt.sign(payload, SECRET_KEY, { expiresIn: '1h' });
        
        return res.json({ 
            message: 'Autenticazione riuscita', 
            token: token 
        });
    }

    res.status(401).json({ message: 'Credenziali non valide' });
});

// Middleware per l'autenticazione
const authenticateToken = (req, res, next) => {
    const authHeader = req.headers['authorization'];
    const token = authHeader && authHeader.split(' ')[1]; // Formato: 'Bearer <token>'

    if (!token) return res.status(401).json({ message: 'Token mancante' });

    jwt.verify(token, SECRET_KEY, (err, user) => {
        if (err) return res.status(403).json({ message: 'Token non valido o scaduto' });
        req.user = user;
        next();
    });
};

// Rotta protetta
app.get('/dashboard', authenticateToken, (req, res) => {
    res.json({
        message: `Benvenuto nel pannello di controllo, ${req.user.username}!`,
        userData: req.user
    });
});

app.listen(3000, () => console.log('Server in ascolto su http://localhost:3000'));

In questo codice, abbiamo implementato il flusso base: l'utente invia credenziali, il server risponde con un token firmato, e ogni richiesta successiva deve includere questo token nell'header Authorization utilizzando lo schema Bearer.

Strategie di Memorizzazione e Sicurezza

Uno degli errori più comuni commessi dagli sviluppatori è salvare il JWT in localStorage. Sebbene sia semplice, espone l'applicazione agli attacchi XSS (Cross-Site Scripting). Se un malintenzionato riesce a iniettare uno script nella tua pagina, può leggere facilmente il token e rubare l'identità dell'utente.

La soluzione: HttpOnly Cookies

La pratica raccomandata è memorizzare il JWT in un cookie con l'attributo HttpOnly. Questo impedisce a JavaScript di accedere al cookie, neutralizzando l'estrazione del token via XSS.

// Esempio di invio token tramite cookie sicuro
res.cookie('token', token, {
    httpOnly: true, // Impedisce l'accesso via JS
    secure: true,   // Solo via HTTPS
    sameSite: 'Strict', // Protezione contro CSRF
    maxAge: 3600000 // 1 ora
});

Gestione del CSRF

Passando ai cookie, ci esponiamo agli attacchi CSRF (Cross-Site Request Forgery). Per mitigare questo rischio, è fondamentale utilizzare l'attributo SameSite: Strict o Lax e, per operazioni critiche, implementare token anti-CSRF o richiedere l'header Custom-Header che non può essere inviato automaticamente dai form HTML.

Il problema della Revoca: Access Tokens vs Refresh Tokens

Poiché i JWT sono stateless, una volta emessi, sono validi fino alla loro scadenza. Se un token viene rubato, l'attaccante ha accesso completo fino a quando il token non scade. Non puoi semplicemente "cancellare la sessione" dal server perché il server non tiene traccia dei token.

Per risolvere questo problema, si utilizza l'architettura a doppio token:

1. Access Token

  • Durata: Breve (es. 15 minuti).
  • Scopo: Autenticare le richieste API.
  • Memorizzazione: Memoria volatile o cookie HttpOnly.

2. Refresh Token

  • Durata: Lunga (es. 7 giorni).
  • Scopo: Ottenere un nuovo Access Token senza richiedere nuovamente le credenziali all'utente.
  • Memorizzazione: Database lato server e cookie HttpOnly.

Il flusso funziona così:

  1. L'utente effettua il login $
    ightarrow$ riceve AccessToken e RefreshToken.
  2. L'utente usa l' AccessToken per le API.
  3. L' AccessToken scade $
    ightarrow$ il server risponde con 401.
  4. Il client invia il RefreshToken a un endpoint specifico (/refresh).
  5. Il server verifica che il RefreshToken sia ancora valido nel database e non sia stato revocato.
  6. Il server emette un nuovo AccessToken.

Grazie a questo sistema, se un utente segnala il furto dell'account, l'amministratore può eliminare il RefreshToken dal database, rendendo impossibile l'ottenimento di nuovi Access Token.

Esempi Pratici: Casi d'Uso Reali

Scenario A: API REST per Mobile App

In un'app mobile, non esistono i cookie nel senso tradizionale del web. Qui il JWT è perfetto: l'app salva il token in un archivio sicuro (come iOS Keychain o Android Keystore) e lo invia nell'header Authorization. Questo permette all'app di rimanere connessa per giorni senza dover richiedere l'email e la password a ogni avvio.

Scenario B: Single Sign-On (SSO)

Il JWT è la base di protocolli come OpenID Connect. Immagina di avere tre applicazioni diverse (es. Mail, Calendar, Drive) che condividono lo stesso sistema di login. Un server di autenticazione centrale emette un JWT che contiene l'identità dell'utente. Ogni applicazione può verificare la firma del token usando la chiave pubblica del server centrale, senza dover interrogare un database utenti comune.

Errori comuni e FAQ

Q: Posso mettere la password dell'utente nel payload del JWT? A: Assolutamente NO. Il payload è solo codificato in Base64, non è criptato. Chiunque intercetti il token può decodificarlo istantaneamente usando strumenti online come jwt.io. Inserisci solo ID, ruoli e metadati non sensibili.

Q: Qual è la differenza tra HS256 e RS256? A: HS256 è un algoritmo simmetrico: la stessa chiave viene usata per firmare e verificare. È ideale se il server che emette il token è lo stesso che lo verifica. RS256 è asimmetrico: si usa una chiave privata per firmare e una chiave pubblica per verificare. È essenziale per l'SSO, dove terze parti devono verificare il token senza poterlo creare.

Q: Cosa succede se cambio la chiave segreta? A: Tutti i token emessi in precedenza diventeranno istantaneamente non validi. Questo è utile in caso di leak della chiave, ma costringerà tutti gli utenti a effettuare nuovamente il login.

Prossimi passi e approfondimenti

L'autenticazione JWT è un potente strumento, ma richiede disciplina. Per evolvere le tue competenze, ti suggerisco di esplorare i seguenti argomenti:

  1. OAuth 2.0: Comprendi come JWT si inserisce nel framework di autorizzazione più usato al mondo.
  2. Redis per Blacklisting: Implementa una "blacklist" di token revocati in Redis per gestire il logout immediato degli Access Token.
  3. Asymmetric Signing: Prova a implementare RS256 utilizzando chiavi .pem per creare un sistema di autenticazione distribuito.
  4. OpenID Connect (OIDC): Studia come estendere OAuth 2.0 per l'identità degli utenti.

Ricorda: la sicurezza non è un prodotto, ma un processo. Testa sempre le tue implementazioni con strumenti di penetration testing e tieni aggiornate le tue dipendenze.