from __future__ import annotations import hashlib import secrets from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin from django.db import models from django.utils import timezone class UserManager(BaseUserManager): def create_user(self, user_name: str, password: str | None = None, **extra_fields): if not user_name: raise ValueError("user_name is required") extra_fields.setdefault("status", User.STATUS_ACTIVE) extra_fields.setdefault("display_name", user_name) user = self.model(user_name=user_name, **extra_fields) user.set_password(password) user.save(using=self._db) return user def create_superuser(self, user_name: str, password: str | None = None, **extra_fields): extra_fields["status"] = User.STATUS_SUPERADMIN extra_fields.setdefault("is_superuser", True) extra_fields.setdefault("is_active", True) return self.create_user(user_name, password, **extra_fields) class User(AbstractBaseUser, PermissionsMixin): """ERD User Access — username field is user_name.""" STATUS_ACTIVE = "active" STATUS_INACTIVE = "inactive" STATUS_SUPERADMIN = "superadmin" STATUS_CHOICES = [ (STATUS_ACTIVE, "Active"), (STATUS_INACTIVE, "Inactive"), (STATUS_SUPERADMIN, "Super Admin"), ] user_id = models.BigAutoField(primary_key=True) user_name = models.CharField(max_length=30, unique=True) display_name = models.CharField(max_length=100) status = models.CharField(max_length=30, choices=STATUS_CHOICES, default=STATUS_ACTIVE) is_staff = models.BooleanField(default=False) is_active = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) objects = UserManager() USERNAME_FIELD = "user_name" REQUIRED_FIELDS: list[str] = [] class Meta: db_table = "user_access" ordering = ["user_name"] def __str__(self) -> str: return self.user_name def save(self, *args, **kwargs): if self.status == self.STATUS_INACTIVE: self.is_active = False self.is_staff = False self.is_superuser = False elif self.status == self.STATUS_SUPERADMIN: self.is_active = True self.is_staff = False self.is_superuser = True else: self.is_active = True self.is_superuser = False # is_staff is left untouched for active users, so an operator can be # explicitly flagged as staff without being a superadmin. super().save(*args, **kwargs) class ApiKey(models.Model): """Hashed API keys for X-API-Key authentication.""" user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="api_keys") name = models.CharField(max_length=100) prefix = models.CharField(max_length=12, db_index=True) key_hash = models.CharField(max_length=64) is_active = models.BooleanField(default=True) last_used_at = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: db_table = "api_keys" ordering = ["-created_at"] def __str__(self) -> str: return f"{self.name} ({self.prefix}…)" @staticmethod def hash_key(raw_key: str) -> str: return hashlib.sha256(raw_key.encode("utf-8")).hexdigest() @classmethod def generate(cls, user: User, name: str) -> tuple[ApiKey, str]: raw = secrets.token_urlsafe(32) prefix = raw[:8] instance = cls.objects.create( user=user, name=name, prefix=prefix, key_hash=cls.hash_key(raw), ) return instance, raw def verify(self, raw_key: str) -> bool: return secrets.compare_digest(self.key_hash, self.hash_key(raw_key)) def touch(self) -> None: self.last_used_at = timezone.now() self.save(update_fields=["last_used_at", "updated_at"])