mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-07-05 04:21:43 +02:00
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:
+37
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user