Files
J621/backend/apps/accounts/tests/test_greeting_tokens.py
JakeBreath 770b1e5ee6 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.
2026-09-18 13:37:29 -05:00

167 lines
6.2 KiB
Python

"""Scope-limited greeting tokens (`j621r_…`).
They exist so shell greetings and scripts can hold a credential that only
authenticates `/api/random/` — everything else must reject them — and they
are stored hashed, shown once.
"""
import hashlib
import json
import shutil
import tempfile
import time
from pathlib import Path
from django.contrib.auth import get_user_model
from django.test import Client, TestCase, override_settings
from rest_framework.authtoken.models import Token
from apps.accounts.models import GreetingToken, hash_bearer_token
from apps.library.models import MediaItem, MediaLocation
User = get_user_model()
def jpost(client, path, body=None):
return client.post(path, data=json.dumps(body or {}), content_type="application/json")
class GreetingTokenTests(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls._tmp = tempfile.mkdtemp(prefix="j621-tokens-")
cls._watched = Path(cls._tmp) / "library"
cls._watched.mkdir(parents=True, exist_ok=True)
cls._settings = override_settings(
MEDIA_ROOT=cls._tmp, WATCHED_FOLDER=str(cls._watched)
)
cls._settings.enable()
@classmethod
def tearDownClass(cls):
cls._settings.disable()
shutil.rmtree(cls._tmp, ignore_errors=True)
super().tearDownClass()
def setUp(self):
self.user = User.objects.create_user(
username="token-user", password="token-pass-123456"
)
self.other = User.objects.create_user(
username="token-other", password="token-pass-123456"
)
self.client = self.api_client(self.user)
self.other_client = self.api_client(self.other)
# One image so the random endpoint can answer.
path = self._watched / "token-test.png"
path.write_bytes(b"token-test")
self.item = MediaItem.objects.create(
md5=hashlib.md5(b"token-test").hexdigest(),
size=path.stat().st_size,
rating="s",
uploaded_by=self.user,
)
MediaLocation.objects.create(
item=self.item, path=str(path), rel_path=path.name, mtime=time.time()
)
def api_client(self, user):
client = Client()
client.defaults["HTTP_AUTHORIZATION"] = (
f"Token {Token.objects.create(user=user).key}"
)
return client
def token_client(self, key):
client = Client()
client.defaults["HTTP_AUTHORIZATION"] = f"Token {key}"
return client
def issue(self, client=None, label=""):
response = jpost(client or self.client, "/api/auth/greeting-tokens/", {"label": label})
self.assertEqual(response.status_code, 201)
return response.json()
def test_create_returns_the_key_once_and_stores_only_a_hash(self):
created = self.issue(self.client, "shell")
key = created["key"]
self.assertTrue(key.startswith("j621r_"))
self.assertEqual(created["label"], "shell")
token = GreetingToken.objects.get(pk=created["id"])
self.assertEqual(token.key_hash, hash_bearer_token(key))
self.assertNotIn(key, token.key_hash)
self.assertEqual(token.prefix, key[:12])
self.assertIsNone(token.last_used_at)
def test_list_hides_keys_and_hashes(self):
self.issue(self.client, "one")
self.issue(self.client, "two")
rows = self.client.get("/api/auth/greeting-tokens/").json()
self.assertEqual([row["label"] for row in rows], ["two", "one"])
for row in rows:
self.assertNotIn("key", row)
self.assertNotIn("key_hash", row)
self.assertTrue(row["prefix"].startswith("j621r_"))
def test_token_authenticates_random_and_nothing_else(self):
key = self.issue(self.other_client, "shell")["key"]
client = self.token_client(key)
response = client.get("/api/random/")
self.assertEqual(response.status_code, 200)
self.assertIn("sig=", response.json()["url"])
for method, path, body in (
("get", "/api/files/", None),
("get", "/api/storage/", None),
("get", "/api/auth/me/", None),
("get", "/api/tags/cloud/", None),
("post", "/api/delete/", {"j_ids": []}),
("get", "/api/auth/greeting-tokens/", None),
):
call = getattr(client, method)
if body is None:
self.assertEqual(call(path).status_code, 401, path)
else:
self.assertEqual(jpost(client, path, body).status_code, 401, path)
def test_normal_api_token_still_authenticates_random(self):
response = self.client.get("/api/random/")
self.assertEqual(response.status_code, 200)
self.assertIn("sig=", response.json()["url"])
def test_unknown_greeting_key_is_rejected(self):
client = self.token_client("j621r_" + "0" * 40)
self.assertEqual(client.get("/api/random/").status_code, 401)
def test_revoked_token_stops_working(self):
created = self.issue(self.client, "temporary")
client = self.token_client(created["key"])
self.assertEqual(client.get("/api/random/").status_code, 200)
response = self.client.delete(f"/api/auth/greeting-tokens/{created['id']}/")
self.assertEqual(response.status_code, 204)
self.assertEqual(client.get("/api/random/").status_code, 401)
self.assertFalse(GreetingToken.objects.filter(pk=created["id"]).exists())
def test_cannot_revoke_someone_elses_token(self):
created = self.issue(self.other_client, "theirs")
response = self.client.delete(f"/api/auth/greeting-tokens/{created['id']}/")
self.assertEqual(response.status_code, 404)
self.assertEqual(self.token_client(created["key"]).get("/api/random/").status_code, 200)
def test_last_used_is_recorded(self):
created = self.issue(self.client, "used")
self.token_client(created["key"]).get("/api/random/")
token = GreetingToken.objects.get(pk=created["id"])
self.assertIsNotNone(token.last_used_at)
def test_long_labels_are_rejected(self):
response = jpost(
self.client, "/api/auth/greeting-tokens/", {"label": "x" * 101}
)
self.assertEqual(response.status_code, 400)