
# Django API 보호하기

이 빠른 시작 가이드에서는 KeenAuth 액세스 토큰으로 Django API를 보호합니다. Python SDK의 `@protect` 뷰 데코레이터를 추가하고, 한 뷰에 스코프를 요구하며, SDK 테스트 키트로 API를 오프라인에서 테스트합니다. 예제는 Django 5.2를 사용하며 데이터베이스는 사용하지 않습니다.

API에는 엔드포인트가 세 개 있습니다:

| 엔드포인트 | 요구 사항 |
|---|---|
| `GET /api/public` | 없음 |
| `GET /api/private` | API에 대한 유효한 액세스 토큰 |
| `GET /api/messages` | `messages:read` 스코프가 포함된 유효한 액세스 토큰 |

## 시작하기 전에

- KeenAuth 환경. 발급자는 `https://your-environment.eu.keenauth.app`와 같은 형태입니다.
- 환경에 등록된 API([API 생성](/docs/api/management/apis/create)). 해당 API의 `identifier`(예: `https://api.example.com`)는 API가 수락하는 토큰의 대상(audience)입니다.
- 해당 API에 대한 `messages:read` 스코프. 이 스코프는 API를 호출하는 애플리케이션에 부여되어 있어야 합니다.
- Python 3.10 이상.

## 1. SDK 설치

```sh
pip install "keenauth-python[django]"
```

SDK는 아직 공개되지 않았습니다. 공개 전까지 예제는 SDK 소스에서 설치합니다. 예제에서는 `requirements.txt`에서 모든 패키지를 정확한 버전으로 고정합니다.

## 2. 구성

`project/settings.py`는 모든 값을 환경 변수에서 읽습니다:

