157 lines
4.9 KiB
Python
157 lines
4.9 KiB
Python
from django.db import models
|
|
from django.utils import timezone
|
|
from apps.common.models import BaseModel
|
|
from apps.identity.models import User
|
|
|
|
|
|
class CredentialType(models.TextChoices):
|
|
PASSWORD = "password", "Password"
|
|
PHONE = "phone", "Phone"
|
|
EMAIL = "email", "Email"
|
|
PASSKEY = "passkey", "Passkey"
|
|
OAUTH = "oauth", "OAuth Provider"
|
|
|
|
|
|
class CredentialStatus(models.TextChoices):
|
|
ACTIVE = "active", "Active"
|
|
INACTIVE = "inactive", "Inactive"
|
|
REVOKED = "revoked", "Revoked"
|
|
|
|
|
|
class Credential(BaseModel):
|
|
user = models.ForeignKey(
|
|
User,
|
|
on_delete=models.CASCADE,
|
|
related_name="credentials",
|
|
)
|
|
credential_type = models.CharField(
|
|
max_length=16,
|
|
choices=CredentialType.choices,
|
|
)
|
|
provider = models.CharField(max_length=128, blank=True, default="")
|
|
value_hash = models.CharField(max_length=128, blank=True, default="")
|
|
is_active = models.BooleanField(default=True)
|
|
is_default = models.BooleanField(default=False)
|
|
last_used_at = models.DateTimeField(null=True, blank=True)
|
|
times_used = models.IntegerField(default=0)
|
|
|
|
class Meta:
|
|
ordering = ["-is_default", "-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["user", "credential_type"]),
|
|
models.Index(fields=["user", "is_default"]),
|
|
models.Index(fields=["is_active"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.user} — {self.get_credential_type_display()}"
|
|
|
|
def mark_used(self):
|
|
self.last_used_at = timezone.now()
|
|
self.times_used += 1
|
|
self.save(update_fields=["last_used_at", "times_used", "updated_at"])
|
|
|
|
|
|
class Passkey(BaseModel):
|
|
"""WebAuthn Passkey for passwordless authentication."""
|
|
|
|
class PasskeyType(models.TextChoices):
|
|
PLATFORM = "platform", "Platform (device-bound, e.g. built-in Fingerprint/Face)"
|
|
CREDENTIAL = "credential", "Cross-Platform (roaming, e.g. YubiKey, Feitian)"
|
|
|
|
user = models.ForeignKey(
|
|
User,
|
|
on_delete=models.CASCADE,
|
|
related_name="passkeys",
|
|
)
|
|
name = models.CharField(max_length=255, blank=True, default="")
|
|
credential_id = models.CharField(max_length=255, unique=True)
|
|
public_key = models.TextField()
|
|
sign_count = models.IntegerField(default=0)
|
|
last_used_at = models.DateTimeField(null=True, blank=True)
|
|
is_active = models.BooleanField(default=True)
|
|
touch_enabled = models.BooleanField(default=True)
|
|
user_verification = models.CharField(
|
|
max_length=16,
|
|
choices=[("required", "Required"), ("preferred", "Preferred"), ("discouraged", "Discouraged")],
|
|
default="preferred",
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["-is_active", "-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["user", "is_active"]),
|
|
models.Index(fields=["credential_id"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.user} — {self.name or 'Passkey'}"
|
|
|
|
def mark_used(self):
|
|
self.last_used_at = timezone.now()
|
|
self.sign_count += 1
|
|
self.save(update_fields=["last_used_at", "sign_count", "updated_at"])
|
|
|
|
|
|
class RecoveryCode(BaseModel):
|
|
"""One-time recovery code for MFA bypass."""
|
|
|
|
user = models.ForeignKey(
|
|
User,
|
|
on_delete=models.CASCADE,
|
|
related_name="recovery_codes",
|
|
)
|
|
code_hash = models.CharField(max_length=128)
|
|
used = models.BooleanField(default=False)
|
|
used_at = models.DateTimeField(null=True, blank=True)
|
|
expires_at = models.DateTimeField()
|
|
purpose = models.CharField(
|
|
max_length=32,
|
|
choices=[("mfa_bypass", "MFA Bypass"), ("account_recovery", "Account Recovery")],
|
|
default="mfa_bypass",
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["user", "used"]),
|
|
models.Index(fields=["expires_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"Recovery code for {self.user}"
|
|
|
|
def is_valid(self):
|
|
from django.utils import timezone
|
|
return not self.used and self.expires_at > timezone.now()
|
|
|
|
|
|
class TrustedDevice(BaseModel):
|
|
"""Device trusted for MFA bypass."""
|
|
|
|
user = models.ForeignKey(
|
|
User,
|
|
on_delete=models.CASCADE,
|
|
related_name="trusted_devices",
|
|
)
|
|
fingerprint = models.CharField(max_length=128)
|
|
expires_at = models.DateTimeField()
|
|
purpose = models.CharField(
|
|
max_length=32,
|
|
choices=[("mfa_bypass", "MFA Bypass"), ("session_remember", "Session Remember")],
|
|
default="mfa_bypass",
|
|
)
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ["-is_active", "-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["user", "is_active"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"Trusted device for {self.user}"
|
|
|
|
def is_valid(self):
|
|
from django.utils import timezone
|
|
return self.is_active and self.expires_at > timezone.now() |