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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user