API REST in Go: autenticazione e autorizzazione con JWT

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.