Java

Autenticazione tra microservizi Spring Boot con JWT, RSA e JWKS

Autenticazione tra microservizi Spring Boot con JWT, RSA e JWKS
LT
Luca Terribili
Autore

Nel precedente articolo abbiamo costruito due microservizi Spring Boot davvero indipendenti: user-service e content-service, ciascuno con il proprio database e senza entità, repository o codice condivisi.

Ora arriva il problema che prima o poi compare in ogni architettura distribuita: come fa un servizio a sapere chi lo sta chiamando? In questa guida vediamo come risolverlo con JWT firmati con RSA, chiave pubblica esposta tramite JWKS e validazione completa del token.

In un monolite c'è una sola applicazione, una sola sessione, un solo sistema di autenticazione. Nei microservizi l'utente si autentica su un servizio e poi chiama un altro.

Nel nostro progetto:

  • il login avviene su user-service
  • la creazione del post avviene su content-service

Non vogliamo copiare le credenziali da un servizio all'altro, e nemmeno che content-service chieda a user-service il permesso a ogni richiesta.

Il flusso parte da qui:

POST /auth/login

Se le credenziali sono corrette, il client riceve:

{
  "accessToken": "...",
  "tokenType": "Bearer",
  "expiresIn": 3600
}

Poi chiama direttamente content-service:

POST http://localhost:8282/posts
Authorization: Bearer <token>

A questo punto content-service deve stabilire se il token è valido, senza:

  • conoscere la password dell'utente
  • conoscere la chiave privata usata per firmare
  • accedere alla tabella users

Gli basta verificare che il token sia stato emesso da una fonte attendibile e non sia stato modificato.

Cos'è un JWT (e cosa non è)

Un JSON Web Token (JWT) è un token strutturato in tre parti: header, payload e firma. Nel payload troviamo i claim, tra cui:

ClaimSignificato
subIdentifica l'utente (subject)
issChi ha emesso il token (issuer)
audIl destinatario previsto (audience)
expQuando il token scade

Attenzione: il payload di un JWT non è cifrato, è solo codificato (Base64URL). Chiunque abbia il token può leggerlo. La firma garantisce integrità e autenticità, non riservatezza.

Quindi mai password o dati sensibili nel payload.

Perché RSA e non HS256

Con HS256 (algoritmo simmetrico) esiste un segreto condiviso:

user-service ───── shared secret ───── content-service

Il problema: il segreto che serve per verificare è lo stesso che serve per generare un token valido. Se content-service viene compromesso e il segreto viene sottratto, chiunque può produrre token che sembrano emessi legittimamente.

Con RSA (algoritmo asimmetrico) separiamo le responsabilità:

user-service
                     │ private key
                     ▼
                 firma JWT
                     │
                     ▼
                   client
                     │ Bearer token
                     ▼
              content-service
                     │ public key
                     ▼
               verifica firma
  • user-service ha la chiave privata → può firmare
  • content-service ha la chiave pubblica → può solo verificare

Punto chiave: la chiave pubblica verifica le firme ma non può crearne di nuove. Per un'architettura a microservizi è la separazione giusta.

Il flusso di login

Il client invia le credenziali:

POST http://localhost:8181/auth/login
Content-Type: application/json
{
  "username": "luca",
  "password": "secret123"
}

Se sono corrette, user-service genera il token. Se sono sbagliate, risponde:

401 Unauthorized

con un messaggio generico. Non distinguiamo tra "username inesistente" e "password errata": risposte diverse permetterebbero di enumerare gli account esistenti.

Creare un post con il token

curl -X POST http://localhost:8282/posts \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"title":"primo post","body":"scritto da luca"}'

content-service verifica il token prima di eseguire qualsiasi logica applicativa:

  • token assente → 401 Unauthorized
  • token non valido → 401 Unauthorized
  • token valido → il post viene creato

Principio: l'autenticazione avviene prima della logica applicativa.

Esporre la chiave pubblica con JWKS

Come fa content-service a conoscere la chiave pubblica? Tramite un endpoint JWKS (JSON Web Key Set) esposto da user-service:

GET /.well-known/jwks.json
user-service
    │ /.well-known/jwks.json
    ▼
public key
    │
    ▼
content-service

La chiave privata non viene mai esposta. Gli altri servizi ricevono solo ciò che serve per verificare.

