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
+8 -1
View File
@@ -6,7 +6,7 @@ from rest_framework import serializers
from apps.library.services import VIDEO_EXTENSIONS
from apps.library.services import signed_media_url as signed_library_url
from .models import User
from .models import User, GreetingToken
def signed_media_url(request, item):
@@ -92,6 +92,13 @@ class UserUpdateSerializer(serializers.Serializer):
role = serializers.ChoiceField(choices=User.ROLE_CHOICES, required=False)
class GreetingTokenSerializer(serializers.ModelSerializer):
class Meta:
model = GreetingToken
fields = ["id", "prefix", "label", "created_at", "last_used_at"]
read_only_fields = fields
class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, validators=[validate_password])