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