278 lines
8.2 KiB
Python
278 lines
8.2 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
|
|
from django.conf import settings
|
|
from django.db import models
|
|
from django.utils import timezone
|
|
|
|
|
|
class UserAuthSession(models.Model):
|
|
session_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
editable=False,
|
|
unique=True,
|
|
db_index=True,
|
|
verbose_name="Session ID",
|
|
)
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name="auth_sessions",
|
|
verbose_name="Пользователь",
|
|
)
|
|
refresh_token_hash = models.CharField(
|
|
max_length=128,
|
|
blank=True,
|
|
default="",
|
|
verbose_name="Хеш refresh token",
|
|
)
|
|
ip_address = models.GenericIPAddressField(
|
|
null=True,
|
|
blank=True,
|
|
verbose_name="IP адрес",
|
|
)
|
|
user_agent = models.TextField(blank=True, default="", verbose_name="User-Agent")
|
|
device_name = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
default="",
|
|
verbose_name="Устройство",
|
|
)
|
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создана")
|
|
last_seen_at = models.DateTimeField(
|
|
auto_now_add=True,
|
|
verbose_name="Последняя активность",
|
|
)
|
|
revoked_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
db_index=True,
|
|
verbose_name="Отозвана",
|
|
)
|
|
revoked_reason = models.CharField(
|
|
max_length=64,
|
|
blank=True,
|
|
default="",
|
|
verbose_name="Причина отзыва",
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["-last_seen_at", "-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["user", "revoked_at"]),
|
|
models.Index(fields=["user", "-last_seen_at"]),
|
|
]
|
|
verbose_name = "Сессия аутентификации"
|
|
verbose_name_plural = "Сессии аутентификации"
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.user_id}:{self.session_id}"
|
|
|
|
@property
|
|
def is_revoked(self) -> bool:
|
|
return self.revoked_at is not None
|
|
|
|
def revoke(self, *, reason: str = "manual", commit: bool = True) -> None:
|
|
if self.revoked_at:
|
|
return
|
|
self.revoked_at = timezone.now()
|
|
self.revoked_reason = reason
|
|
if commit:
|
|
self.save(update_fields=["revoked_at", "revoked_reason"])
|
|
from .realtime import schedule_auth_session_revoked
|
|
|
|
schedule_auth_session_revoked(
|
|
user_id=self.user_id,
|
|
session_id=str(self.session_id),
|
|
)
|
|
|
|
|
|
class QrLoginToken(models.Model):
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name="qr_login_tokens",
|
|
verbose_name="Пользователь",
|
|
)
|
|
token_hash = models.CharField(
|
|
max_length=128,
|
|
unique=True,
|
|
db_index=True,
|
|
verbose_name="Хеш QR-токена",
|
|
)
|
|
created_by_session = models.ForeignKey(
|
|
UserAuthSession,
|
|
null=True,
|
|
blank=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name="created_qr_login_tokens",
|
|
verbose_name="Сессия-источник",
|
|
)
|
|
used_session = models.ForeignKey(
|
|
UserAuthSession,
|
|
null=True,
|
|
blank=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name="used_qr_login_tokens",
|
|
verbose_name="Созданная сессия",
|
|
)
|
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создан")
|
|
expires_at = models.DateTimeField(db_index=True, verbose_name="Истекает")
|
|
used_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
db_index=True,
|
|
verbose_name="Использован",
|
|
)
|
|
created_ip_address = models.GenericIPAddressField(
|
|
null=True,
|
|
blank=True,
|
|
verbose_name="IP создания",
|
|
)
|
|
created_user_agent = models.TextField(
|
|
blank=True,
|
|
default="",
|
|
verbose_name="User-Agent создания",
|
|
)
|
|
used_ip_address = models.GenericIPAddressField(
|
|
null=True,
|
|
blank=True,
|
|
verbose_name="IP использования",
|
|
)
|
|
used_user_agent = models.TextField(
|
|
blank=True,
|
|
default="",
|
|
verbose_name="User-Agent использования",
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["user", "used_at", "expires_at"]),
|
|
]
|
|
verbose_name = "QR-токен входа"
|
|
verbose_name_plural = "QR-токены входа"
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.user_id}:{self.created_at:%Y-%m-%d %H:%M:%S}"
|
|
|
|
@property
|
|
def is_used(self) -> bool:
|
|
return self.used_at is not None
|
|
|
|
@property
|
|
def is_expired(self) -> bool:
|
|
return timezone.now() >= self.expires_at
|
|
|
|
|
|
class QrLoginRequest(models.Model):
|
|
scan_token_hash = models.CharField(
|
|
max_length=128,
|
|
unique=True,
|
|
db_index=True,
|
|
verbose_name="Хеш токена сканирования",
|
|
)
|
|
client_secret_hash = models.CharField(
|
|
max_length=128,
|
|
unique=True,
|
|
db_index=True,
|
|
verbose_name="Хеш секрета клиента",
|
|
)
|
|
requester_ip_address = models.GenericIPAddressField(
|
|
null=True,
|
|
blank=True,
|
|
verbose_name="IP запрашивающего устройства",
|
|
)
|
|
requester_user_agent = models.TextField(
|
|
blank=True,
|
|
default="",
|
|
verbose_name="User-Agent запрашивающего устройства",
|
|
)
|
|
requester_device_name = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
default="",
|
|
verbose_name="Запрашивающее устройство",
|
|
)
|
|
approved_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
null=True,
|
|
blank=True,
|
|
on_delete=models.CASCADE,
|
|
related_name="approved_qr_login_requests",
|
|
verbose_name="Подтвердил пользователь",
|
|
)
|
|
approved_by_session = models.ForeignKey(
|
|
UserAuthSession,
|
|
null=True,
|
|
blank=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name="approved_qr_login_requests",
|
|
verbose_name="Подтвердила сессия",
|
|
)
|
|
used_session = models.ForeignKey(
|
|
UserAuthSession,
|
|
null=True,
|
|
blank=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name="qr_login_requests",
|
|
verbose_name="Созданная сессия",
|
|
)
|
|
denied_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
null=True,
|
|
blank=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name="denied_qr_login_requests",
|
|
verbose_name="Отклонил пользователь",
|
|
)
|
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создан")
|
|
expires_at = models.DateTimeField(db_index=True, verbose_name="Истекает")
|
|
approved_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
db_index=True,
|
|
verbose_name="Подтвержден",
|
|
)
|
|
denied_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
db_index=True,
|
|
verbose_name="Отклонен",
|
|
)
|
|
claimed_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
db_index=True,
|
|
verbose_name="Получен клиентом",
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["expires_at", "approved_at", "denied_at"]),
|
|
]
|
|
verbose_name = "QR-запрос входа"
|
|
verbose_name_plural = "QR-запросы входа"
|
|
|
|
def __str__(self) -> str:
|
|
device = self.requester_device_name or "unknown"
|
|
return f"{device}:{self.created_at:%Y-%m-%d %H:%M:%S}"
|
|
|
|
@property
|
|
def is_expired(self) -> bool:
|
|
return timezone.now() >= self.expires_at
|
|
|
|
@property
|
|
def status(self) -> str:
|
|
if self.claimed_at is not None:
|
|
return "claimed"
|
|
if self.denied_at is not None:
|
|
return "denied"
|
|
if self.is_expired:
|
|
return "expired"
|
|
if self.approved_at is not None:
|
|
return "approved"
|
|
return "pending"
|