from __future__ import annotations import hashlib import secrets from django.conf import settings from django.contrib.auth import get_user_model from django.db import models from django.utils import timezone User = get_user_model() def _sha256(s: str) -> str: return hashlib.sha256(s.encode("utf-8")).hexdigest() class PersonalAccessToken(models.Model): """ Token opaque (PAT) stocké haché pour liens publics ou intégrations. Révocable instantanément + expiration. """ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="personal_access_tokens") name = models.CharField(max_length=120, blank=True) token_hash = models.CharField(max_length=64, unique=True, db_index=True) collections = models.JSONField(default=list) # ex: ["technical-galleries", "assets"] expires_at = models.DateTimeField() revoked = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ("-created_at",) def __str__(self) -> str: return f"PAT({self.user}, {self.name or 'unnamed'})" @property def is_expired(self) -> bool: return timezone.now() >= self.expires_at @property def is_active(self) -> bool: return (not self.revoked) and (not self.is_expired) @classmethod def issue(cls, *, user, collections: list[str], ttl_hours: int = 720, name: str = "share link") -> tuple[str, "PersonalAccessToken"]: raw = "pat_" + secrets.token_urlsafe(32) obj = cls.objects.create( user=user, name=name, token_hash=_sha256(raw), collections=sorted(set(collections)), expires_at=timezone.now() + timezone.timedelta(hours=int(ttl_hours)), ) return raw, obj @classmethod def lookup(cls, raw: str) -> "PersonalAccessToken | None": try: obj = cls.objects.get(token_hash=_sha256(raw)) return obj if obj.is_active else None except cls.DoesNotExist: return None