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>
This commit is contained in:
obervinov
2026-06-24 19:15:31 +04:00
parent 9641e169f2
commit 4273c541c5
23 changed files with 1505 additions and 6 deletions
+118 -1
View File
@@ -1,9 +1,14 @@
import hashlib
import hmac
import secrets
import pytz
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser, Group
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db import IntegrityError, models, transaction
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from apps.users.managers import UserManager
@@ -524,3 +529,115 @@ class UserSettings(models.Model):
def clean(self):
super().clean()
class APITokenManager(models.Manager):
def create_token(self, *, user, name: str, expires_at=None):
token_secret = secrets.token_urlsafe(32)
token_hash = self.model.hash_secret(token_secret)
# token_key is unique; the pre-check in generate_token_key still leaves a
# tiny race window under concurrency, so retry on the unique-constraint
# violation with a fresh key instead of failing the request.
last_error = None
for _ in range(5):
token = self.model(
user=user,
name=name,
token_key=self.model.generate_token_key(),
token_hash=token_hash,
expires_at=expires_at,
)
token.full_clean()
try:
with transaction.atomic():
token.save()
except IntegrityError as exc:
last_error = exc
continue
return token, token.build_raw_token(token_secret)
raise last_error
class APIToken(models.Model):
TOKEN_PREFIX = "wygiwyh_pat_"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="api_tokens",
verbose_name=_("User"),
)
name = models.CharField(max_length=255, verbose_name=_("Name"))
token_key = models.CharField(
max_length=16,
unique=True,
db_index=True,
verbose_name=_("Token key"),
)
token_hash = models.CharField(max_length=255, verbose_name=_("Token hash"))
last_used_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Last used at"),
)
expires_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Expires at"),
)
revoked_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Revoked at"),
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated at"))
objects = APITokenManager()
class Meta:
indexes = [
models.Index(fields=["user", "revoked_at"]),
models.Index(fields=["expires_at"]),
]
ordering = ["-created_at"]
verbose_name = _("API token")
verbose_name_plural = _("API tokens")
def __str__(self):
return f"{self.user} / {self.name}"
@classmethod
def generate_token_key(cls) -> str:
while True:
candidate = secrets.token_hex(8)
if not cls.objects.filter(token_key=candidate).exists():
return candidate
@classmethod
def parse_raw_token(cls, raw_token: str):
if not raw_token.startswith(cls.TOKEN_PREFIX):
raise ValueError("Token is missing the expected prefix.")
payload = raw_token.removeprefix(cls.TOKEN_PREFIX)
token_key, separator, token_secret = payload.partition(".")
if not separator or not token_key or not token_secret:
raise ValueError("Token is malformed.")
return token_key, token_secret
def build_raw_token(self, token_secret: str) -> str:
return f"{self.TOKEN_PREFIX}{self.token_key}.{token_secret}"
@staticmethod
def hash_secret(token_secret: str) -> str:
# The secret is a 256-bit random value (secrets.token_urlsafe(32)), so a
# single SHA-256 is sufficient and avoids a slow KDF on every request.
return hashlib.sha256(token_secret.encode("utf-8")).hexdigest()
def check_secret(self, raw_secret: str) -> bool:
return hmac.compare_digest(self.token_hash, self.hash_secret(raw_secret))
def is_expired(self) -> bool:
return self.expires_at is not None and self.expires_at <= timezone.now()