Scoped API tokens for the random endpoint, with a management page

Backend: a GreetingToken model stores only a SHA-256 hash of a j621r_…
key (shown once at creation) plus label, prefix, created/last-used. A
dedicated GreetingTokenAuthentication understands the usual
'Authorization: Token …' header but is registered only on RandomItemView
(alongside the normal token auth), so a greeting token authenticates
/api/random/ and is rejected with 401 everywhere else — exactly the scope
shell greetings need. Endpoints: GET/POST /api/auth/greeting-tokens/ and
DELETE /api/auth/greeting-tokens/{id}/ (own tokens only; the list never
returns keys or hashes).

Frontend: /tokens page (Account → Shell tokens card, command palette entry)
lists tokens with label, prefix, created/last-used and revoke (shared
confirm dialog). Creating one shows the key with Copy and 'Copy for fish'
buttons plus a pointer to extras/fish_greeting.

Tests: apps/accounts/tests/test_greeting_tokens.py — 9 tests covering
create-once semantics and hashing, hidden keys in listings, the scope
guarantee (random 200 with a signed URL; 401 on files, storage, me, tags
cloud, delete and the token list itself), unknown/revoked keys, cross-user
revocation, last-used tracking and label limits.

Verified live: created a token, rolled /random (signed URL), got 401 from
four other endpoints, saw the list omit secrets, revoked it (204) and the
same key then 401'd on /random. Full suite: 39 tests green.
This commit is contained in:
2026-09-18 13:37:29 -05:00
parent 2d9493d9fe
commit 770b1e5ee6
19 changed files with 678 additions and 9 deletions
+49 -1
View File
@@ -1,5 +1,6 @@
import logging
from django.http import Http404
from rest_framework import mixins, status, viewsets
from rest_framework.authtoken.models import Token
from rest_framework.authtoken.views import ObtainAuthToken
@@ -13,9 +14,10 @@ from apps.core.permissions import IsAppStaff
from apps.library.models import MediaItem
from .crypto import encrypt_secret
from .models import User
from .models import GreetingToken, User
from .serializers import (
E621CredentialsSerializer,
GreetingTokenSerializer,
PreferencesSerializer,
RegisterSerializer,
UserListSerializer,
@@ -157,6 +159,52 @@ class PreferencesView(APIView):
return Response(preferences)
class GreetingTokenListView(APIView):
"""List and create the caller's random-endpoint tokens.
The plaintext key is returned once on creation; only its hash is stored,
and it only authenticates `/api/random/` (see GreetingToken).
"""
permission_classes = [IsAuthenticated]
def get(self, request):
tokens = GreetingToken.objects.filter(user=request.user)
return Response(GreetingTokenSerializer(tokens, many=True).data)
def post(self, request):
label = str(request.data.get("label") or "").strip()
if len(label) > 100:
return Response(
{"detail": "Label is too long (100 characters max)."},
status=status.HTTP_400_BAD_REQUEST,
)
token, key = GreetingToken.issue(request.user, label)
logger.info(
"Greeting token %s created by %s", token.prefix, request.user.username
)
return Response(
{**GreetingTokenSerializer(token).data, "key": key},
status=status.HTTP_201_CREATED,
)
class GreetingTokenDetailView(APIView):
"""Revoke one of the caller's tokens."""
permission_classes = [IsAuthenticated]
def delete(self, request, pk):
token = GreetingToken.objects.filter(pk=pk, user=request.user).first()
if token is None:
raise Http404
logger.info(
"Greeting token %s revoked by %s", token.prefix, request.user.username
)
token.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class UserViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,