Quando si sviluppa un'API con Flask, prima o poi arriva il momento di decidere come autenticare gli utenti e impedire che chiunque possa accedere alle risorse riservate.

Una possibilità è utilizzare le sessioni tradizionali, nelle quali il server conserva lo stato dell'utente autenticato. Un'altra consiste nell'impiegare i JSON Web Token (JWT), token firmati che il client presenta alle richieste successive.

JWT è una soluzione diffusa soprattutto nelle API REST e nelle applicazioni distribuite. Non è però una scorciatoia per rendere automaticamente sicuro un sistema: bisogna gestire correttamente le credenziali, la scadenza dei token e gli endpoint protetti.

Vediamo come realizzare un esempio funzionante con Python, Flask, PyJWT e SQLite, partendo dalla registrazione fino alla verifica dell'identità dell'utente.

Che cos'è un JSON Web Token

Un JSON Web Token è un formato standard per rappresentare informazioni che possono essere verificate dal sistema che le riceve.

Un JWT firmato è normalmente composto da tre parti separate da un punto:

  • Header: contiene informazioni sul tipo di token e sull'algoritmo utilizzato.
  • Payload: contiene i dati, chiamati claim, come l'identificativo dell'utente e la scadenza.
  • Signature: consente di verificare che il contenuto non sia stato alterato e che la firma sia valida.

La struttura risultante ha un aspetto simile a questo:

header.payload.signature

Le stringhe sono codificate in Base64URL, ma questo non significa che i dati siano cifrati. Chiunque disponga di un JWT firmato nel formato JWS può normalmente leggere il payload senza conoscere la chiave di firma.

La firma garantisce l'integrità del token, non la riservatezza dei dati. Per questo motivo, non bisogna inserire nel payload password, chiavi API o altre informazioni segrete.

Nel nostro esempio il token conterrà l'identificativo dell'utente e alcuni metadati, tra cui la data di scadenza. Le informazioni personali rimarranno nel database.

Preparare l'ambiente Python

Per il progetto utilizzeremo Flask per gestire le richieste HTTP, PyJWT per generare e verificare i token e SQLite per memorizzare gli utenti.

Creiamo un ambiente virtuale e installiamo le dipendenze:

python -m venv .venv
source .venv/bin/activate
pip install Flask PyJWT

Su Windows, l'attivazione dell'ambiente virtuale si esegue con:

.venv\Scripts\Activate.ps1

Non è necessario installare SQLAlchemy per questo esempio: utilizzeremo SQLite tramite il modulo sqlite3 della libreria standard di Python, evitando una dipendenza aggiuntiva.

Impostiamo anche una chiave di firma attraverso una variabile d'ambiente. Non deve essere inserita direttamente nel codice sorgente, soprattutto se il progetto viene versionato con Git.

Su Linux possiamo generare una chiave casuale con:

python -c "import secrets; print(secrets.token_hex(32))"

Copiamo il valore generato nella variabile d'ambiente:

export JWT_SECRET_KEY="inserisci_qui_la_chiave_generata"

La chiave deve rimanere segreta e deve essere conservata in modo persistente nella configurazione dell'ambiente in cui verrà eseguita l'applicazione.

Creare l'applicazione Flask

Salviamo il codice seguente in app.py. Per mantenere l'esempio leggibile, registrazione, login e verifica del token sono raccolti nello stesso file. In un progetto più grande sarebbe preferibile separare configurazione, accesso al database e route.

import os
import sqlite3
from datetime import datetime, timedelta, timezone
from functools import wraps

import jwt
from flask import Flask, g, jsonify, request
from werkzeug.security import (
    check_password_hash,
    generate_password_hash,
)

app = Flask(__name__)

SECRET_KEY = os.environ.get("JWT_SECRET_KEY")

if not SECRET_KEY:
    raise RuntimeError(
        "La variabile JWT_SECRET_KEY non è configurata."
    )