| 변수 | 필수 여부 | 의미 |
|---|---|---|
| `DJANGO_SECRET_KEY` | 예 | Django의 시크릿 키입니다. 하나 생성해 두고 절대 커밋하지 마세요 |
| `DJANGO_ALLOWED_HOSTS` | 아니요 | 쉼표로 구분된 호스트 이름입니다. 기본값은 `localhost,127.0.0.1` |
| `KEENAUTH_ISSUER` | 예 | 환경의 발급자입니다 |
| `KEENAUTH_AUDIENCE` | 예 | API의 식별자입니다 |
| `PUBLIC_BASE_URL` | 예 | 클라이언트가 호출하는 URL입니다([프록시 뒤에서](#프록시-뒤에서) 참조) |
| `KEENAUTH_WEBHOOK_SECRET` | 권장 | `token.revoked`를 전달하는 웹훅 엔드포인트의 `idp_whsec_` 서명 시크릿입니다(교체하는 동안에는 쉼표로 구분해 두 개를 입력합니다). 이 값이 없으면 API가 폐기된 토큰을 알 수 없습니다 |

## 3. 뷰 보호하기

`@protect(protector)`는 유효한 토큰을 요구하고 검증된 클레임을 `request.auth`에 넣습니다. `scopes=[...]`는 스코프도 요구합니다. 검사에 실패하면 뷰가 실행되기 전에 `application/problem+json` 응답을 반환합니다.

```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`는 시작할 때 설정에서 protector를 만듭니다(`api/apps.py`의 `ApiConfig.ready`). 따라서 설정이 누락되면 첫 요청이 오기 전에 서버가 중지됩니다. verifier는 토큰을 처음 확인할 때 환경에서 서명 키를 가져옵니다.

```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. 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`는 개발 전용입니다. 프로덕션에서는 WSGI 서버에서 `project.wsgi`를 실행하세요.

첫 호출을 하려면 API에 `messages:read` 스코프가 있는 머신 간(M2M) 애플리케이션의 클라이언트 자격 증명 그랜트로 토큰을 받으세요:

```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. 네트워크 없이 테스트

`keenauth.testing`는 공개 테스트 키로 서명한 토큰을 발급하며, `testing.verifier`는 이런 토큰만 수락합니다. 테스트에서는 `get_protector`를 이 verifier를 사용하는 protector로 대체하므로 네트워크, KeenAuth 계정, 설정의 테스트 전환 스위치가 필요하지 않습니다. 자세한 내용은 [오프라인 테스트 키트](/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
```

예제의 테스트는 유효한 토큰이 수락되는지 확인합니다. 또한 누락되었거나 만료되었거나 대상이 잘못된 토큰, 누락된 스코프, URL 쿼리에 포함된 토큰, 폐기된 토큰이 아래 상태 코드로 거부되는지 확인합니다. 또한 `PUBLIC_BASE_URL`에 대한 증명(proof)이 포함된 DPoP 바인딩 토큰을 보내고, 프로덕션 protector가 테스트 발급자를 거부하는지도 확인합니다. 테스트는 서명된 `token.revoked` 전달도 보냅니다. 유효한 전달은 토큰을 폐기하고, 위조되었거나 변조되었거나 오래된 전달은 `400`를 받고 아무것도 폐기하지 않으며, 반복된 전달은 한 번만 효과가 적용됩니다.

## 응답

| 요청 | 상태 | 본문의 `code` |
|---|---|---|
| `Authorization` 헤더 없음 | `401` | `missing_token` |
| 만료된 토큰, 다른 API용 토큰, 잘못된 서명, 폐기된 토큰 | `401` | `invalid_token` |
| 필요한 스코프가 없는 토큰 | `403` | `insufficient_scope` |
| URL 쿼리에 포함된 토큰(`?access_token=`) | `400` | `invalid_request` |

본문은 `application/problem+json`이고, `WWW-Authenticate` 헤더에는 표준 챌린지가 담깁니다. Django는 여러 챌린지를 하나의 헤더로 합칩니다. 전체 표는 [미들웨어로 API 보호하기](/docs/sdk/protect-api#응답)에 있습니다.

## 프록시 뒤에서

클라이언트가 DPoP를 사용하면 미들웨어는 각 증명이 클라이언트가 호출한 URL을 대상으로 만들어졌는지 확인합니다. `PUBLIC_BASE_URL`를 클라이언트가 호출하는 외부 URL로 설정하세요(예: `https://api.example.com`). 프록시나 로드 밸런서 뒤에 있더라도 마찬가지입니다. 이 설정은 필수이며 `https`를 사용해야 합니다(`http`는 `localhost`, `127.0.0.1`, `[::1]`에서만 허용됩니다). 이 설정이 없으면 앱이 시작되지 않습니다. 미들웨어는 `X-Forwarded-*` 헤더를 직접 읽지 않습니다. Django는 `SECURE_PROXY_SSL_HEADER` 또는 `USE_X_FORWARDED_HOST`를 설정한 경우에만 이 헤더를 적용하며, `PUBLIC_BASE_URL`가 설정되어 있으면 검사되는 URL은 이 헤더에 의존하지 않습니다.

## 폐기된 토큰

액세스 토큰은 만료될 때까지 유효하므로, KeenAuth는 토큰이 폐기되면 API에 알려줍니다. 예제는 인메모리 폐기 캐시를 protector에 전달하고, 이 캐시에 값을 기록하는 `token.revoked` 웹훅을 `POST /webhooks/keenauth`(`api/webhook.py`)에서 제공합니다.

1. 대시보드에서 URL이 `https://<your host>/webhooks/keenauth`인 웹훅 엔드포인트를 만들고 `token.revoked`를 구독하게 설정한 뒤, 서명 시크릿을 `KEENAUTH_WEBHOOK_SECRET`로 내보내세요.
2. 핸들러는 원시 본문에 대해 SDK로 서명을 검증합니다. 서명이 잘못되었거나 타임스탬프가 5분 이상 차이 나면 `400`를 응답합니다. 이벤트의 셀렉터(`access_token`, `session`, `user`, `client`, `org`, `org_user`, `client_user`)를 폐기 마커에 매핑하고 `204`를 응답합니다. 그러면 미들웨어가 `not_before` 이전에 발급된 일치하는 모든 토큰을 거부합니다.
3. KeenAuth는 최소 한 번 전달하므로, 핸들러는 각 `webhook-id`를 한 번만 적용합니다. 마커를 저장할 수 없으면 `503`를 응답하고, KeenAuth는 이벤트를 다시 전달합니다. 다른 이벤트 유형과 어떤 액세스 토큰과도 일치할 수 없는 셀렉터(`refresh_family`, `api`)는 무시합니다.

핸들러는 이벤트 ID, 셀렉터 유형, 사유를 기록하며, 본문, 서명, 시크릿은 기록하지 않습니다. 각 워커 프로세스에는 고유한 캐시가 있습니다. 워커나 인스턴스가 여러 개라면 대신 공유 저장소를 사용하세요. 자세한 내용은 [폐기 캐시](/docs/sdk/protect-api#폐기-캐시tokenrevoked)를 참조하세요.

## 다음 단계

- [미들웨어로 API 보호하기](/docs/sdk/protect-api): Flask, 단계별 인증 요구 사항, DPoP 및 모든 옵션.
- [토큰 검증](/docs/sdk/token-verification): 미들웨어 아래에서 동작하는 verifier.
- [오프라인 테스트 키트](/docs/sdk/testing).
