Implementare l'autenticazione JWT con Node.js, Express, TypeScript e MongoDB
Dopo aver creato un backend con Express e TypeScript e aver implementato le operazioni CRUD con MongoDB, il passo successivo è permettere agli utenti di identificarsi. Finora, infatti, le API dei prodotti non distinguono una richiesta proveniente da un utente registrato da una proveniente da chiunque altro. È una situazione accettabile durante i primi esperimenti, ma non per un'applicazione che deve gestire account e dati personali.
In questo tutorial realizzerò un sistema di autenticazione basato su JSON Web Token, comunemente chiamati JWT. L'obiettivo è permettere a un utente di registrarsi, effettuare il login e ricevere un token che potrà presentare nelle richieste successive. In questo modo il server potrà verificare l'identità dichiarata senza richiedere username e password a ogni chiamata.
Utilizzerò lo stack già introdotto nei tutorial precedenti: Node.js, Express, TypeScript e MongoDB. Per proteggere le password userò bcryptjs, mentre per creare e verificare i token utilizzerò jsonwebtoken. Entrambe le librerie sono ampiamente utilizzate nell'ecosistema Node.js e permettono di concentrarsi sui meccanismi fondamentali dell'autenticazione.
Alla fine del tutorial avrò realizzato due endpoint, uno per la registrazione e uno per il login. Il codice sarà organizzato in file separati, in continuità con la struttura del progetto. Nel tutorial successivo aggiungerò il middleware necessario per proteggere le rotte e verificare i permessi degli utenti.
Che cos'è l'autenticazione e perché utilizzare JWT
L'autenticazione è il processo attraverso il quale un sistema verifica l'identità di un utente. Nel caso più comune, l'utente fornisce un indirizzo email e una password. Il server verifica le credenziali e, se sono corrette, permette di proseguire.
L'autenticazione non va confusa con l'autorizzazione. La prima risponde alla domanda «chi sei?», mentre la seconda risponde a «che cosa puoi fare?». Un utente può essere autenticato correttamente senza avere il permesso di eliminare un prodotto o consultare i dati di un altro utente.
JWT è un formato standard per rappresentare informazioni firmate digitalmente. Il token contiene delle dichiarazioni, chiamate claim, come l'identificativo dell'utente e la data di scadenza. Il server firma il token con una chiave segreta e, nelle richieste successive, può verificarne la firma e la validità.
È importante capire che un JWT firmato non è necessariamente cifrato. Il suo contenuto può essere letto da chi lo possiede. Per questo non inserirò nel payload password, hash delle password o altre informazioni riservate. Il token conterrà soltanto l'identificativo dell'utente e il ruolo assegnato dal server.
Preparare il progetto e installare le dipendenze
Parto dal progetto node-study creato nei tutorial precedenti. Presuppongo che Express, TypeScript, il driver MongoDB e la connessione al database siano già configurati.
Dalla directory del progetto installo le librerie necessarie:
npm install bcryptjs jsonwebtoken
npm install -D @types/jsonwebtokenbcryptjs permette di calcolare un hash della password e di confrontare successivamente la password inserita con l'hash memorizzato. jsonwebtoken permette invece di generare e verificare i token JWT. Il pacchetto @types/jsonwebtoken fornisce le definizioni TypeScript della libreria.
Non salverò mai la password originale nel database. Anche in un ambiente di sviluppo, abituarsi a conservare credenziali in chiaro è un errore che non conviene portarsi dietro.
Configurare il segreto JWT
Nel file .env, già utilizzato per la configurazione del database, aggiungo una variabile per il segreto usato nella firma dei token:
JWT_SECRET=INSERISCI_UN_SEGRETO_CASUALE_LUNGOIl valore è un segnaposto: devo sostituirlo con una stringa casuale e sufficientemente lunga. Posso generarne una da terminale con:
openssl rand -base64 32Copio il risultato nel file .env, senza condividere il valore o inserirlo nel repository. Il file .env deve essere escluso dal controllo versione tramite .gitignore.
Il segreto non è la password di un utente. Serve al server per firmare e verificare i token. Se un attaccante riuscisse a ottenerlo, potrebbe tentare di creare token contraffatti. In produzione lo conserverò quindi in un sistema sicuro per la gestione dei segreti, non nel codice sorgente.
Definire il modello dell'utente
Creo una nuova directory src/models e al suo interno il file user.ts. Il modello rappresenta i dati che conserverò nella collection MongoDB users.
export type UserRole = 'user' | 'admin';
export type User = {
email: string;
passwordHash: string;
role: UserRole;
createdAt: Date;
};Il campo email identifica l'utente, passwordHash contiene l'hash della password e createdAt registra la data di creazione. Il campo role permette di distinguere un utente normale da un amministratore.
Il tipo TypeScript documenta la struttura prevista, ma non crea automaticamente uno schema o una validazione nel database. MongoDB è un database flessibile: sarò io a verificare i dati prima di inserirli.
Il ruolo non deve essere accettato liberamente durante la registrazione. Un nuovo account riceverà sempre il ruolo user; altrimenti chiunque potrebbe tentare di registrarsi come amministratore semplicemente modificando il JSON inviato al server.
Creare l'endpoint di registrazione
Creo il file src/routes/auth.routes.ts. Il primo endpoint sarà POST /api/auth/register. Riceverà un indirizzo email e una password, controllerà che abbiano un formato accettabile, calcolerà l'hash e inserirà il nuovo utente nel database.
Per il momento effettuerò controlli essenziali direttamente nel codice. La validazione con una libreria dedicata sarà affrontata in un tutorial successivo.
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { connectToDatabase } from '../config/database.js';
import type { User } from '../models/user.js';
const router = Router();
function isRecord(
value: unknown,
): value is Record<string, unknown> {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value)
);
}
router.post('/register', async (req, res) => {
const body: unknown = req.body;
if (!isRecord(body)) {
res.status(400).json({
error: 'Il corpo della richiesta deve essere un oggetto',
});
return;
}
if (typeof body.email !== 'string') {
res.status(400).json({
error: 'Email non valida',
});
return;
}
const email = body.email.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
res.status(400).json({
error: 'Formato email non valido',
});
return;
}
if (
typeof body.password !== 'string' ||
body.password.length < 8
) {
res.status(400).json({
error: 'La password deve contenere almeno 8 caratteri',
});
return;
}
const db = await connectToDatabase();
const users = db.collection<User>('users');
const existingUser = await users.findOne({ email });
if (existingUser) {
res.status(409).json({
error: 'Impossibile completare la registrazione con questi dati',
});
return;
}
const passwordHash = await bcrypt.hash(body.password, 12);
const user: User = {
email,
passwordHash,
role: 'user',
createdAt: new Date(),
};
const result = await users.insertOne(user);
res.status(201).json({
id: result.insertedId.toHexString(),
email: user.email,
role: user.role,
createdAt: user.createdAt,
});
});
export default router;La funzione isRecord verifica che il corpo della richiesta sia un oggetto e non, per esempio, una stringa o un array. Il tipo unknown obbliga a controllare i dati ricevuti prima di utilizzarli, invece di fidarsi ciecamente di ciò che arriva dal client.
La chiamata bcrypt.hash(body.password, 12) calcola l'hash con un costo computazionale configurato a 12. Il valore va valutato in base alle prestazioni dell'ambiente e alle raccomandazioni di sicurezza aggiornate. L'hash, a differenza di una password cifrata reversibilmente, non è progettato per essere decifrato: durante il login confronterò la password inserita con l'hash salvato.
La risposta non contiene passwordHash. Restituisce soltanto i dati che il client ha bisogno di conoscere. Anche se l'hash non è la password originale, non c'è motivo di esporlo.
Il controllo preliminare sull'email evita i duplicati nella maggior parte dei casi, ma non basta a garantire l'unicità in presenza di due registrazioni simultanee. Per questo aggiungerò anche un indice univoco in MongoDB.
Impedire indirizzi email duplicati
Nel file src/index.ts, dopo aver stabilito la connessione al database e prima di avviare il server, creo un indice univoco sulla collection degli utenti:
const db = await connectToDatabase();
await db.collection('users').createIndex(
{ email: 1 },
{ unique: true },
);Se index.ts contiene già una chiamata a connectToDatabase(), non devo aggiungerne una seconda inutilmente: recupero il database con la connessione esistente e inserisco la creazione dell'indice prima di app.listen().
L'indice è la garanzia effettiva a livello di database. Anche se due richieste superassero contemporaneamente il controllo con findOne, MongoDB impedirebbe l'inserimento di due documenti con la stessa email. In un'applicazione completa gestirò l'errore di chiave duplicata restituendo una risposta HTTP coerente, senza mostrare dettagli interni del database.
Normalizzare l'email con trim().toLowerCase() riduce anche le differenze dovute a spazi o maiuscole. È una scelta applicativa da mantenere coerente in registrazione e login.
Implementare il login
Ora aggiungo POST /api/auth/login nello stesso file auth.routes.ts. Il client invierà email e password; il server recupererà l'utente e confronterà la password con il suo hash.
Per generare il token uso jsonwebtoken. Il payload conterrà il ruolo e il subject, cioè l'identificativo dell'utente. Imposterò una scadenza di un'ora.
Aggiungo gli import necessari all'inizio del file:
import jwt from 'jsonwebtoken';
import { ObjectId } from 'mongodb';Poi aggiungo la rotta prima di export default router:
router.post('/login', async (req, res) => {
const body: unknown = req.body;
if (
!isRecord(body) ||
typeof body.email !== 'string' ||
typeof body.password !== 'string'
) {
res.status(400).json({
error: 'Email e password sono obbligatorie',
});
return;
}
const email = body.email.trim().toLowerCase();
const db = await connectToDatabase();
const users = db.collection<User>('users');
const user = await users.findOne({ email });
if (
!user ||
!(await bcrypt.compare(body.password, user.passwordHash))
) {
res.status(401).json({
error: 'Credenziali non valide',
});
return;
}
const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) {
throw new Error('JWT_SECRET non configurato');
}
const token = jwt.sign(
{ role: user.role },
jwtSecret,
{
subject: user._id.toHexString(),
expiresIn: '1h',
},
);
res.json({
accessToken: token,
tokenType: 'Bearer',
expiresIn: 3600,
});
});L'operatore await davanti a bcrypt.compare() è importante perché il confronto è asincrono. Se l'utente non esiste oppure la password è sbagliata, restituisco lo stesso messaggio di errore. In questo modo non rivelo direttamente se un determinato indirizzo email è registrato.
Il token viene creato soltanto dopo aver verificato le credenziali. subject identifica l'utente e expiresIn: '1h' limita la durata del token a un'ora. Il campo role viene letto dal documento del database, non dal JSON inviato dal client: un utente non può attribuirsi il ruolo di amministratore modificando la richiesta di login.
Il controllo di JWT_SECRET evita di firmare token se la configurazione manca. Per una configurazione ancora più rigorosa, posso verificare la presenza del segreto all'avvio del server, prima di accettare richieste.
Collegare le rotte all'applicazione Express
Nel file src/app.ts importo il router:
import authRouter from './routes/auth.routes.js';Dopo app.use(express.json()) aggiungo:
app.use('/api/auth', authRouter);Express combina il prefisso /api/auth con i percorsi definiti nel router. Di conseguenza, router.post('/register', ...) diventa POST /api/auth/register, mentre router.post('/login', ...) diventa POST /api/auth/login.
Non devo modificare la struttura generale dell'applicazione né creare un secondo server. Il router di autenticazione è un modulo aggiuntivo collegato all'applicazione esistente.
Provare la registrazione e il login
Avvio MongoDB e il backend, quindi provo a registrare un utente:
curl -i -X POST http://localhost:3000/api/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"dev@example.com","password":"Password-di-test-123"}'Se tutto funziona, ricevo 201 Created e un JSON con l'identificativo, l'email, il ruolo e la data di creazione.
Provo quindi il login:
curl -i -X POST http://localhost:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"dev@example.com","password":"Password-di-test-123"}'La risposta contiene accessToken, tokenType ed expiresIn. Il token è una stringa che userò nel tutorial successivo per accedere alle rotte protette. Se la password è sbagliata, mi aspetto invece una risposta 401 Unauthorized.
Posso verificare anche i casi negativi: email malformata, password troppo corta, registrazione duplicata e campi mancanti. La gestione centralizzata di tutti gli errori e una validazione più completa saranno miglioramenti successivi.
Considerazioni di sicurezza
Il sistema realizzato è una base didattica, non una soluzione completa pronta per un servizio pubblico. In produzione occorre prevedere anche limiti ai tentativi di login, protezione contro gli abusi, validazione rigorosa dei dati, gestione sicura dei segreti e una strategia per revocare o rinnovare i token.
La durata del JWT deve essere scelta in base al rischio e all'esperienza utente desiderata. Un token rubato può essere utilizzato fino alla scadenza, salvo l'introduzione di ulteriori meccanismi di revoca. Il fatto che sia firmato non impedisce a chi lo possiede di usarlo.
Infine, il progetto deve essere servito tramite HTTPS in qualsiasi ambiente esposto alla rete. Senza HTTPS, un token inviato nella richiesta potrebbe essere intercettato. Nel prossimo tutorial userò il token nell'header Authorization e implementerò il middleware che verifica l'identità dell'utente prima di permettere l'accesso alle risorse protette.
Conclusione
Con questo tutorial ho aggiunto al backend una prima implementazione dell'autenticazione. Gli utenti possono registrarsi, le password vengono conservate sotto forma di hash e il login restituisce un JWT firmato con una scadenza definita.
Il token, da solo, non protegge ancora gli endpoint dei prodotti. Per ottenere questo risultato devo verificare il token a ogni richiesta protetta e decidere quali operazioni può eseguire l'utente autenticato. È proprio il passaggio che affronterò nel Tutorial 9, dedicato all'autorizzazione e alla protezione delle rotte.