DATABASE = os.environ.get("DATABASE_PATH", "users.db")
TOKEN_LIFETIME = timedelta(minutes=15)


def get_db():
    if "db" not in g:
        g.db = sqlite3.connect(DATABASE)
        g.db.row_factory = sqlite3.Row

    return g.db


@app.teardown_appcontext
def close_db(exception=None):
    db = g.pop("db", None)

    if db is not None:
        db.close()


def init_db():
    with sqlite3.connect(DATABASE) as db:
        db.execute("""
            CREATE TABLE IF NOT EXISTS users (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                username TEXT NOT NULL UNIQUE,
                password_hash TEXT NOT NULL
            )
        """)


with app.app_context():
    init_db()

La tabella users conserva il nome utente e l'hash della password, non la password originale. Per generare e verificare gli hash utilizziamo le funzioni di Werkzeug, già disponibile come dipendenza di Flask.

Il database viene inizializzato quando il modulo viene caricato. È una soluzione accettabile per questo esempio, mentre in un'applicazione più articolata conviene affidare la gestione dello schema a migrazioni dedicate.

Registrare un nuovo utente

La registrazione riceve nome utente e password in una richiesta POST. Prima di creare l'account controlliamo che i dati siano presenti e che il nome utente non sia già utilizzato.

@app.route("/register", methods=["POST"])
def register():
    data = request.get_json(silent=True)

    if not isinstance(data, dict):
        return jsonify({
            "error": "Il corpo della richiesta deve essere JSON."
        }), 400

    username = data.get("username")
    password = data.get("password")

    if not isinstance(username, str) or not username.strip():
        return jsonify({
            "error": "Il nome utente è obbligatorio."
        }), 400

    if not isinstance(password, str) or len(password) < 12:
        return jsonify({
            "error": "La password deve contenere almeno 12 caratteri."
        }), 400

    username = username.strip()

    password_hash = generate_password_hash(password)

    try:
        db = get_db()
        cursor = db.execute(
            """
            INSERT INTO users (username, password_hash)
            VALUES (?, ?)
            """,
            (username, password_hash),
        )
        db.commit()

    except sqlite3.IntegrityError:
        return jsonify({
            "error": "Nome utente non disponibile."
        }), 409

    return jsonify({
        "message": "Utente creato.",
        "id": cursor.lastrowid,
    }), 201

La password viene trasformata in un hash prima di essere salvata. Al login non sarà quindi necessario recuperare la password originale: confronteremo quella ricevuta con l'hash presente nel database.

La query utilizza parametri separati dal testo SQL, evitando di costruire istruzioni concatenando direttamente i dati ricevuti dal client.

La lunghezza minima della password è soltanto una regola di base dell'esempio. Un'applicazione reale dovrebbe adottare una politica adeguata al proprio contesto, proteggere la registrazione dagli abusi e applicare limiti alle richieste.

Generare il JWT durante il login

Dopo la registrazione, l'utente può autenticarsi inviando le proprie credenziali alla route /login.

Se il nome utente esiste e la password è corretta, generiamo un token firmato con algoritmo HS256. Il token contiene l'identificativo dell'utente nel claim sub, la data di emissione iat e la scadenza exp.

@app.route("/login", methods=["POST"])
def login():
    data = request.get_json(silent=True)

    if not isinstance(data, dict):
        return jsonify({
            "error": "Il corpo della richiesta deve essere JSON."
        }), 400

    username = data.get("username")
    password = data.get("password")

    if not isinstance(username, str) or not isinstance(password, str):
        return jsonify({
            "error": "Credenziali non valide."
        }), 400

    user = get_db().execute(
        """
        SELECT id, username, password_hash
        FROM users
        WHERE username = ?
        """,
        (username.strip(),),
    ).fetchone()

    if user is None or not check_password_hash(
        user["password_hash"], password
    ):
        return jsonify({
            "error": "Credenziali non valide."
        }), 401

    now = datetime.now(timezone.utc)

    token = jwt.encode(
        {
            "sub": str(user["id"]),
            "iat": now,
            "exp": now + TOKEN_LIFETIME,
        },
        SECRET_KEY,
        algorithm="HS256",
    )

    return jsonify({
        "access_token": token,
        "token_type": "Bearer",
        "expires_in": int(TOKEN_LIFETIME.total_seconds()),
    }), 200