Questo modello scala bene: se domani hai dieci servizi che verificano lo stesso tipo di token, non devi copiare la private key in dieci applicazioni.

Come validare correttamente un JWT

Una firma valida non rende il token valido per qualsiasi contesto. In content-service controlliamo almeno quattro cose:

JWT
 │
 ├── firma valida?
 ├── issuer (iss) corretto?
 ├── audience (aud) corretta?
 └── scadenza (exp) valida?
        │
        ▼
     accetta

È l'errore più comune nei primi progetti con JWT: decodificare il payload e fidarsi di quello che c'è scritto. Il payload lo può leggere (e costruire) chiunque. Quello che conta è verificare che provenga davvero da chi possiede la chiave privata.

Perché non usiamo Spring Security per validare il token

Quando si parla di autenticazione in Spring Boot, la risposta d'istinto è: "aggiungi Spring Security". È uno strumento solido e, in molti casi, la scelta giusta. In questo progetto, però, in content-service abbiamo scelto di non affidargli la validazione del token. Le ragioni sono tre.

1. L'obiettivo è capire cosa succede, non nasconderlo.
Con il modulo OAuth2 Resource Server di Spring Security bastano poche righe di configurazione e il token viene validato "per magia". Funziona, ma chi lo usa la prima volta spesso non sa dire quali controlli vengano fatti davvero. Scrivere la verifica in modo esplicito ci obbliga a ragionare su firma, iss, aud ed exp, uno per uno.

2. Meno superficie, meno comportamenti impliciti.
Spring Security porta con sé una filter chain, regole di default, sessioni, CSRF, gestione delle eccezioni. Sono tutte cose utili, ma per un servizio che deve fare una cosa sola (verificare un Bearer token e ricavare il sub) sono molta configurazione da capire, e da tenere sotto controllo, per un risultato piccolo.

3. Un esempio didattico deve restare leggibile.
Il valore di questo progetto sta nei confini tra i servizi, non nella configurazione del framework. Meno strati ci sono tra la richiesta e la verifica della firma, più il flusso si segue.

Attenzione: questo non vuol dire che Spring Security sia da evitare. In produzione, per validare JWT in un resource server, usare il supporto OAuth2 Resource Server di Spring Security è di solito la scelta più sensata: è mantenuto, testato e gestisce per te molti casi limite (compreso il refresh delle chiavi JWKS). Qui abbiamo scelto la strada esplicita per imparare, non perché sia migliore.

Una precisione: user-service usa comunque parti di Spring Security per verificare le credenziali al login (AuthenticationManager). Non ha senso reinventare l'hashing delle password. La differenza è che il token, la sua firma e la sua validazione li gestiamo noi in modo visibile.

Il claim sub e il user_id del post

Dopo la validazione, leggiamo il claim sub per identificare l'utente:

Authorization: Bearer JWT
        │
        ▼
verifica della firma
        │
        ▼
    claim sub
        │
        ▼
 user_id del post

Di conseguenza il client non può scegliere l'utente. Non accettiamo una richiesta così:

{
  "title": "Primo post",
  "body": "Ciao",
  "userId": "uuid-di-un-altro-utente"
}

La richiesta contiene solo ciò che l'utente deve poter controllare:

{
  "title": "Primo post",
  "body": "Ciao"
}

Regola generale (valida anche fuori dai microservizi): non accettare dal client informazioni che il server può ricavare da un contesto attendibile.

Perché non interrogare ogni volta user-service

Si potrebbe pensare: a ogni POST /posts, content-service chiama GET /users/{id} e verifica che l'utente esista. Funziona, ma introduce nuovi problemi:

  • ogni richiesta dipende dalla disponibilità di user-service
  • se user-service è giù, si bloccano anche operazioni che riguardano solo i post
  • aumentano latenza e traffico tra i servizi

Con il token firmato, content-service verifica l'autenticazione in locale.

Questo non elimina del tutto le chiamate a user-service: se servono dati dell'utente non presenti nel token, la comunicazione serve. Ma autenticazione e recupero dei dati utente sono due problemi diversi.

Il token non sostituisce il database degli utenti

Se nel token c'è sub = 123, content-service conosce un'identità autenticata, non l'utente. Se domani vuoi mostrare nome ed email e quei dati appartengono a user-service, dovrai recuperarli con:

  • una chiamata REST
  • una proiezione locale
  • eventi
  • altre strategie architetturali

