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.
117 lines
3.6 KiB
Python
117 lines
3.6 KiB
Python
import secrets
|
|
|
|
from django.conf import settings
|
|
from django.contrib.auth.models import AbstractUser
|
|
from django.db import models
|
|
from django.utils import timezone
|
|
|
|
|
|
class User(AbstractUser):
|
|
"""Project user with optional e621 API credentials and an app role."""
|
|
|
|
ROLE_USER = "user"
|
|
ROLE_UPLOADER = "uploader"
|
|
ROLE_STAFF = "staff"
|
|
ROLE_CHOICES = [
|
|
(ROLE_USER, "User"),
|
|
(ROLE_UPLOADER, "Uploader"),
|
|
(ROLE_STAFF, "Staff"),
|
|
]
|
|
|
|
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default=ROLE_USER)
|
|
avatar = models.ForeignKey(
|
|
"library.MediaItem",
|
|
null=True,
|
|
blank=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name="+",
|
|
)
|
|
e621_username = models.CharField(max_length=100, blank=True, default="")
|
|
# Stored encrypted (see apps/accounts/crypto.py): never the plaintext key.
|
|
e621_api_key = models.CharField(max_length=400, blank=True, default="")
|
|
e621_base_url = models.CharField(max_length=200, default="https://e621.net")
|
|
# Per-user browse preferences (landing page, default filters, grid size).
|
|
preferences = models.JSONField(default=dict, blank=True)
|
|
|
|
@property
|
|
def e621_api_key_plain(self):
|
|
"""The decrypted e621 key ("" when it cannot be decrypted)."""
|
|
from .crypto import decrypt_secret
|
|
|
|
return decrypt_secret(self.e621_api_key)
|
|
|
|
@property
|
|
def e621_configured(self):
|
|
return bool(self.e621_username and self.e621_api_key_plain)
|
|
|
|
@property
|
|
def can_upload(self):
|
|
return self.is_superuser or self.role in {self.ROLE_UPLOADER, self.ROLE_STAFF}
|
|
|
|
@property
|
|
def is_app_staff(self):
|
|
"""Staff in this app: superusers, Django staff, or the staff role."""
|
|
return bool(
|
|
self.is_superuser or self.is_staff or self.role == self.ROLE_STAFF
|
|
)
|
|
|
|
|
|
def hash_bearer_token(value):
|
|
"""SHA-256 of a high-entropy bearer token (no salt needed)."""
|
|
import hashlib
|
|
|
|
return hashlib.sha256(value.encode()).hexdigest()
|
|
|
|
|
|
class GreetingToken(models.Model):
|
|
"""Long-lived token that only authenticates the random-image endpoint.
|
|
|
|
Meant for shell greetings and similar scripts, so it is safe to keep in a
|
|
config file: it cannot read the library, upload, or touch an account. Only
|
|
the SHA-256 hash is stored; the plaintext is returned once at creation.
|
|
"""
|
|
|
|
PREFIX = "j621r_"
|
|
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name="greeting_tokens",
|
|
)
|
|
key_hash = models.CharField(max_length=64, unique=True)
|
|
prefix = models.CharField(max_length=16)
|
|
label = models.CharField(max_length=100, blank=True, default="")
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
last_used_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
|
|
def __str__(self):
|
|
return f"{self.prefix}… ({self.user})"
|
|
|
|
@classmethod
|
|
def issue(cls, user, label=""):
|
|
"""Create a token and return ``(token, plaintext_key)``."""
|
|
key = cls.PREFIX + secrets.token_hex(20)
|
|
token = cls.objects.create(
|
|
user=user,
|
|
key_hash=hash_bearer_token(key),
|
|
prefix=key[:12],
|
|
label=label.strip()[:100],
|
|
)
|
|
return token, key
|
|
|
|
@classmethod
|
|
def resolve(cls, key):
|
|
if not key.startswith(cls.PREFIX):
|
|
return None
|
|
return (
|
|
cls.objects.select_related("user")
|
|
.filter(key_hash=hash_bearer_token(key))
|
|
.first()
|
|
)
|
|
|
|
def touch(self):
|
|
GreetingToken.objects.filter(pk=self.pk).update(last_used_at=timezone.now())
|