La chiave viene letta dalla configurazione del processo e non viene restituita al client. La scadenza limita il periodo durante il quale il token può essere utilizzato.

La verifica delle credenziali restituisce lo stesso messaggio sia quando il nome utente non esiste sia quando la password è sbagliata. In questo modo si evitano risposte che rivelino direttamente quali account sono registrati.

HS256 utilizza una chiave condivisa: il sistema che verifica il token deve conoscere la stessa chiave utilizzata per firmarlo. Per sistemi con molti servizi indipendenti può essere preferibile valutare firme asimmetriche, nelle quali la chiave privata firma e quella pubblica verifica.

Proteggere le route con un decoratore

A questo punto possiamo autenticare gli utenti, ma dobbiamo ancora impedire l'accesso alle risorse riservate senza un token valido.

Flask permette di farlo con un decoratore Python. La funzione seguente legge l'header Authorization, verifica il token e recupera l'utente dal database.

def token_required(view):
    @wraps(view)
    def wrapped(*args, **kwargs):
        authorization = request.headers.get("Authorization", "")
        parts = authorization.split()

        if len(parts) != 2 or parts[0].lower() != "bearer":
            return jsonify({
                "error": "Token Bearer mancante o non valido."
            }), 401

        token = parts[1]

        try:
            payload = jwt.decode(
                token,
                SECRET_KEY,
                algorithms=["HS256"],
                options={"require": ["sub", "iat", "exp"]},
            )

        except jwt.ExpiredSignatureError:
            return jsonify({
                "error": "Token scaduto."
            }), 401

        except jwt.InvalidTokenError:
            return jsonify({
                "error": "Token non valido."
            }), 401

        user_id = payload.get("sub")

        if not isinstance(user_id, str) or not user_id.isdigit():
            return jsonify({
                "error": "Token non valido."
            }), 401

        user = get_db().execute(
            "SELECT id, username FROM users WHERE id = ?",
            (int(user_id),),
        ).fetchone()

        if user is None:
            return jsonify({
                "error": "Utente non disponibile."
            }), 401

        return view(user, *args, **kwargs)

    return wrapped

La verifica specifica l'algoritmo accettato, anziché fidarsi di quello dichiarato dal token ricevuto. Inoltre, gli errori vengono gestiti in modo mirato: un token scaduto non è la stessa cosa di una richiesta priva di credenziali.

Il decoratore non si limita a verificare la firma. Controlla anche che l'utente esista ancora nel database. Questo consente, per esempio, di rifiutare un token appartenente a un account eliminato.

Creare un endpoint protetto

Possiamo applicare il decoratore a qualsiasi route che richieda un utente autenticato.

@app.route("/profile", methods=["GET"])
@token_required
def profile(current_user):
    return jsonify({
        "id": current_user["id"],
        "username": current_user["username"],
    }), 200


@app.route("/health", methods=["GET"])
def health():
    return jsonify({"status": "ok"}), 200


if __name__ == "__main__":
    app.run(debug=False)

La route /profile restituisce i dati dell'utente associato al token. La route /health, invece, è pubblica e permette di verificare che il servizio risponda.

Il server di sviluppo Flask è adatto ai test locali, non alla pubblicazione diretta su Internet. In produzione è necessario utilizzare un server WSGI appropriato e configurare correttamente HTTPS.

Provare l'autenticazione con curl

Possiamo verificare il funzionamento dell'applicazione senza sviluppare un frontend.

Avviamo il server:

python app.py

Registriamo un utente:

curl -X POST http://127.0.0.1:5000/register \
  -H "Content-Type: application/json" \
  -d '{"username":"mario","password":"UnaPasswordLunga123!"}'