Conoscere l'ID di un utente non significa possedere l'utente.

Gestione delle chiavi e rotazione

Nel progetto didattico la chiave privata RSA sta nelle risorse del servizio ed è esclusa dal repository con .gitignore. Va bene in locale, non in produzione, e il problema non è solo Git: una private key dentro un'immagine Docker finisce nell'immagine.

In produzione la chiave va fornita con un meccanismo di gestione dei secret. Lo stesso vale per le password PostgreSQL.

Distinguiamo quindi:

Configurazione applicativaSegreti applicativi
Può essere versionataNon va versionata

Rotazione delle chiavi

JWKS apre la strada alla key rotation. Se cambi la chiave privata, i token firmati con la vecchia chiave possono essere ancora validi, quindi non puoi sostituire tutto di colpo.

L'endpoint JWKS permette ai servizi consumer di conoscere le chiavi pubbliche disponibili e di individuare quella giusta tramite l'identificativo (kid) nell'header del token.

Nel progetto non abbiamo implementato una rotazione completa, ma l'architettura lascia spazio a questa evoluzione. Ed è la differenza tra progettare solo per il caso felice e progettare qualcosa che possa crescere.

Due problemi reali

createdAt che torna null

Nel database il valore è impostato con un default now(), ma Hibernate non lo scrive direttamente e l'entità in memoria non viene aggiornata dopo il persist. Risultato:

{
  "id": "...",
  "createdAt": null
}

mentre nel database il valore c'è. Soluzioni: ricaricare l'entità oppure delegare il timestamp a Hibernate con @CreationTimestamp.

Lezione: l'oggetto Java e la riga del database non sono sincronizzati in ogni istante.

Il doppio lookup nel login

AuthenticationManager cerca l'utente per verificare le credenziali, poi LoginService lo cerca di nuovo per recuperare l'ID da mettere nel token. Funziona, non è un disastro, ma si può migliorare.

Prima facciamo funzionare il sistema, poi misuriamo, poi ottimizziamo ciò che serve. Altrimenti passi tre giorni a eliminare una query e scopri che il vero problema era altrove. Un grande classico del mestiere.

Il test che manca: il contratto tra servizi

Il progetto non ha ancora una vera suite di test di integrazione. Il primo che aggiungerei verifica il contratto tra i due servizi, con PostgreSQL avviato tramite Testcontainers:

user-service
      │ login
      ▼
 JWT firmato
      ▼
content-service
      │ verifica firma
      ▼
 creazione post
      ▼
     201

Se cambia il modo in cui user-service firma il token, content-service potrebbe smettere di accettarlo senza che il compilatore dica nulla. È uno dei problemi tipici dei sistemi distribuiti: due progetti che compilano perfettamente e un sistema complessivamente rotto.

Cosa abbiamo imparato

Da due semplici API siamo arrivati a:

  • database separati e nessuna foreign key tra servizi
  • nessuna entità condivisa
  • JWT per trasportare l'identità
  • RSA per separare chi firma da chi verifica
  • JWKS per pubblicare la chiave pubblica
  • validazione di firma, iss, aud ed exp
  • identità dell'autore ricavata da sub, non da un user_id del client

E abbiamo visto che questa architettura introduce nuovi problemi: comunicazione tra servizi, gestione delle chiavi, test dei contratti, consistenza dei dati e maggiore complessità operativa.

I microservizi non rendono automaticamente un'applicazione migliore. Distribuiscono responsabilità, deploy e dati in modo diverso, e in cambio ti fanno gestire la complessità che prima era nascosta dentro un singolo processo.

Abbiamo evitato di aggiungere tecnologia solo per dire di averla usata: non servono dieci servizi per spiegare cos'è un microservizio, né Kubernetes per far parlare due applicazioni.

  • user-service possiede gli utenti
  • content-service possiede i post
  • i database sono separati
  • l'identità attraversa il confine come token firmato
  • il codice non lo attraversa come classi condivise
  • se un servizio ha bisogno di qualcosa dell'altro, deve attraversare esplicitamente quel confine

Il modo migliore per imparare i microservizi con Spring Boot è partire dai confini che vuoi creare e dai problemi che producono, non dalle tecnologie dell'ecosistema. Spring Boot è lo strumento per implementare l'architettura, non l'architettura stessa.

Vedi tutti →