|
c588255…
|
ragelink
|
1 |
"""User profile and personal access token models. |
|
c588255…
|
ragelink
|
2 |
|
|
c588255…
|
ragelink
|
3 |
UserProfile extends Django's built-in User with optional profile fields. |
|
c588255…
|
ragelink
|
4 |
PersonalAccessToken provides user-scoped tokens for API/CLI authentication, |
|
c588255…
|
ragelink
|
5 |
separate from project-scoped APITokens. |
|
c588255…
|
ragelink
|
6 |
""" |
|
c588255…
|
ragelink
|
7 |
|
|
c588255…
|
ragelink
|
8 |
import hashlib |
|
c588255…
|
ragelink
|
9 |
import re |
|
c588255…
|
ragelink
|
10 |
import secrets |
|
c588255…
|
ragelink
|
11 |
|
|
c588255…
|
ragelink
|
12 |
from django.contrib.auth.models import User |
|
c588255…
|
ragelink
|
13 |
from django.db import models |
|
c588255…
|
ragelink
|
14 |
from django.utils import timezone |
|
c588255…
|
ragelink
|
15 |
|
|
c588255…
|
ragelink
|
16 |
|
|
c588255…
|
ragelink
|
17 |
class UserProfile(models.Model): |
|
c588255…
|
ragelink
|
18 |
"""Extended profile information for users.""" |
|
c588255…
|
ragelink
|
19 |
|
|
c588255…
|
ragelink
|
20 |
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") |
|
c588255…
|
ragelink
|
21 |
handle = models.CharField( |
|
c588255…
|
ragelink
|
22 |
max_length=50, |
|
c588255…
|
ragelink
|
23 |
blank=True, |
|
c588255…
|
ragelink
|
24 |
null=True, |
|
c588255…
|
ragelink
|
25 |
default=None, |
|
c588255…
|
ragelink
|
26 |
unique=True, |
|
c588255…
|
ragelink
|
27 |
help_text="@handle for mentions (alphanumeric and hyphens only)", |
|
c588255…
|
ragelink
|
28 |
) |
|
c588255…
|
ragelink
|
29 |
bio = models.TextField(blank=True, default="", max_length=500) |
|
c588255…
|
ragelink
|
30 |
location = models.CharField(max_length=100, blank=True, default="") |
|
c588255…
|
ragelink
|
31 |
website = models.URLField(blank=True, default="") |
|
c588255…
|
ragelink
|
32 |
|
|
c588255…
|
ragelink
|
33 |
def __str__(self): |
|
c588255…
|
ragelink
|
34 |
return f"@{self.handle or self.user.username}" |
|
c588255…
|
ragelink
|
35 |
|
|
c588255…
|
ragelink
|
36 |
@staticmethod |
|
c588255…
|
ragelink
|
37 |
def sanitize_handle(raw: str) -> str: |
|
c588255…
|
ragelink
|
38 |
"""Slugify a handle: lowercase, alphanumeric + hyphens, strip leading/trailing hyphens.""" |
|
c588255…
|
ragelink
|
39 |
cleaned = re.sub(r"[^a-z0-9-]", "", raw.lower().strip()) |
|
c588255…
|
ragelink
|
40 |
return cleaned.strip("-") |
|
c588255…
|
ragelink
|
41 |
|
|
c588255…
|
ragelink
|
42 |
|
|
c588255…
|
ragelink
|
43 |
class PersonalAccessToken(models.Model): |
|
c588255…
|
ragelink
|
44 |
"""User-scoped personal access token for API/CLI authentication. |
|
c588255…
|
ragelink
|
45 |
|
|
c588255…
|
ragelink
|
46 |
Tokens are stored as SHA-256 hashes -- the raw value is shown once on |
|
c588255…
|
ragelink
|
47 |
creation and never stored in plaintext. |
|
c588255…
|
ragelink
|
48 |
""" |
|
c588255…
|
ragelink
|
49 |
|
|
c588255…
|
ragelink
|
50 |
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="personal_tokens") |
|
c588255…
|
ragelink
|
51 |
name = models.CharField(max_length=200) |
|
c588255…
|
ragelink
|
52 |
token_hash = models.CharField(max_length=64, unique=True) |
|
c588255…
|
ragelink
|
53 |
token_prefix = models.CharField(max_length=12) |
|
c588255…
|
ragelink
|
54 |
scopes = models.CharField(max_length=500, default="read", help_text="Comma-separated: read, write, admin") |
|
c588255…
|
ragelink
|
55 |
expires_at = models.DateTimeField(null=True, blank=True) |
|
c588255…
|
ragelink
|
56 |
last_used_at = models.DateTimeField(null=True, blank=True) |
|
c588255…
|
ragelink
|
57 |
created_at = models.DateTimeField(auto_now_add=True) |
|
c588255…
|
ragelink
|
58 |
revoked_at = models.DateTimeField(null=True, blank=True) |
|
c588255…
|
ragelink
|
59 |
|
|
c588255…
|
ragelink
|
60 |
class Meta: |
|
c588255…
|
ragelink
|
61 |
ordering = ["-created_at"] |
|
c588255…
|
ragelink
|
62 |
|
|
c588255…
|
ragelink
|
63 |
@staticmethod |
|
c588255…
|
ragelink
|
64 |
def generate(): |
|
c588255…
|
ragelink
|
65 |
"""Generate a new token. Returns (raw_token, token_hash, prefix).""" |
|
c588255…
|
ragelink
|
66 |
raw = f"frp_{secrets.token_urlsafe(32)}" |
|
c588255…
|
ragelink
|
67 |
hash_val = hashlib.sha256(raw.encode()).hexdigest() |
|
c588255…
|
ragelink
|
68 |
prefix = raw[:12] |
|
c588255…
|
ragelink
|
69 |
return raw, hash_val, prefix |
|
c588255…
|
ragelink
|
70 |
|
|
c588255…
|
ragelink
|
71 |
@staticmethod |
|
c588255…
|
ragelink
|
72 |
def hash_token(raw_token): |
|
c588255…
|
ragelink
|
73 |
return hashlib.sha256(raw_token.encode()).hexdigest() |
|
c588255…
|
ragelink
|
74 |
|
|
c588255…
|
ragelink
|
75 |
@property |
|
c588255…
|
ragelink
|
76 |
def is_expired(self): |
|
c588255…
|
ragelink
|
77 |
return bool(self.expires_at and self.expires_at < timezone.now()) |
|
c588255…
|
ragelink
|
78 |
|
|
c588255…
|
ragelink
|
79 |
@property |
|
c588255…
|
ragelink
|
80 |
def is_revoked(self): |
|
c588255…
|
ragelink
|
81 |
return self.revoked_at is not None |
|
c588255…
|
ragelink
|
82 |
|
|
c588255…
|
ragelink
|
83 |
@property |
|
c588255…
|
ragelink
|
84 |
def is_active(self): |
|
c588255…
|
ragelink
|
85 |
return not self.is_expired and not self.is_revoked |
|
c588255…
|
ragelink
|
86 |
|
|
c588255…
|
ragelink
|
87 |
def __str__(self): |
|
c588255…
|
ragelink
|
88 |
return f"{self.name} ({self.token_prefix}...)" |