La risposta attesa è un codice HTTP 201, che indica la creazione dell'account.

Effettuiamo il login:

curl -X POST http://127.0.0.1:5000/login \
  -H "Content-Type: application/json" \
  -d '{"username":"mario","password":"UnaPasswordLunga123!"}'

La risposta contiene un access_token. Copiamo il valore restituito e usiamolo per accedere al profilo:

curl http://127.0.0.1:5000/profile \
  -H "Authorization: Bearer INSERISCI_IL_TOKEN"

Se il token è valido e non è scaduto, la risposta contiene l'identificativo e il nome utente. Se manca il token, la firma non è valida oppure il token è scaduto, l'endpoint restituisce un errore HTTP 401.

Questi comandi sono pensati per un ambiente locale di test. In produzione le richieste devono utilizzare HTTPS e le credenziali non devono essere inserite in script condivisi o repository pubblici.

JWT o sessioni tradizionali?

JWT viene spesso presentato come una soluzione automaticamente superiore alle sessioni. La realtà è meno spettacolare.

Con una sessione tradizionale, il server conserva lo stato dell'autenticazione e il client presenta un identificativo di sessione. Il server può invalidare quella sessione modificando o eliminando lo stato associato.

Con un JWT firmato e verificato localmente, il server può convalidare il token senza consultare un archivio di sessioni a ogni richiesta. Questo può semplificare alcuni sistemi distribuiti, ma non elimina la necessità di un database per gli utenti, né risolve automaticamente tutti i problemi di scalabilità.

Il principale compromesso riguarda la revoca. Un token valido continua normalmente a funzionare fino alla scadenza, anche se nel frattempo l'utente cambia password o perde i privilegi. Nell'esempio abbiamo ridotto questo rischio utilizzando una durata di quindici minuti e verificando che l'utente esista ancora, ma non abbiamo implementato una revoca completa.

Per esigenze più articolate si possono adottare token di accesso brevi accompagnati da refresh token, oppure meccanismi di revoca e rotazione. I refresh token devono essere gestiti con particolare attenzione, perché consentono di ottenere nuovi token di accesso.

Anche la conservazione lato client conta. Un token sottratto può essere utilizzato da chi lo possiede fino alla scadenza o alla sua invalidazione. La scelta tra cookie sicuri e altri meccanismi di conservazione dipende dall'architettura; per i cookie occorre considerare anche le protezioni contro gli attacchi CSRF.

Limiti dell'esempio e accorgimenti per la produzione

Il codice mostra i meccanismi essenziali dell'autenticazione JWT, ma non costituisce da solo un sistema completo per la produzione.

Prima di pubblicare un'applicazione è necessario considerare almeno questi aspetti:

  • HTTPS: protegge le credenziali e i token durante il trasferimento.
  • Gestione delle chiavi: la chiave di firma deve essere sufficientemente robusta, protetta e ruotabile.
  • Rate limiting: limita i tentativi ripetuti di login e gli abusi degli endpoint pubblici.
  • Revoca e rinnovo: definisce cosa succede quando un account viene disabilitato o un token viene compromesso.
  • Autorizzazione: essere autenticati non significa poter accedere a qualsiasi risorsa. I permessi devono essere verificati separatamente.
  • Validazione dei dati: occorre applicare limiti e regole ai dati ricevuti, oltre a gestire correttamente gli errori.
  • Test automatici: registrazione, login, token scaduti, credenziali errate e accessi non autorizzati devono essere verificati sistematicamente.

Un ultimo dettaglio: firmare un token non cifra i suoi contenuti e non dimostra che l'utente abbia il diritto di compiere qualsiasi operazione. Il token è uno strumento per verificare determinate informazioni, non un sostituto della progettazione della sicurezza.

JWT è utile quando risponde a un'esigenza concreta dell'architettura. Se le sessioni tradizionali risolvono già il problema in modo più semplice, non c'è alcun premio per aver aggiunto complessità.