Protéger une API Django

Ce guide de démarrage protège une API Django avec des jetons d'accès KeenAuth. Vous ajoutez le décorateur de vue @protect du SDK Python, exigez un scope sur une vue et testez l'API hors ligne avec le kit de test du SDK. L'exemple utilise Django 5.2 et aucune base de données.

L'API comporte trois points de terminaison :

Point de terminaison Exige
GET /api/public rien
GET /api/private un jeton d'accès valide pour votre API
GET /api/messages un jeton d'accès valide avec le scope messages:read

Avant de commencer

1. Installer le SDK

pip install "keenauth-python[django]"

Le SDK n'est pas encore publié. En attendant, l'exemple l'installe depuis le code source du SDK. L'exemple épingle chaque paquet à une version exacte dans requirements.txt.

2. Configurer

project/settings.py lit chaque valeur depuis des variables d'environnement :

Variable Obligatoire Signification
DJANGO_SECRET_KEY oui Clé secrète de Django ; générez-en une et ne la committez jamais
DJANGO_ALLOWED_HOSTS non Noms d'hôte séparés par des virgules, valeur par défaut localhost,127.0.0.1
KEENAUTH_ISSUER oui L'émetteur de votre environnement
KEENAUTH_AUDIENCE oui L'identifiant de votre API
PUBLIC_BASE_URL oui L'URL appelée par les clients (voir Derrière un proxy)
KEENAUTH_WEBHOOK_SECRET recommandé Le secret de signature idp_whsec_ du point de terminaison webhook qui envoie token.revoked (deux, séparés par une virgule, pendant la rotation). Sans lui, l'API n'apprend jamais les jetons révoqués

3. Protéger vos vues

@protect(protector) exige un jeton valide et place les revendications vérifiées dans request.auth ; scopes=[...] exige en plus des scopes. Un contrôle échoué renvoie une réponse application/problem+json avant l'exécution de la vue.

@require_GET
def public(request: HttpRequest) -> JsonResponse:
    # No token needed.
    return JsonResponse({"message": "Hello from a public endpoint."})


@require_GET
@protect(protector)
def private(request: HttpRequest) -> JsonResponse:
    # Any valid access token for this API; the verified claims are in request.auth.
    return JsonResponse({"message": "Hello from a private endpoint.", "sub": request.auth.subject})


@require_GET
@protect(protector, scopes=["messages:read"])
def messages(request: HttpRequest) -> JsonResponse:
    # A valid access token that also carries the messages:read scope.
    return JsonResponse({"messages": ["Hello from a scoped endpoint."]})

api/auth.py construit le protecteur à partir des paramètres au démarrage (ApiConfig.ready dans api/apps.py), donc un paramètre manquant arrête le serveur avant la première requête. Le vérificateur récupère les clés de signature de votre environnement la première fois qu'il voit un jeton.

@cache
def get_protector() -> Protector:
    if not settings.KEENAUTH_ISSUER or not settings.KEENAUTH_AUDIENCE or not settings.KEENAUTH_PUBLIC_BASE_URL:
        raise ImproperlyConfigured("KEENAUTH_ISSUER, KEENAUTH_AUDIENCE and PUBLIC_BASE_URL are required")
    # The verifier refuses an issuer under the reserved .invalid domain, so a test token can never pass here.
    verifier = Verifier(VerifierConfig(issuer=settings.KEENAUTH_ISSUER, audience=settings.KEENAUTH_AUDIENCE))
    return Protector(
        verifier,
        public_base_url=settings.KEENAUTH_PUBLIC_BASE_URL,
        revocation=revocation,
        # The error never contains the token or claim values; never log the Authorization or DPoP header.
        on_error=lambda err: log.warning("rejected: %s", err),
    )


@cache
def get_webhook() -> Optional[RevocationWebhook]:
    """The token.revoked webhook, or None when KEENAUTH_WEBHOOK_SECRET is not set. A malformed secret raises
    WebhookSecretError (without echoing it), so ApiConfig.ready stops the server at startup."""
    if not settings.KEENAUTH_WEBHOOK_SECRETS:
        return None
    return RevocationWebhook(settings.KEENAUTH_WEBHOOK_SECRETS, revocation)


class ProtectorProxy:
    """Hands each request to `get_protector()`, so the `@protect` decorators can be applied at import time."""

    def authenticate(self, req: RequestInfo, requirement: Optional[Requirement] = None) -> AccessClaims:
        return get_protector().authenticate(req, requirement)


protector = ProtectorProxy()

4. Lancer et appeler l'API

export DJANGO_SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(50))')"
export KEENAUTH_ISSUER=https://your-environment.eu.keenauth.app
export KEENAUTH_AUDIENCE=https://api.example.com
export PUBLIC_BASE_URL=https://api.example.com
python manage.py runserver 8080

runserver est réservé au développement ; en production, lancez project.wsgi sous un serveur WSGI.

Pour un premier appel, obtenez un jeton avec le flux client credentials d'une application machine-à-machine qui possède le scope messages:read sur votre API :

