Files
WYGIWYH/app/apps/api/authentication.py
T
obervinov 4273c541c5 Add API tokens and OAuth2 client support for external integrations
- Personal API tokens (model, user-settings UI, admin, management command,
  DRF auth class) for non-interactive API access from automations like n8n.
  Raw token shown once; only a SHA-256 hash is stored; last_used_at writes
  are throttled.
- OAuth2 authorization server via django-oauth-toolkit with authorization
  server metadata and optional, off-by-default Dynamic Client Registration
  (RFC 7591), so remote OAuth/MCP clients can authenticate and self-register.
- Tests for token auth, DCR gating and the management commands, plus
  .env.example and README documentation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:15:31 +04:00

65 lines
2.4 KiB
Python

from datetime import timedelta
from django.conf import settings
from django.utils import timezone
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.exceptions import AuthenticationFailed
from apps.users.models import APIToken
class APITokenAuthentication(BaseAuthentication):
keyword = "Token"
def authenticate(self, request):
auth = get_authorization_header(request).split()
if not auth or auth[0].lower() != self.keyword.lower().encode():
return None
if len(auth) != 2:
raise AuthenticationFailed("Invalid API token header.")
try:
raw_token = auth[1].decode("utf-8")
except UnicodeDecodeError as exc:
raise AuthenticationFailed("Invalid API token header.") from exc
# Only claim tokens carrying our prefix; otherwise return None so the
# request falls through to other authenticators (e.g. DRF's built-in
# TokenAuthentication, which shares the "Token" keyword).
if not raw_token.startswith(APIToken.TOKEN_PREFIX):
return None
try:
token_key, token_secret = APIToken.parse_raw_token(raw_token)
except ValueError as exc:
raise AuthenticationFailed("Invalid API token.") from exc
token = APIToken.objects.select_related("user").filter(token_key=token_key).first()
if token is None or not token.check_secret(token_secret):
raise AuthenticationFailed("Invalid API token.")
if token.revoked_at is not None:
raise AuthenticationFailed("API token has been revoked.")
if token.is_expired():
raise AuthenticationFailed("API token has expired.")
if not token.user.is_active:
raise AuthenticationFailed("User account is disabled.")
self._touch_last_used(token)
return (token.user, token)
@staticmethod
def _touch_last_used(token):
# Avoid a write on every request: only refresh once per interval.
now = timezone.now()
interval = settings.API_TOKEN_LAST_USED_UPDATE_INTERVAL
if (
token.last_used_at is None
or (now - token.last_used_at) >= timedelta(seconds=interval)
):
token.last_used_at = now
token.save(update_fields=["last_used_at"])
def authenticate_header(self, request):
return self.keyword