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
- Un environnement KeenAuth. Son émetteur ressemble à
https://your-environment.eu.keenauth.app. - Une API enregistrée dans l'environnement (Créer une API). Son
identifier, par exemplehttps://api.example.com, est l'audience des jetons que votre API accepte. - Le scope
messages:readsur cette API, accordé à l'application qui l'appelle. - Python 3.10 ou ultérieur.
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.
- Dans le tableau de bord, créez un point de terminaison webhook avec l'URL
https://<your host>/webhooks/keenauthqui s'abonne àtoken.revoked, et exportez son secret de signature sousKEENAUTH_WEBHOOK_SECRET. - Le gestionnaire vérifie la signature avec le SDK sur le corps brut et répond
400lorsqu'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épond204; le middleware rejette ensuite chaque jeton correspondant émis avantnot_before. - KeenAuth livre au moins une fois, donc le gestionnaire applique chaque
webhook-idune seule fois. Si le marqueur ne peut pas être stocké, il répond503et 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
- Protéger votre API avec un middleware : Flask, exigences de step-up, DPoP et toutes les options.
- Vérifier les jetons : le vérificateur sous le middleware.
- Kit de test hors ligne.