Proteggere le API con JWT e gestire i permessi in Express e TypeScript
Nel tutorial precedente ho implementato la registrazione e il login degli utenti. Dopo aver verificato le credenziali, il backend genera un JSON Web Token che il client può utilizzare nelle richieste successive. Ho quindi un sistema capace di riconoscere un utente, ma manca ancora un passaggio fondamentale: impedire che chiunque possa accedere alle operazioni che dovrebbero essere riservate agli utenti autenticati.
In questo tutorial collegherò il sistema di autenticazione alle API già realizzate. Il client invierà il token nell'header HTTP Authorization, mentre Express utilizzerà un middleware per verificarne la firma, controllare la scadenza e recuperare l'identità dell'utente. Se il token è valido, la richiesta potrà proseguire; in caso contrario, il server la rifiuterà.
Aggiungerò anche una prima forma di autorizzazione basata sui ruoli. Un utente autenticato non deve necessariamente poter eseguire tutte le operazioni disponibili: per esempio, posso consentire a tutti gli utenti registrati di creare prodotti e riservare l'eliminazione agli amministratori. Questa separazione è il punto di partenza per politiche di accesso più articolate.
Alla fine del tutorial avrò un middleware riutilizzabile, un controllo dei ruoli e delle rotte protette. Il codice resterà organizzato in moduli distinti, così potrò applicare le stesse regole anche a eventuali future API per ordini, profili e altre risorse senza riscrivere ogni volta la verifica del token.
Autenticazione e autorizzazione sono due controlli diversi
L'autenticazione verifica l'identità dell'utente. Nel nostro progetto avviene attraverso il JWT generato dopo il login. Il server controlla che il token sia valido e recupera l'identificativo dell'utente e il suo ruolo.
L'autorizzazione stabilisce invece quali operazioni quell'utente può eseguire. Per esempio, due utenti possono essere entrambi autenticati, ma soltanto uno può avere il ruolo admin. Il secondo controllo deve essere applicato dopo il primo: non posso autorizzare un utente basandomi su un ruolo che non ho verificato.
Un errore comune consiste nel nascondere un pulsante nell'interfaccia e considerare protetta l'operazione corrispondente. Il client non è una barriera di sicurezza: chiunque può inviare una richiesta HTTP direttamente con curl, Postman o uno script. Le verifiche devono quindi essere eseguite dal backend per ogni richiesta interessata.
Come funziona il middleware di autenticazione
Express permette di inserire funzioni middleware nella catena di elaborazione delle richieste. Un middleware può controllare la richiesta, aggiungere informazioni, restituire una risposta oppure chiamare next() per passare il controllo alla funzione successiva.
Creerò un middleware chiamato authenticate. Questo leggerà l'header Authorization, si aspetterà un token nel formato Bearer e verificherà il JWT utilizzando il segreto configurato nel tutorial precedente.
Se il token è valido, il middleware memorizzerà nella richiesta l'identificativo dell'utente e il suo ruolo. Le rotte successive potranno utilizzare queste informazioni senza dover decodificare nuovamente il token.
Aggiungere il tipo dell'utente alla richiesta Express
TypeScript non conosce automaticamente una proprietà personalizzata come req.auth. Per dichiararla correttamente, creo il file src/types/express.d.ts.
import type { UserRole } from '../models/user.js';
declare global {
namespace Express {
interface Request {
auth?: {
userId: string;
role: UserRole;
};
}
}
}
export {};La dichiarazione estende il tipo Request fornito da Express. La proprietà auth è opzionale perché una richiesta pubblica, che non passa dal middleware di autenticazione, potrebbe non contenerla.
Quando una rotta è protetta, sarà il middleware a valorizzare req.auth. Il tipo UserRole è quello già definito nel modello dell'utente e limita il ruolo ai valori previsti dal progetto: user e admin.
Il file deve trovarsi dentro la directory src, inclusa dalla configurazione TypeScript utilizzata nei tutorial precedenti. Non devo importarlo manualmente nell'applicazione: TypeScript caricherà la dichiarazione durante la compilazione.
Implementare il middleware JWT
Creo la directory src/middleware e il file src/middleware/authenticate.ts.
import type { RequestHandler } from 'express';
import jwt, { type JwtPayload } from 'jsonwebtoken';
export const authenticate: RequestHandler = (
req,
res,
next,
) => {
const authorization = req.headers.authorization;
if (!authorization?.startsWith('Bearer ')) {
res.status(401).json({
error: 'Autenticazione richiesta',
});
return;
}
const token = authorization.slice(7).trim();
if (!token) {
res.status(401).json({
error: 'Token mancante',
});
return;
}
const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) {
next(new Error('JWT_SECRET non configurato'));
return;
}
try {
const decoded = jwt.verify(token, jwtSecret);
if (
typeof decoded === 'string' ||
typeof decoded.sub !== 'string' ||
(decoded.role !== 'user' && decoded.role !== 'admin')
) {
res.status(401).json({
error: 'Token non valido',
});
return;
}
const payload: JwtPayload = decoded;
req.auth = {
userId: payload.sub!,
role: payload.role,
};
next();
} catch {
res.status(401).json({
error: 'Token non valido o scaduto',
});
}
};Il middleware controlla innanzitutto che l'header sia presente e rispetti il formato atteso. Il prefisso Bearer è una convenzione comunemente utilizzata per trasmettere token di accesso nell'header HTTP Authorization.
La funzione jwt.verify() verifica la firma e la validità temporale del token. Non basta decodificare il JWT: un client potrebbe costruire un payload con un ruolo inventato. La verifica della firma impedisce di accettare modifiche non autorizzate al contenuto, purché il segreto rimanga protetto.
Dopo la verifica controllo anche il formato delle informazioni che mi servono. In particolare, richiedo un sub di tipo stringa e un ruolo tra quelli ammessi. Se il token non è valido, è scaduto o non contiene le dichiarazioni previste, la richiesta viene rifiutata con 401 Unauthorized.
La proprietà req.auth viene impostata soltanto dopo tutti questi controlli. Infine, next() permette alla richiesta di raggiungere la rotta successiva. Se manca la configurazione del segreto, invece, inoltro un errore al middleware centralizzato di Express: si tratta di un problema del server, non di un errore nelle credenziali dell'utente.
Creare un middleware per i ruoli
Avere un token valido non significa avere ogni permesso. Creo quindi src/middleware/authorize.ts, che conterrà un middleware riutilizzabile per richiedere uno o più ruoli.
import type { RequestHandler } from 'express';
import type { UserRole } from '../models/user.js';
export function requireRole(
...allowedRoles: UserRole[]
): RequestHandler {
return (req, res, next) => {
if (!req.auth) {
res.status(401).json({
error: 'Autenticazione richiesta',
});
return;
}
if (!allowedRoles.includes(req.auth.role)) {
res.status(403).json({
error: 'Permessi insufficienti',
});
return;
}
next();
};
}La funzione accetta un elenco di ruoli consentiti. Per esempio, requireRole('admin') permette di proseguire soltanto agli amministratori, mentre requireRole('user', 'admin') accetta entrambi i ruoli.
La differenza tra 401 e 403 è importante. Uso 401 Unauthorized quando manca un'autenticazione valida. Uso 403 Forbidden quando l'utente è stato riconosciuto, ma non ha il permesso richiesto.
Questo middleware non verifica direttamente il JWT. Si fida delle informazioni presenti in req.auth, che devono essere state inserite dal middleware authenticate. Per questo l'ordine con cui applico i middleware è essenziale.
Proteggere le rotte dei prodotti
Nel progetto esiste già src/routes/products.routes.ts, che contiene le operazioni CRUD dei prodotti. Ora posso scegliere quali operazioni proteggere e quali lasciare pubbliche.
Per questo esempio manterrò pubbliche la consultazione dell'elenco e quella del singolo prodotto. Richiederò invece un token per creare e modificare prodotti, mentre riserverò l'eliminazione agli amministratori.
All'inizio del file products.routes.ts aggiungo gli import:
import { authenticate } from '../middleware/authenticate.js';
import { requireRole } from '../middleware/authorize.js';A questo punto modifico le definizioni delle rotte interessate. Per creare un prodotto:
router.post('/', authenticate, async (req, res) => {
// Mantieni qui la validazione e il codice CREATE esistenti.
});Per modificare un prodotto:
router.patch('/:id', authenticate, async (req, res) => {
// Mantieni qui la validazione e il codice UPDATE esistenti.
});Per eliminarlo:
router.delete(
'/:id',
authenticate,
requireRole('admin'),
async (req, res) => {
// Mantieni qui il codice DELETE esistente.
},
);Questi esempi mostrano dove inserire i middleware: non devi sostituire l'intero contenuto delle funzioni CRUD con i commenti. Mantieni il codice già implementato e modifica soltanto le firme delle rotte.
Express esegue i middleware nell'ordine indicato. Per la cancellazione, prima authenticate verifica il token e valorizza req.auth; successivamente requireRole('admin') controlla il ruolo. Solo se entrambi i controlli hanno successo la funzione che elimina il prodotto viene eseguita.
Le rotte GET restano pubbliche per scelta didattica. In un'applicazione reale la politica dipende dai dati trattati e dai requisiti del prodotto: non esiste una regola universale secondo cui tutte le letture debbano essere pubbliche.
Verificare che il router sia montato correttamente
Nel file src/app.ts il router dei prodotti deve essere già collegato con il prefisso utilizzato nel tutorial precedente:
app.use('/api/products', productsRouter);Non è necessario aggiungere nuovamente il router se è già presente. I middleware sono stati inseriti direttamente nelle definizioni delle rotte, quindi Express li eseguirà automaticamente quando le richieste raggiungeranno gli endpoint interessati.
Anche il middleware centralizzato per gli errori deve restare dopo le rotte, in modo che possa ricevere gli errori inoltrati da next(error). Gli errori gestiti direttamente dal middleware JWT, come un token scaduto, producono invece subito una risposta 401.
Provare le richieste senza autenticazione
Avvio il server e provo a creare un prodotto senza token:
curl -i -X POST http://localhost:3000/api/products \
-H 'Content-Type: application/json' \
-d '{"name":"Tastiera","price":49.90}'Mi aspetto 401 Unauthorized, perché la richiesta non contiene l'header Authorization. La stessa chiamata, con un token valido appartenente a un utente normale, dovrebbe raggiungere la funzione di creazione e restituire 201 Created, secondo il codice CRUD già presente.
Per ottenere un token, effettuo prima il login:
curl -s -X POST http://localhost:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"dev@example.com","password":"Password-di-test-123"}'Copio il valore di accessToken dalla risposta. Poi lo invio nella richiesta protetta:
curl -i -X POST http://localhost:3000/api/products \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer INCOLLA_QUI_IL_TOKEN' \
-d '{"name":"Tastiera","price":49.90}'Sostituisco INCOLLA_QUI_IL_TOKEN con il token effettivo, senza virgolette aggiuntive all'interno del valore. Se il token è valido e non è scaduto, il middleware permette alla richiesta di proseguire.
Verificare i permessi amministrativi
L'endpoint DELETE richiede sia un token valido sia il ruolo admin. Gli utenti appena registrati ricevono intenzionalmente il ruolo user, quindi riceveranno 403 Forbidden quando tenteranno di eliminare un prodotto.
Per verificare il comportamento in ambiente di sviluppo posso promuovere manualmente un account di test ad amministratore tramite MongoDB Compass o mongosh. Devo modificare il documento nella collection users, impostando role a admin, e poi effettuare nuovamente il login per ottenere un token con il ruolo aggiornato.
È importante effettuare un nuovo login dopo la modifica, perché il ruolo è contenuto nel token emesso in precedenza. Il middleware verifica la firma del token, non rilegge automaticamente il documento dell'utente dal database a ogni richiesta.
Non esporrò mai un endpoint pubblico che consenta a un utente di modificare il proprio ruolo. Un simile meccanismo permetterebbe a chiunque di diventare amministratore.
Un limite importante: il proprietario delle risorse
Il controllo dei ruoli è utile, ma non risolve tutti i problemi di autorizzazione. Nel nostro esempio un utente autenticato può modificare un prodotto conoscendone l'ID, perché il modello CRUD originale non associa i prodotti al loro proprietario.
In un'applicazione reale devo decidere se i prodotti sono risorse condivise oppure appartengono a utenti specifici. Se sono privati, dovrò aggiungere un campo come ownerId e verificare che corrisponda all'identificativo presente in req.auth.userId. Il controllo deve avvenire nella query MongoDB stessa, non soltanto nell'interfaccia client.
Per esempio, una modifica potrebbe cercare il prodotto usando contemporaneamente _id e ownerId. In questo modo un utente non potrebbe aggiornare una risorsa appartenente a un altro utente semplicemente indovinandone l'identificativo. Il codice di questo tutorial introduce i meccanismi fondamentali; la gestione della proprietà delle risorse è un'estensione necessaria quando il dominio applicativo lo richiede.
Considerazioni sulla sicurezza
Il JWT deve avere una scadenza limitata e il segreto di firma deve rimanere riservato. Il token va trasmesso esclusivamente su HTTPS quando il backend è esposto a una rete. Non deve essere scritto nei log o inserito in URL, perché potrebbe finire nella cronologia del browser o nei log dei server intermedi.
Il sistema mostrato non include ancora refresh token, revoca immediata dei token o un sistema completo di gestione degli account. Per molte applicazioni occorrono anche rate limiting sul login, procedure di recupero password e una politica per disabilitare gli account.
Infine, l'autorizzazione deve essere verificata su ogni endpoint sensibile. Proteggere soltanto la rotta principale non serve se esiste un'altra rotta che permette di eseguire la stessa operazione senza controlli. È una revisione da fare ogni volta che aggiungo nuove API.
Conclusione
Con questo tutorial ho collegato l'autenticazione JWT alle API esistenti. Il middleware authenticate verifica il token e identifica l'utente; requireRole controlla se il suo ruolo è autorizzato a eseguire l'operazione richiesta.
Ho inoltre separato le responsabilità: la verifica del token non è mescolata alla logica CRUD e i controlli dei ruoli possono essere riutilizzati su altre rotte. È una struttura più semplice da mantenere e da testare.
Il backend ora distingue tra richieste anonime, utenti autenticati e amministratori. Il prossimo passo sarà consolidare la validazione dei dati, uniformare gli errori e aggiungere test automatici, così da verificare non soltanto il percorso corretto ma anche tutti i tentativi di accesso non consentiti.