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
+37 -1
View File
@@ -4,13 +4,19 @@ from django.contrib.auth.forms import (
UserCreationForm,
AdminPasswordChangeForm,
)
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.contrib import admin
from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import Group
from apps.users.models import User, UserSettings
from apps.users.models import APIToken, User, UserSettings
@admin.action(description=_("Revoke selected API tokens"))
def revoke_api_tokens(modeladmin, request, queryset):
queryset.update(revoked_at=timezone.now())
admin.site.unregister(Group)
@@ -77,3 +83,33 @@ class GroupAdmin(BaseGroupAdmin, ModelAdmin):
admin.site.register(UserSettings)
@admin.register(APIToken)
class APITokenAdmin(admin.ModelAdmin):
actions = [revoke_api_tokens]
list_display = (
"name",
"user",
"token_key",
"created_at",
"last_used_at",
"expires_at",
"revoked_at",
)
search_fields = ("name", "user__email", "token_key")
# Never expose the secret hash in the form; it must not be editable.
exclude = ("token_hash",)
readonly_fields = (
"user",
"name",
"token_key",
"created_at",
"updated_at",
"last_used_at",
"expires_at",
"revoked_at",
)
def has_add_permission(self, request):
return False
+48
View File
@@ -1,4 +1,7 @@
from datetime import timedelta
from apps.common.middleware.thread_local import get_current_user
from apps.users.models import APIToken
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.tom_select import TomSelect
from apps.users.models import UserSettings
@@ -16,6 +19,7 @@ from django.contrib.auth.forms import (
UsernameField,
)
from django.db import transaction
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@@ -427,3 +431,47 @@ class UserAddForm(UserCreationForm):
if commit:
user.save()
return user
class APITokenCreateForm(forms.Form):
name = forms.CharField(
max_length=255,
label=_("Token name"),
help_text=_("Use a descriptive name such as n8n, Home Assistant, or backup job."),
)
expires_in_days = forms.IntegerField(
required=False,
min_value=1,
label=_("Expires in days"),
help_text=_("Leave empty for a non-expiring token."),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(
"name",
"expires_in_days",
FormActions(
NoClassSubmit(
"submit",
_("Create token"),
css_class="btn btn-outline-primary w-full",
),
),
)
def save(self, user):
expires_in_days = self.cleaned_data.get("expires_in_days")
expires_at = None
if expires_in_days:
expires_at = timezone.now() + timedelta(days=expires_in_days)
return APIToken.objects.create_token(
user=user,
name=self.cleaned_data["name"],
expires_at=expires_at,
)
@@ -0,0 +1,36 @@
# Generated by Django 5.2.15 on 2026-06-24 09:21
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0025_alter_usersettings_default_account'),
]
operations = [
migrations.CreateModel(
name='APIToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Name')),
('token_key', models.CharField(db_index=True, max_length=16, unique=True, verbose_name='Token key')),
('token_hash', models.CharField(max_length=255, verbose_name='Token hash')),
('last_used_at', models.DateTimeField(blank=True, null=True, verbose_name='Last used at')),
('expires_at', models.DateTimeField(blank=True, null=True, verbose_name='Expires at')),
('revoked_at', models.DateTimeField(blank=True, null=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')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'verbose_name': 'API token',
'verbose_name_plural': 'API tokens',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['user', 'revoked_at'], name='users_apito_user_id_73edec_idx'), models.Index(fields=['expires_at'], name='users_apito_expires_2b737c_idx')],
},
),
]
+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()
+45
View File
@@ -0,0 +1,45 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from apps.users.models import APIToken
class UserAPITokenViewsTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
email="user@example.com",
password="test-password",
)
self.client.force_login(self.user)
self.htmx_headers = {"HTTP_HX_REQUEST": "true"}
def test_user_settings_renders_api_token_section(self):
response = self.client.get(reverse("user_settings"), **self.htmx_headers)
self.assertContains(response, "API Tokens")
self.assertContains(response, reverse("user_api_token_add"))
def test_can_create_api_token_from_ui(self):
response = self.client.post(
reverse("user_api_token_add"),
{"name": "n8n", "expires_in_days": "30"},
**self.htmx_headers,
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Copy this token now")
self.assertEqual(APIToken.objects.filter(user=self.user, name="n8n").count(), 1)
def test_can_revoke_own_api_token(self):
token, _ = APIToken.objects.create_token(user=self.user, name="n8n")
response = self.client.delete(
reverse("user_api_token_revoke", kwargs={"token_id": token.id}),
**self.htmx_headers,
)
self.assertEqual(response.status_code, 200)
token.refresh_from_db()
self.assertIsNotNone(token.revoked_at)
self.assertContains(response, "Revoked")
+10
View File
@@ -32,6 +32,16 @@ urlpatterns = [
views.update_settings,
name="user_settings",
),
path(
"user/api-tokens/add/",
views.api_token_add,
name="user_api_token_add",
),
path(
"user/api-tokens/<int:token_id>/revoke/",
views.api_token_revoke,
name="user_api_token_revoke",
),
path(
"users/",
views.users_index,
+53 -2
View File
@@ -2,12 +2,13 @@ from apps.common.decorators.demo import disabled_on_demo
from apps.common.decorators.htmx import only_htmx
from apps.common.decorators.user import htmx_login_required, is_superuser
from apps.users.forms import (
APITokenCreateForm,
LoginForm,
UserAddForm,
UserSettingsForm,
UserUpdateForm,
)
from apps.users.models import UserSettings
from apps.users.models import APIToken, UserSettings
from django.contrib import messages
from django.contrib.auth import get_user_model, logout
from django.contrib.auth.decorators import login_required
@@ -18,6 +19,7 @@ from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
@@ -112,7 +114,56 @@ def update_settings(request):
else:
form = UserSettingsForm(instance=user_settings)
return render(request, "users/fragments/user_settings.html", {"form": form})
return render(
request,
"users/fragments/user_settings.html",
{
"form": form,
"api_token_form": APITokenCreateForm(),
"api_tokens": request.user.api_tokens.all(),
},
)
def _render_api_tokens(request, *, form=None, raw_token=None):
return render(
request,
"users/fragments/api_tokens.html",
{
"api_token_form": form or APITokenCreateForm(),
"api_tokens": request.user.api_tokens.all(),
"raw_token": raw_token,
},
)
@only_htmx
@htmx_login_required
@require_http_methods(["POST"])
def api_token_add(request):
form = APITokenCreateForm(request.POST)
if form.is_valid():
_token, raw_token = form.save(user=request.user)
messages.success(request, _("API token created successfully"))
return _render_api_tokens(
request,
form=APITokenCreateForm(),
raw_token=raw_token,
)
return _render_api_tokens(request, form=form)
@only_htmx
@htmx_login_required
@require_http_methods(["DELETE"])
def api_token_revoke(request, token_id):
token = get_object_or_404(APIToken, id=token_id, user=request.user)
if token.revoked_at is None:
token.revoked_at = timezone.now()
token.save(update_fields=["revoked_at"])
messages.success(request, _("API token revoked successfully"))
return _render_api_tokens(request)
@only_htmx