API REST in Go: autenticazione e autorizzazione con JWT
Finora la nostra API era aperta a chiunque. In un servizio reale la maggior parte degli endpoint richiede che il chiamante sia autenticato, e alcune operazioni sono riservate a utenti con determinati privilegi. In questa puntata implementiamo l'autenticazione tramite token JWT, un meccanismo perfettamente adatto alla natura stateless di REST: il server non conserva sessioni, ma emette un token firmato che il client presenta a ogni richiesta. Costruiremo l'endpoint di login, il middleware che verifica il token e un controllo di autorizzazione basato sui ruoli, appoggiandoci al context introdotto nella puntata precedente.
Che cos'è un token JWT
JWT, acronimo di JSON Web Token, è uno standard per rappresentare affermazioni verificabili in modo compatto. Un token è composto da tre parti separate da punti: un'intestazione che descrive l'algoritmo di firma, un payload che contiene le affermazioni (i cosiddetti claim, come l'identità dell'utente e la scadenza) e una firma. La firma è ciò che rende il token sicuro: il server la calcola con una chiave segreta e la verifica a ogni richiesta, quindi un client non può alterare i claim senza invalidare il token. È importante capire che il payload è soltanto codificato, non cifrato: chiunque può leggerne il contenuto, perciò non vi si inseriscono mai dati sensibili.
Aggiungiamo al progetto la libreria più diffusa per i JWT in Go.
go get github.com/golang-jwt/jwt/v5
Modellare l'utente e i claim
Definiamo la struttura dell'utente e i claim personalizzati che inseriremo nel token. I claim includono l'identificatore dell'utente, il suo ruolo e i campi registrati che regolano la validità temporale del token.
// User rappresenta un utente del sistema.
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"-"` // mai serializzato nelle risposte
Role string `json:"role"`
}
// Claims sono le affermazioni contenute nel token JWT.
type Claims struct {
UserID int64 `json:"uid"`
Role string `json:"role"`
jwt.RegisteredClaims
}
Il campo PasswordHash è marcato con json:"-" perché non deve mai comparire in una risposta dell'API. L'incorporamento di jwt.RegisteredClaims ci dà accesso ai claim standard come la scadenza (exp) e l'istante di emissione (iat), gestiti automaticamente dalla libreria durante la validazione.
Custodire le password
Le password non vanno mai memorizzate in chiaro né con semplici hash come SHA-256, che sono troppo veloci e quindi vulnerabili agli attacchi a forza bruta. Lo strumento corretto è un algoritmo di hashing lento e con sale integrato, come bcrypt, disponibile nel pacchetto delle librerie complementari di Go.
go get golang.org/x/crypto/bcrypt
// HashPassword produce l'hash bcrypt di una password in chiaro.
func HashPassword(password string) (string, error) {
// Il costo determina la lentezza dell'algoritmo: piu alto e piu sicuro,
// ma anche piu lento. Il valore predefinito e un buon compromesso.
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
// CheckPassword verifica una password in chiaro contro il suo hash.
func CheckPassword(password, hash string) bool {
// Restituisce nil se la password corrisponde all'hash
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
La funzione CompareHashAndPassword estrae il sale e il costo dall'hash memorizzato e ricalcola l'hash della password fornita, confrontandoli in modo resistente agli attacchi temporali. Non dobbiamo gestire il sale manualmente: bcrypt lo incorpora nell'hash risultante.
Emettere e verificare i token
Concentriamo la logica dei token in un tipo dedicato, che custodisce la chiave segreta e la durata di validità. Espone due metodi: uno per generare un token a partire da un utente, l'altro per validarne uno e recuperarne i claim.
// TokenService gestisce la generazione e la verifica dei token JWT.
type TokenService struct {
secret []byte
ttl time.Duration
}
func NewTokenService(secret string, ttl time.Duration) *TokenService {
return &TokenService{secret: []byte(secret), ttl: ttl}
}
// Generate produce un token firmato per l'utente dato.
func (s *TokenService) Generate(user User) (string, error) {
claims := Claims{
UserID: user.ID,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
// Impostiamo scadenza e istante di emissione
ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.ttl)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.secret)
}
// Verify valida un token e ne restituisce i claim.
func (s *TokenService) Verify(tokenString string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims,
func(t *jwt.Token) (any, error) {
// Verifichiamo che l'algoritmo sia quello atteso, respingendo
// token firmati con metodi diversi (difesa contro alg confusion).
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("metodo di firma inatteso")
}
return s.secret, nil
})
if err != nil || !token.Valid {
return nil, errors.New("token non valido")
}
return claims, nil
}
Il controllo dell'algoritmo di firma all'interno della funzione di verifica non è un dettaglio pedante: senza di esso l'applicazione sarebbe esposta a una classe di attacchi in cui un aggressore altera l'intestazione del token per aggirare la firma. Verificare che il metodo sia quello atteso, qui HMAC, è una salvaguardia essenziale.
L'endpoint di login
Il login riceve le credenziali, recupera l'utente, verifica la password e, in caso di successo, emette un token. Un dettaglio di sicurezza importante: la risposta a credenziali errate non deve mai rivelare se a essere sbagliata sia l'email o la password, per non fornire informazioni a chi tenta di indovinare gli account esistenti.
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
func login(users *UserRepository, tokens *TokenService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var input loginRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "corpo non valido")
return
}
user, err := users.GetByEmail(r.Context(), input.Email)
// Rispondiamo allo stesso modo per utente inesistente e password errata
if err != nil || !CheckPassword(input.Password, user.PasswordHash) {
writeError(w, http.StatusUnauthorized, "credenziali non valide")
return
}
token, err := tokens.Generate(user)
if err != nil {
writeError(w, http.StatusInternalServerError, "errore interno")
return
}
writeJSON(w, http.StatusOK, map[string]string{"token": token})
}
}
Il middleware di autenticazione
Ora scriviamo il middleware che protegge gli endpoint. Estrae il token dall'intestazione Authorization, lo verifica e, se valido, inserisce i claim nel context della richiesta, così che i gestori a valle sappiano chi è l'utente. Riprendiamo il pattern del context visto nella puntata precedente.
type contextKey string
const claimsKey contextKey = "claims"
// AuthMiddleware verifica il token JWT e inserisce i claim nel context.
func AuthMiddleware(tokens *TokenService) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// L'intestazione ha la forma "Bearer <token>"
header := r.Header.Get("Authorization")
prefix := "Bearer "
if !strings.HasPrefix(header, prefix) {
writeError(w, http.StatusUnauthorized, "token mancante")
return
}
tokenString := strings.TrimPrefix(header, prefix)
claims, err := tokens.Verify(tokenString)
if err != nil {
writeError(w, http.StatusUnauthorized, "token non valido")
return
}
// Inseriamo i claim nel context per i gestori successivi
ctx := context.WithValue(r.Context(), claimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// ClaimsFromContext recupera i claim dal context.
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
claims, ok := ctx.Value(claimsKey).(*Claims)
return claims, ok
}
Autorizzazione basata sui ruoli
L'autenticazione stabilisce chi è l'utente; l'autorizzazione stabilisce cosa può fare. Aggiungiamo un middleware che consente l'accesso solo agli utenti con un determinato ruolo, da applicare dopo quello di autenticazione, perché presuppone che i claim siano già presenti nel context.
// RequireRole autorizza solo gli utenti che possiedono il ruolo richiesto.
func RequireRole(role string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := ClaimsFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "non autenticato")
return
}
if claims.Role != role {
// L'utente e autenticato ma privo dei permessi: 403 Forbidden
writeError(w, http.StatusForbidden, "permessi insufficienti")
return
}
next.ServeHTTP(w, r)
})
}
}
La distinzione tra i codici 401 e 403 è semantica e va rispettata: il 401 Unauthorized significa "non so chi sei, autenticati", mentre il 403 Forbidden significa "so chi sei, ma non hai il permesso". Confonderli disorienta i client.
Applicare la protezione agli endpoint
Con i middleware pronti, possiamo proteggere selettivamente gli endpoint. Rendiamo pubblica la lettura del catalogo, ma riserviamo la creazione e la cancellazione agli amministratori autenticati. Poiché il ServeMux di Go 1.22 non permette di applicare middleware a singole rotte in modo nativo, li applichiamo direttamente al gestore prima di registrarlo.
func main() {
// ... inizializzazione di repository, tokens, ecc.
mux := http.NewServeMux()
// Endpoint pubblici
mux.HandleFunc("POST /login", login(users, tokens))
mux.HandleFunc("GET /books", listBooks(repo))
mux.HandleFunc("GET /books/{id}", getBook(repo))
// Endpoint riservati agli amministratori: componiamo autenticazione e ruolo
adminOnly := func(h http.Handler) http.Handler {
return Chain(h, AuthMiddleware(tokens), RequireRole("admin"))
}
mux.Handle("POST /books", adminOnly(http.HandlerFunc(createBook(repo))))
mux.Handle("DELETE /books/{id}", adminOnly(http.HandlerFunc(deleteBook(repo))))
// I middleware globali avvolgono tutto
handler := Chain(mux, RecoverMiddleware, LoggingMiddleware)
server := &http.Server{Addr: ":8080", Handler: handler}
log.Fatal(server.ListenAndServe())
}
Proviamo l'intero flusso: otteniamo un token con il login e lo usiamo per creare un libro.
# Otteniamo il token
TOKEN=$(curl -s -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@example.com","password":"segreta"}' | jq -r .token)
# Creiamo un libro presentando il token
curl -X POST http://localhost:8080/books \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Le citta invisibili","author":"Calvino","year":1972}'
Conclusione
Abbiamo dotato l'API di un sistema di autenticazione e autorizzazione completo e stateless. Abbiamo custodito le password con bcrypt, emesso e verificato token JWT con particolare attenzione alla sicurezza dell'algoritmo di firma, costruito l'endpoint di login con risposte prudenti alle credenziali errate, e protetto gli endpoint con middleware di autenticazione e controllo dei ruoli, sfruttando il context per trasportare l'identità dell'utente. Nella settima e ultima puntata affronteremo la validazione degli input, la gestione strutturata degli errori e i test automatici, portando l'applicazione a un livello di qualità adatto alla produzione.