|
1
|
"""API tokens scoped to a repository for CI/CD and automation. |
|
2
|
|
|
3
|
Tokens are stored as SHA-256 hashes -- the raw value is shown once on creation |
|
4
|
and never stored in plaintext. |
|
5
|
""" |
|
6
|
|
|
7
|
import hashlib |
|
8
|
import secrets |
|
9
|
|
|
10
|
from django.db import models |
|
11
|
from django.utils import timezone |
|
12
|
|
|
13
|
from core.models import ActiveManager, Tracking |
|
14
|
|
|
15
|
|
|
16
|
class APIToken(Tracking): |
|
17
|
"""API token scoped to a repository for CI/CD and automation.""" |
|
18
|
|
|
19
|
repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="api_tokens") |
|
20
|
name = models.CharField(max_length=200) |
|
21
|
token_hash = models.CharField(max_length=64, unique=True, help_text="SHA-256 hash of the token") |
|
22
|
token_prefix = models.CharField(max_length=12, help_text="First 12 chars for identification") |
|
23
|
permissions = models.CharField(max_length=200, default="status:write", help_text="Comma-separated permissions") |
|
24
|
expires_at = models.DateTimeField(null=True, blank=True) |
|
25
|
last_used_at = models.DateTimeField(null=True, blank=True) |
|
26
|
|
|
27
|
objects = ActiveManager() |
|
28
|
all_objects = models.Manager() |
|
29
|
|
|
30
|
class Meta: |
|
31
|
ordering = ["-created_at"] |
|
32
|
|
|
33
|
@staticmethod |
|
34
|
def generate(): |
|
35
|
"""Generate a new token. Returns (raw_token, token_hash, prefix).""" |
|
36
|
raw = f"frp_{secrets.token_urlsafe(32)}" |
|
37
|
hash_val = hashlib.sha256(raw.encode()).hexdigest() |
|
38
|
prefix = raw[:12] |
|
39
|
return raw, hash_val, prefix |
|
40
|
|
|
41
|
@staticmethod |
|
42
|
def hash_token(raw_token): |
|
43
|
return hashlib.sha256(raw_token.encode()).hexdigest() |
|
44
|
|
|
45
|
def has_permission(self, permission): |
|
46
|
"""Check if this token has a specific permission.""" |
|
47
|
perms = [p.strip() for p in self.permissions.split(",")] |
|
48
|
return permission in perms or "*" in perms |
|
49
|
|
|
50
|
def __str__(self): |
|
51
|
return f"{self.name} ({self.token_prefix}...)" |
|
52
|
|
|
53
|
|
|
54
|
def authenticate_api_token(request, repository): |
|
55
|
"""Check Bearer token auth. Returns APIToken or None.""" |
|
56
|
auth = request.META.get("HTTP_AUTHORIZATION", "") |
|
57
|
if not auth.startswith("Bearer "): |
|
58
|
return None |
|
59
|
raw_token = auth[7:] |
|
60
|
token_hash = APIToken.hash_token(raw_token) |
|
61
|
try: |
|
62
|
token = APIToken.objects.get(token_hash=token_hash, repository=repository, deleted_at__isnull=True) |
|
63
|
# Check expiry |
|
64
|
if token.expires_at and token.expires_at < timezone.now(): |
|
65
|
return None |
|
66
|
token.last_used_at = timezone.now() |
|
67
|
token.save(update_fields=["last_used_at"]) |
|
68
|
return token |
|
69
|
except APIToken.DoesNotExist: |
|
70
|
return None |
|
71
|
|