FossilRepo

fossilrepo / accounts / models.py
Source Blame History 88 lines
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}...)"

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button