
# 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](/docs/api/management/apis/create)). Son `identifier`, par exemple `https://api.example.com`, est l'audience des jetons que votre API accepte.
- Le scope `messages:read` sur cette API, accordé à l'application qui l'appelle.
- Python 3.10 ou ultérieur.

## 1. Installer le SDK

```sh
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](#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.

```python
@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.

```python
@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

```sh
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 :

```sh
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](/docs/sdk/testing).

```python
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)
```

```python
    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)
```

```sh
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](/docs/sdk/protect-api#réponses).

## 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](/docs/sdk/protect-api#cache-de-révocation-tokenrevoked).

## Étapes suivantes

- [Protéger votre API avec un middleware](/docs/sdk/protect-api) : Flask, exigences de step-up, DPoP et toutes les options.
- [Vérifier les jetons](/docs/sdk/token-verification) : le vérificateur sous le middleware.
- [Kit de test hors ligne](/docs/sdk/testing).