ACCESS_TOKEN=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=client_credentials \
  -d resource=https://api.example.com \
  -d scope=messages:read \
  https://your-environment.eu.keenauth.app/oauth2/token | jq -r .access_token)

curl -i http://localhost:8080/api/messages -H "Authorization: Bearer $ACCESS_TOKEN"

5. Tester sans réseau

keenauth.testing émet des jetons signés avec une clé de test publique, et testing.verifier n'accepte que ces jetons. Les tests remplacent get_protector par un protecteur qui utilise ce vérificateur ; ils n'ont donc besoin ni de réseau, ni de compte KeenAuth, ni d'interrupteur de test dans les paramètres. Voir Kit de test hors ligne.

class ProtectedAPITest(SimpleTestCase):
    def setUp(self) -> None:
        self.cache = MemoryRevocationCache()
        test_protector = Protector(testing.verifier(audience=AUDIENCE), public_base_url=BASE_URL, revocation=self.cache)
        patcher = mock.patch.object(auth, "get_protector", return_value=test_protector)
        patcher.start()
        self.addCleanup(patcher.stop)
    def test_scope(self) -> None:
        self.assert_problem(self.get("/api/messages", testing.mint_access_token(AUDIENCE)), 403, "insufficient_scope",
                            'scope="messages:read"')
        tok = testing.mint_access_token(AUDIENCE, claims={"scope": "profile messages:read"})
        self.assertEqual(self.get("/api/messages", tok).status_code, 200)
set -a; . ./.env.example; set +a
DJANGO_SECRET_KEY=insecure-test-only python manage.py test

Les tests de l'exemple vérifient qu'un jeton valide est accepté, et qu'un jeton absent, expiré ou pour une autre audience, un scope manquant, un jeton dans l'URL de la requête et un jeton révoqué sont rejetés avec les statuts ci-dessous. Ils envoient aussi un jeton lié à DPoP avec une preuve pour PUBLIC_BASE_URL, et vérifient que le protecteur de production refuse l'émetteur de test. Les tests envoient également des livraisons token.revoked signées : une livraison valide révoque le jeton, une livraison falsifiée, altérée ou périmée reçoit 400 et ne révoque rien, et une livraison répétée n'a qu'un seul effet.

Réponses

Requête Statut code dans le corps
Aucun en-tête Authorization 401 missing_token
Jeton expiré, jeton pour une autre API, signature invalide, jeton révoqué 401 invalid_token
Jeton sans le scope requis 403 insufficient_scope
Jeton dans l'URL de la requête (?access_token=) 400 invalid_request

Le corps est application/problem+json et l'en-tête WWW-Authenticate porte le défi standard ; Django fusionne plusieurs défis en un seul en-tête. Le tableau complet se trouve dans Protéger votre API avec un middleware.

Derrière un proxy

Si les clients utilisent DPoP, le middleware vérifie que chaque preuve a été faite pour l'URL appelée par le client. Définissez PUBLIC_BASE_URL sur l'URL externe appelée par les clients, par exemple https://api.example.com, y compris derrière un proxy ou un répartiteur de charge. Ce paramètre est obligatoire et doit utiliser https (http uniquement pour localhost, 127.0.0.1 ou [::1]) ; l'application refuse de démarrer sans lui. Le middleware ne lit jamais lui-même les en-têtes X-Forwarded-* ; Django ne les applique que si vous définissez SECURE_PROXY_SSL_HEADER ou USE_X_FORWARDED_HOST, et avec PUBLIC_BASE_URL défini, l'URL vérifiée ne dépend pas de ces en-têtes.

Jetons révoqués

Un jeton d'accès reste valide jusqu'à son expiration ; KeenAuth informe donc votre API lorsque des jetons sont révoqués. L'exemple transmet un cache de révocation en mémoire au protecteur et sert un webhook token.revoked à POST /webhooks/keenauth (api/webhook.py) qui l'alimente.

  1. Dans le tableau de bord, créez un point de terminaison webhook avec l'URL https://<your host>/webhooks/keenauth qui s'abonne à token.revoked, et exportez son secret de signature sous KEENAUTH_WEBHOOK_SECRET.
  2. Le gestionnaire vérifie la signature avec le SDK sur le corps brut et répond 400 lorsqu'elle est invalide ou que l'horodatage diffère de plus de cinq minutes. Il associe le sélecteur de l'événement (access_token, session, user, client, org, org_user, client_user) à un marqueur de révocation et répond 204 ; le middleware rejette ensuite chaque jeton correspondant émis avant not_before.
  3. KeenAuth livre au moins une fois, donc le gestionnaire applique chaque webhook-id une seule fois. Si le marqueur ne peut pas être stocké, il répond 503 et KeenAuth renvoie l'événement. Il ignore les autres types d'événements et les sélecteurs qu'aucun jeton d'accès ne peut correspondre (refresh_family, api).

Le gestionnaire journalise l'identifiant de l'événement, le type de sélecteur et la raison, jamais le corps, une signature ou le secret. Chaque processus de travail a son propre cache ; avec plusieurs workers ou instances, utilisez plutôt un stockage partagé. Voir Cache de révocation.

Étapes suivantes