feat(auth): add session management and settings integration

This commit is contained in:
lizerk 2026-04-11 23:31:14 +03:00
parent 23cc809e06
commit 7dcf84dcd1
26 changed files with 2412 additions and 463 deletions

View File

@ -0,0 +1,23 @@
from __future__ import annotations
from rest_framework_simplejwt.authentication import JWTAuthentication
from .services import validate_access_session
class SessionAwareJWTAuthentication(JWTAuthentication):
def validate_session(self, validated_token, user, *, request=None):
return validate_access_session(
validated_token,
user=user,
request=request,
)
def authenticate(self, request):
result = super().authenticate(request)
if result is None:
return None
user, validated_token = result
self.validate_session(validated_token, user, request=request)
return user, validated_token

View File

@ -0,0 +1,82 @@
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"])

View File

@ -1,11 +1,23 @@
# api/auth/serializers.py
from __future__ import annotations
from api.v1.employees.views._helpers import PHONE_FIELD
from employees.utils import _normalize_phone
from django.contrib.auth import get_user_model
from employees.models import Employee
from employees.utils import _normalize_phone
from rest_framework import serializers
from rest_framework.exceptions import AuthenticationFailed
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework_simplejwt.serializers import (
TokenObtainPairSerializer,
TokenRefreshSerializer,
)
from .models import UserAuthSession
from .services import (
SESSION_ID_CLAIM,
create_auth_session,
hash_refresh_token,
validate_refresh_session,
)
User = get_user_model()
@ -25,6 +37,31 @@ class TokenRefreshResponseSerializer(serializers.Serializer):
access = serializers.CharField(read_only=True)
class SessionSerializer(serializers.ModelSerializer):
session_id = serializers.UUIDField(read_only=True)
is_current = serializers.SerializerMethodField()
class Meta:
model = UserAuthSession
fields = (
"session_id",
"is_current",
"device_name",
"ip_address",
"created_at",
"last_seen_at",
"revoked_at",
)
def get_is_current(self, obj: UserAuthSession) -> bool:
current_session_id = self.context.get("current_session_id")
return str(obj.session_id) == str(current_session_id)
class SessionBulkActionResponseSerializer(serializers.Serializer):
revoked = serializers.IntegerField(read_only=True)
class PhoneOrEmailTokenObtainPairSerializer(TokenObtainPairSerializer):
"""
Принимает email ИЛИ phone/phone_number + password.
@ -69,10 +106,10 @@ class PhoneOrEmailTokenObtainPairSerializer(TokenObtainPairSerializer):
if not attrs.get(self.username_field):
raise AuthenticationFailed(
"Email or phone number required",
code="no_credentials"
code="no_credentials",
)
data = super().validate(attrs)
super().validate(attrs)
# не выдаём токены, если email не подтверждён/аккаунт не активен
if not self.user.email_verified or not self.user.is_active:
@ -81,4 +118,36 @@ class PhoneOrEmailTokenObtainPairSerializer(TokenObtainPairSerializer):
code="email_not_verified",
)
return data
request = self.context.get("request")
if request is None:
raise AuthenticationFailed("request_context_missing")
session = create_auth_session(user=self.user, request=request)
refresh = self.get_token(self.user)
refresh[SESSION_ID_CLAIM] = str(session.session_id)
access = refresh.access_token
access[SESSION_ID_CLAIM] = str(session.session_id)
session.refresh_token_hash = hash_refresh_token(str(refresh))
session.save(update_fields=["refresh_token_hash"])
return {
"refresh": str(refresh),
"access": str(access),
}
class SessionTokenRefreshSerializer(TokenRefreshSerializer):
def validate(self, attrs):
raw_refresh = attrs["refresh"]
refresh = self.token_class(raw_refresh)
session = validate_refresh_session(
refresh,
raw_refresh=raw_refresh,
request=self.context.get("request"),
)
access = refresh.access_token
access[SESSION_ID_CLAIM] = str(session.session_id)
return {"access": str(access)}

View File

@ -0,0 +1,158 @@
from __future__ import annotations
from datetime import timedelta
from typing import Any
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.utils.crypto import salted_hmac
from rest_framework.exceptions import AuthenticationFailed
from rest_framework_simplejwt.exceptions import InvalidToken
from rest_framework_simplejwt.settings import api_settings as jwt_api_settings
from .models import UserAuthSession
User = get_user_model()
SESSION_ID_CLAIM = "session_id"
LAST_SEEN_UPDATE_INTERVAL = timedelta(minutes=5)
def hash_refresh_token(raw_token: str) -> str:
return salted_hmac("api.auth.refresh", raw_token).hexdigest()
def extract_client_ip(request) -> str:
forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "")
if forwarded_for:
return forwarded_for.split(",")[0].strip()
return (request.META.get("REMOTE_ADDR") or "").strip()
def extract_user_agent(request) -> str:
return (request.META.get("HTTP_USER_AGENT") or "").strip()[:1024]
def build_device_name(user_agent: str) -> str:
if not user_agent:
return "Неизвестное устройство"
lowered = user_agent.lower()
browser = "Браузер"
if "yabrowser" in lowered:
browser = "Yandex Browser"
elif "edg/" in lowered:
browser = "Microsoft Edge"
elif "chrome/" in lowered and "chromium" not in lowered:
browser = "Google Chrome"
elif "firefox/" in lowered:
browser = "Mozilla Firefox"
elif "safari/" in lowered and "chrome/" not in lowered:
browser = "Safari"
platform = "Unknown OS"
if "android" in lowered:
platform = "Android"
elif "iphone" in lowered or "ipad" in lowered or "ios" in lowered:
platform = "iOS"
elif "windows" in lowered:
platform = "Windows"
elif "mac os x" in lowered or "macintosh" in lowered:
platform = "macOS"
elif "linux" in lowered:
platform = "Linux"
return f"{browser} on {platform}"
def create_auth_session(*, user: User, request) -> UserAuthSession:
user_agent = extract_user_agent(request)
return UserAuthSession.objects.create(
user=user,
ip_address=extract_client_ip(request) or None,
user_agent=user_agent,
device_name=build_device_name(user_agent),
)
def _load_session_from_token(token: Any, *, user: User | None = None) -> UserAuthSession:
session_id = token.get(SESSION_ID_CLAIM)
if not session_id:
raise AuthenticationFailed("session_missing")
user_id = token.get(jwt_api_settings.USER_ID_CLAIM)
if user is not None:
user_id = user.pk
try:
return UserAuthSession.objects.select_related("user").get(
session_id=session_id,
user_id=user_id,
)
except UserAuthSession.DoesNotExist as exc:
raise AuthenticationFailed("session_not_found") from exc
def touch_session(
session: UserAuthSession,
*,
request=None,
force: bool = False,
) -> UserAuthSession:
now = timezone.now()
update_fields: list[str] = []
if force or not session.last_seen_at or (
now - session.last_seen_at >= LAST_SEEN_UPDATE_INTERVAL
):
session.last_seen_at = now
update_fields.append("last_seen_at")
if request is not None:
ip_address = extract_client_ip(request) or None
user_agent = extract_user_agent(request)
device_name = build_device_name(user_agent)
if session.ip_address != ip_address:
session.ip_address = ip_address
update_fields.append("ip_address")
if user_agent and session.user_agent != user_agent:
session.user_agent = user_agent
update_fields.append("user_agent")
if device_name and session.device_name != device_name:
session.device_name = device_name
update_fields.append("device_name")
if update_fields:
session.save(update_fields=update_fields)
return session
def validate_access_session(
token: Any,
*,
user: User,
request=None,
) -> UserAuthSession:
session = _load_session_from_token(token, user=user)
if session.is_revoked:
raise AuthenticationFailed("session_revoked")
return touch_session(session, request=request)
def validate_refresh_session(
token: Any,
*,
raw_refresh: str,
request=None,
) -> UserAuthSession:
session = _load_session_from_token(token)
if session.is_revoked:
raise InvalidToken("session_revoked")
expected_hash = hash_refresh_token(raw_refresh)
if session.refresh_token_hash != expected_hash:
raise InvalidToken("session_mismatch")
return touch_session(session, request=request, force=True)

31
backend/api/auth/urls.py Normal file
View File

@ -0,0 +1,31 @@
from django.urls import path
from .views import (
JWTTokenRefreshView,
LogoutOtherSessionsAPIView,
PhoneOrEmailTokenObtainPairView,
RegisterAPIView,
ResendEmailAPIView,
SessionDetailAPIView,
SessionListAPIView,
VerifyEmailAPIView,
)
urlpatterns = [
path("token/", PhoneOrEmailTokenObtainPairView.as_view(), name="token_obtain_pair"),
path("token/refresh/", JWTTokenRefreshView.as_view(), name="token_refresh"),
path("register/", RegisterAPIView.as_view(), name="register"),
path("resend-email/", ResendEmailAPIView.as_view(), name="resend-email"),
path("verify-email/", VerifyEmailAPIView.as_view(), name="verify-email"),
path("sessions/", SessionListAPIView.as_view(), name="sessions"),
path(
"sessions/logout-others/",
LogoutOtherSessionsAPIView.as_view(),
name="logout-others",
),
path(
"sessions/<uuid:session_id>/",
SessionDetailAPIView.as_view(),
name="session-detail",
),
]

View File

@ -1,18 +1,55 @@
# api/auth/views.py
from __future__ import annotations
import logging
from datetime import timedelta
from api.v1.employees.serializers import (
EmailSerializer,
EmailVerifySerializer,
RegisterSerializer,
)
from api.v1.employees.views._helpers import Employee
from api.v1.employees.views.mixins import LdapUserCreationMixin
from common.emails import send_templated_mail
from django.core.files.base import ContentFile
from django.db import transaction
from django.utils import timezone
from django.utils.crypto import get_random_string
from drf_spectacular.utils import (
OpenApiExample,
OpenApiParameter,
OpenApiResponse,
extend_schema,
inline_serializer,
)
from rest_framework_simplejwt.views import TokenObtainPairView
from rest_framework_simplejwt.views import TokenRefreshView
from employees.models import Position, Skill
from employees.utils import _normalize_phone
from rest_framework import serializers, status
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from .models import UserAuthSession
from .serializers import (
PhoneOrEmailTokenObtainPairSerializer,
SessionBulkActionResponseSerializer,
SessionSerializer,
SessionTokenRefreshSerializer,
TokenPairResponseSerializer,
TokenRefreshRequestSerializer,
TokenRefreshResponseSerializer,
)
from .services import SESSION_ID_CLAIM
logger = logging.getLogger(__name__)
class AnonymousAPIView(APIView):
authentication_classes = []
throttle_scope = "anon"
permission_classes = [AllowAny]
@extend_schema(
@ -59,9 +96,350 @@ class PhoneOrEmailTokenObtainPairView(TokenObtainPairView):
responses={
200: TokenRefreshResponseSerializer,
401: OpenApiResponse(
description="Refresh token недействителен или истек."
description="Refresh token недействителен, истек или сессия отозвана."
),
},
)
class JWTTokenRefreshView(TokenRefreshView):
pass
serializer_class = SessionTokenRefreshSerializer
class ResendEmailAPIView(AnonymousAPIView):
@extend_schema(
tags=["Auth"],
summary="Повторно отправить код подтверждения email",
request=EmailSerializer,
responses={
200: inline_serializer(
"ResendEmailResponse",
{"ok": serializers.BooleanField()},
),
400: OpenApiResponse(description="Email уже подтвержден."),
404: OpenApiResponse(description="Пользователь не найден."),
},
)
def post(self, request):
ser = EmailSerializer(data=request.data)
ser.is_valid(raise_exception=True)
email = ser.validated_data["email"]
user = Employee.objects.filter(email__iexact=email).first()
if not user:
return Response({"ok": False, "error": "user_not_found"}, status=404)
if user.email_verified:
return Response({"ok": False, "error": "already_verified"}, status=400)
user.email_activation_code = get_random_string(6, "0123456789")
user.save(update_fields=["email_activation_code"])
send_templated_mail(
subject="Подтверждение регистрации",
to=[user.email],
template_base="emails/registration_verify_code",
context={"code": user.email_activation_code, "user": user},
)
return Response({"ok": True}, status=200)
class VerifyEmailAPIView(AnonymousAPIView):
@extend_schema(
tags=["Auth"],
summary="Подтвердить email кодом",
request=EmailVerifySerializer,
responses={
200: inline_serializer(
"VerifyEmailResponse",
{
"ok": serializers.BooleanField(),
"user_id": serializers.IntegerField(),
},
),
400: OpenApiResponse(description="Код пустой, неверный или истек."),
404: OpenApiResponse(description="Пользователь не найден."),
},
)
def post(self, request):
ser = EmailVerifySerializer(data=request.data)
ser.is_valid(raise_exception=True)
email = ser.validated_data["email"].strip().lower()
code = ser.validated_data["code"].strip()
user = Employee.objects.filter(email__iexact=email).first()
if not user:
return Response({"ok": False, "error": "user_not_found"}, status=404)
if not code:
return Response({"ok": False, "error": "empty_code"}, status=400)
created = getattr(user, "created_at", None) or getattr(
user, "date_joined", None
)
if created and not user.email_verified:
if timezone.now() - created > timedelta(minutes=5):
user.delete()
return Response({"ok": False, "error": "expired"}, status=400)
if not user.verify_email(code):
return Response({"ok": False, "error": "invalid_code"}, status=400)
user.is_active = True
user._ldap_changes = {"is_active": True}
user.save()
return Response({"ok": True, "user_id": user.id}, status=200)
class RegisterAPIView(LdapUserCreationMixin, AnonymousAPIView):
parser_classes = (JSONParser, FormParser, MultiPartParser)
def is_ldap_enabled(self) -> bool:
# Сохраняем старую точку patch'а тестов:
# api.v1.employees.views._is_ldap_enabled
from api.v1.employees import views as employee_views
return employee_views._is_ldap_enabled()
@extend_schema(
tags=["Auth"],
summary="Зарегистрировать нового пользователя",
request=RegisterSerializer,
responses={
201: inline_serializer(
"RegisterResponse",
{
"id": serializers.IntegerField(),
"email": serializers.EmailField(),
"email_verified": serializers.BooleanField(),
"is_active": serializers.BooleanField(),
},
),
200: inline_serializer(
"RegisterPendingVerificationResponse",
{
"ok": serializers.BooleanField(),
"pending_verification": serializers.BooleanField(),
"user_id": serializers.IntegerField(),
},
),
400: OpenApiResponse(
description="Ошибка валидации или занятый email/телефон."
),
},
)
@transaction.atomic
def post(self, request):
logger.warning("[REGISTER] Received data: %s", request.data)
logger.warning("[REGISTER] Content-Type: %s", request.content_type)
ser = RegisterSerializer(data=request.data)
if not ser.is_valid():
logger.warning("[REGISTER] Validation errors: %s", ser.errors)
ser.is_valid(raise_exception=True)
v = ser.validated_data
email = v["email"].strip().lower()
password = v["password"]
phone_norm = _normalize_phone(
v.get("phone_number") or request.data.get("phone")
)
logger.warning(
"[REGISTER] Phone normalization: input=%s, normalized=%s",
v.get("phone_number"),
phone_norm,
)
if not phone_norm:
logger.error(
"[REGISTER] Phone normalization FAILED for: %s",
v.get("phone_number"),
)
return Response({"ok": False, "error": "invalid_phone"}, status=400)
existing_phone = Employee.objects.filter(phone_number=phone_norm).first()
if existing_phone:
logger.warning(
"[REGISTER] Phone %s already used by user id=%s",
phone_norm,
existing_phone.id,
)
return Response(
{
"ok": False,
"error": "phone_taken",
"detail": (
"Номер телефона уже зарегистрирован. "
"Войдите в существующий аккаунт "
"или используйте другой номер."
),
"phone_number": [
"Этот номер телефона уже привязан к другому аккаунту."
],
},
status=400,
)
user = Employee.objects.filter(email__iexact=email).first()
if user:
logger.warning(
"[REGISTER] User exists: email=%s, verified=%s",
email,
user.email_verified,
)
if user.email_verified:
logger.error("[REGISTER] Email already taken and verified: %s", email)
return Response({"ok": False, "error": "email_taken"}, status=400)
return Response(
{
"ok": True,
"pending_verification": True,
"user_id": user.id,
},
status=200,
)
avatar_file = v.get("avatar") or getattr(request, "FILES", {}).get("avatar")
avatar_bytes = None
avatar_name = None
if avatar_file:
try:
avatar_bytes = (
avatar_file.read() if hasattr(avatar_file, "read") else None
)
avatar_name = getattr(avatar_file, "name", None) or "avatar.jpg"
except Exception:
avatar_bytes = None
avatar_name = None
emp, error_response = self.create_user(
first_name=v["first_name"],
last_name=v["last_name"],
email=email,
phone=phone_norm,
password=password,
avatar_bytes=avatar_bytes,
is_active=False,
)
if error_response:
return error_response
if avatar_bytes:
try:
emp.avatar.save(avatar_name, ContentFile(avatar_bytes), save=False)
except Exception:
pass
emp.telegram = v.get("telegram", "")
emp.whatsapp = v.get("whatsapp", "")
emp.wechat = v.get("wechat", "")
emp.birth_date = v["birth_date"]
emp.gender = v.get("gender")
if v.get("patronymic"):
emp.patronymic = v["patronymic"]
pos_id = v.get("position")
if pos_id and Position.objects.filter(pk=pos_id).exists():
emp.position_id = pos_id
emp.save()
skills_ids = v.get("skills") or []
if skills_ids:
emp.skills.set(Skill.objects.filter(pk__in=skills_ids))
emp.email_activation_code = get_random_string(6, "0123456789")
emp.save(update_fields=["email_activation_code"])
send_templated_mail(
subject="Подтверждение регистрации",
to=[emp.email],
template_base="emails/registration_verify_code",
context={"code": emp.email_activation_code, "user": emp},
)
return Response(
{
"id": emp.id,
"email": emp.email,
"email_verified": emp.email_verified,
"is_active": emp.is_active,
},
status=status.HTTP_201_CREATED,
)
class SessionBaseAPIView(APIView):
permission_classes = [IsAuthenticated]
def get_current_session_id(self, request) -> str | None:
token = getattr(request, "auth", None)
if token is None:
return None
return token.get(SESSION_ID_CLAIM)
class SessionListAPIView(SessionBaseAPIView):
@extend_schema(
tags=["Auth"],
summary="Список активных сессий пользователя",
responses={200: SessionSerializer(many=True)},
)
def get(self, request):
current_session_id = self.get_current_session_id(request)
queryset = UserAuthSession.objects.filter(
user=request.user,
revoked_at__isnull=True,
).order_by("-last_seen_at", "-created_at")
serializer = SessionSerializer(
queryset,
many=True,
context={"current_session_id": current_session_id},
)
return Response(serializer.data)
class SessionDetailAPIView(SessionBaseAPIView):
@extend_schema(
tags=["Auth"],
summary="Завершить одну сессию",
parameters=[
OpenApiParameter(
name="session_id",
type=str,
location=OpenApiParameter.PATH,
)
],
responses={204: OpenApiResponse(description="Сессия завершена.")},
)
def delete(self, request, session_id):
session = UserAuthSession.objects.filter(
user=request.user,
session_id=session_id,
revoked_at__isnull=True,
).first()
if not session:
return Response(status=status.HTTP_404_NOT_FOUND)
reason = "self_logout" if str(session.session_id) == str(
self.get_current_session_id(request)
) else "manual_logout"
session.revoke(reason=reason)
return Response(status=status.HTTP_204_NO_CONTENT)
class LogoutOtherSessionsAPIView(SessionBaseAPIView):
@extend_schema(
tags=["Auth"],
summary="Завершить все остальные сессии",
responses={200: SessionBulkActionResponseSerializer},
)
def post(self, request):
current_session_id = self.get_current_session_id(request)
sessions = UserAuthSession.objects.filter(
user=request.user,
revoked_at__isnull=True,
).exclude(session_id=current_session_id)
revoked = 0
for session in sessions:
session.revoke(reason="logout_others")
revoked += 1
return Response({"revoked": revoked}, status=status.HTTP_200_OK)

View File

@ -0,0 +1,128 @@
# Generated manually for auth session registry.
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="UserAuthSession",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"session_id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
unique=True,
verbose_name="Session ID",
),
),
(
"refresh_token_hash",
models.CharField(
blank=True,
default="",
max_length=128,
verbose_name="Хеш refresh token",
),
),
(
"ip_address",
models.GenericIPAddressField(
blank=True,
null=True,
verbose_name="IP адрес",
),
),
(
"user_agent",
models.TextField(blank=True, default="", verbose_name="User-Agent"),
),
(
"device_name",
models.CharField(
blank=True,
default="",
max_length=255,
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(
blank=True,
db_index=True,
null=True,
verbose_name="Отозвана",
),
),
(
"revoked_reason",
models.CharField(
blank=True,
default="",
max_length=64,
verbose_name="Причина отзыва",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="auth_sessions",
to=settings.AUTH_USER_MODEL,
verbose_name="Пользователь",
),
),
],
options={
"verbose_name": "Сессия аутентификации",
"verbose_name_plural": "Сессии аутентификации",
"ordering": ["-last_seen_at", "-created_at"],
},
),
migrations.AddIndex(
model_name="userauthsession",
index=models.Index(
fields=["user", "revoked_at"],
name="api_useraut_user_id_1e9721_idx",
),
),
migrations.AddIndex(
model_name="userauthsession",
index=models.Index(
fields=["user", "-last_seen_at"],
name="api_useraut_user_id_82cdaa_idx",
),
),
]

3
backend/api/models.py Normal file
View File

@ -0,0 +1,3 @@
from api.auth.models import UserAuthSession
__all__ = ["UserAuthSession"]

View File

@ -8,7 +8,6 @@ from drf_spectacular.views import (
)
from rest_framework.permissions import AllowAny
from .auth.views import JWTTokenRefreshView, PhoneOrEmailTokenObtainPairView
from .docs_views import AsyncAPIDocsView, AsyncAPISchemaView
app_name = "api"
@ -42,14 +41,5 @@ urlpatterns = [
path("ws/schema/", AsyncAPISchemaView.as_view(), name="ws-schema"),
path("ws/docs/", AsyncAPIDocsView.as_view(), name="ws-docs"),
path("v1/", include(("api.v1.urls", "v1"), namespace="v1")),
path(
"auth/token/",
PhoneOrEmailTokenObtainPairView.as_view(),
name="token_obtain_pair",
),
path(
"auth/token/refresh/",
JWTTokenRefreshView.as_view(),
name="token_refresh",
),
path("auth/", include("api.auth.urls")),
]

View File

@ -1,343 +1,15 @@
"""Auth-related views.
"""Обратно-совместимый реэкспорт auth views из canonical api.auth."""
Регистрация, подтверждение email и повторная отправка кода.
"""
from __future__ import annotations
import logging
from datetime import timedelta
from common.emails import send_templated_mail
from drf_spectacular.utils import (
OpenApiResponse,
extend_schema,
inline_serializer,
from api.auth.views import (
AnonymousAPIView,
RegisterAPIView,
ResendEmailAPIView,
VerifyEmailAPIView,
)
from django.core.files.base import ContentFile
from django.db import transaction
from django.utils import timezone
from django.utils.crypto import get_random_string
from employees.models import Position, Skill
from employees.utils import _normalize_phone
from rest_framework import serializers, status
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from ..serializers import (
EmailSerializer,
EmailVerifySerializer,
RegisterSerializer,
)
from ._helpers import Employee
from .mixins import LdapUserCreationMixin
logger = logging.getLogger(__name__)
class AnonymousAPIView(APIView):
"""Базовый класс для анонимных (публичных) API endpoints.
Все auth-related views наследуются от этого класса для DRY.
Используется только JWT (без SessionAuthentication) → CSRF не требуется.
"""
# Отключаем SessionAuthentication для публичных endpoints.
authentication_classes = []
throttle_scope = "anon"
permission_classes = [AllowAny]
class ResendEmailAPIView(AnonymousAPIView):
"""POST /api/v1/auth/resend-email/ body: {"email": "..."}"""
@extend_schema(
tags=["Auth"],
summary="Повторно отправить код подтверждения email",
request=EmailSerializer,
responses={
200: inline_serializer(
"ResendEmailResponse",
{"ok": serializers.BooleanField()},
),
400: OpenApiResponse(description="Email уже подтвержден."),
404: OpenApiResponse(description="Пользователь не найден."),
},
)
def post(self, request):
ser = EmailSerializer(data=request.data)
ser.is_valid(raise_exception=True)
email = ser.validated_data["email"]
user = Employee.objects.filter(email__iexact=email).first()
if not user:
return Response(
{"ok": False, "error": "user_not_found"}, status=404
)
if user.email_verified:
return Response(
{"ok": False, "error": "already_verified"}, status=400
)
user.email_activation_code = get_random_string(6, "0123456789")
user.save(update_fields=["email_activation_code"])
send_templated_mail(
subject="Подтверждение регистрации",
to=[user.email],
template_base="emails/registration_verify_code",
context={"code": user.email_activation_code, "user": user},
)
return Response({"ok": True}, status=200)
class VerifyEmailAPIView(AnonymousAPIView):
@extend_schema(
tags=["Auth"],
summary="Подтвердить email кодом",
request=EmailVerifySerializer,
responses={
200: inline_serializer(
"VerifyEmailResponse",
{
"ok": serializers.BooleanField(),
"user_id": serializers.IntegerField(),
},
),
400: OpenApiResponse(description="Код пустой, неверный или истек."),
404: OpenApiResponse(description="Пользователь не найден."),
},
)
def post(self, request):
"""Подтверждает email и активирует пользователя.
Активация синхронизируется в LDAP через сигналы.
"""
ser = EmailVerifySerializer(data=request.data)
ser.is_valid(raise_exception=True)
email = ser.validated_data["email"].strip().lower()
code = ser.validated_data["code"].strip()
user = Employee.objects.filter(email__iexact=email).first()
if not user:
return Response(
{"ok": False, "error": "user_not_found"}, status=404
)
if not code:
return Response({"ok": False, "error": "empty_code"}, status=400)
created = getattr(user, "created_at", None) or getattr(
user, "date_joined", None
)
if created and not user.email_verified:
if timezone.now() - created > timedelta(minutes=5):
user.delete()
return Response({"ok": False, "error": "expired"}, status=400)
if not user.verify_email(code):
return Response({"ok": False, "error": "invalid_code"}, status=400)
# Активируем пользователя (сигнал sync_employee_to_ldap_on_save
# синхронизирует в LDAP)
user.is_active = True
user._ldap_changes = {"is_active": True}
user.save()
return Response({"ok": True, "user_id": user.id}, status=200)
class RegisterAPIView(LdapUserCreationMixin, AnonymousAPIView):
"""Регистрация: создаём учётку в LDAP (disabled) с паролем.
В БД используется set_unusable_password.
Использует LdapUserCreationMixin для вынесения LDAP-специфичной логики.
"""
parser_classes = (JSONParser, FormParser, MultiPartParser)
@extend_schema(
tags=["Auth"],
summary="Зарегистрировать нового пользователя",
request=RegisterSerializer,
responses={
201: inline_serializer(
"RegisterResponse",
{
"id": serializers.IntegerField(),
"email": serializers.EmailField(),
"email_verified": serializers.BooleanField(),
"is_active": serializers.BooleanField(),
},
),
200: inline_serializer(
"RegisterPendingVerificationResponse",
{
"ok": serializers.BooleanField(),
"pending_verification": serializers.BooleanField(),
"user_id": serializers.IntegerField(),
},
),
400: OpenApiResponse(
description="Ошибка валидации или занятый email/телефон."
),
},
)
@transaction.atomic
def post(self, request):
# Логируем входящие данные для диагностики
logger.warning(f"[REGISTER] Received data: {request.data}")
logger.warning(f"[REGISTER] Content-Type: {request.content_type}")
ser = RegisterSerializer(data=request.data)
if not ser.is_valid():
logger.warning(f"[REGISTER] Validation errors: {ser.errors}")
ser.is_valid(raise_exception=True)
v = ser.validated_data
email = v["email"].strip().lower()
password = v["password"]
phone_norm = _normalize_phone(
v.get("phone_number") or request.data.get("phone")
)
logger.warning(
f"[REGISTER] Phone normalization: input={
v.get('phone_number')
}, normalized={phone_norm}"
)
if not phone_norm:
logger.error(
f"[REGISTER] Phone normalization FAILED for: {
v.get('phone_number')
}"
)
return Response({"ok": False, "error": "invalid_phone"}, status=400)
existing_phone = Employee.objects.filter(
phone_number=phone_norm
).first()
if existing_phone:
logger.warning(
"[REGISTER] Phone %s already used by user id=%s",
phone_norm,
existing_phone.id,
)
return Response(
{
"ok": False,
"error": "phone_taken",
"detail": (
"Номер телефона уже зарегистрирован. "
"Войдите в существующий аккаунт "
"или используйте другой номер."
),
"phone_number": [
"Этот номер телефона уже привязан к другому аккаунту."
],
},
status=400,
)
user = Employee.objects.filter(email__iexact=email).first()
if user:
logger.warning(
f"[REGISTER] User exists: email={email}, verified={
user.email_verified
}"
)
if user.email_verified:
# Email уже верифицирован - нельзя регистрироваться
logger.error(
f"[REGISTER] Email already taken and verified: {email}"
)
return Response(
{"ok": False, "error": "email_taken"}, status=400
)
else:
# Есть неверифицированный пользователь - повторная отправка кода
return Response(
{
"ok": True,
"pending_verification": True,
"user_id": user.id,
},
status=200,
)
avatar_file = v.get("avatar") or getattr(request, "FILES", {}).get(
"avatar"
)
avatar_bytes = None
avatar_name = None
if avatar_file:
try:
avatar_bytes = (
avatar_file.read() if hasattr(avatar_file, "read") else None
)
avatar_name = getattr(avatar_file, "name", None) or "avatar.jpg"
except Exception:
avatar_bytes = None
avatar_name = None
# Создаём пользователя (миксин сам выберет LDAP или БД режим)
emp, error_response = self.create_user(
first_name=v["first_name"],
last_name=v["last_name"],
email=email,
phone=phone_norm,
password=password,
avatar_bytes=avatar_bytes,
is_active=False, # не активен до верификации email
)
if error_response:
return error_response
# 2) Заполняем доп.поля БД
if avatar_bytes:
try:
emp.avatar.save(
avatar_name, ContentFile(avatar_bytes), save=False
)
except Exception:
pass
emp.telegram = v.get("telegram", "")
emp.whatsapp = v.get("whatsapp", "")
emp.wechat = v.get("wechat", "")
emp.birth_date = v["birth_date"]
emp.gender = v.get("gender") # опциональное поле
if v.get("patronymic"):
emp.patronymic = v["patronymic"]
pos_id = v.get("position")
if pos_id and Position.objects.filter(pk=pos_id).exists():
emp.position_id = pos_id
emp.save()
skills_ids = v.get("skills") or []
if skills_ids:
emp.skills.set(Skill.objects.filter(pk__in=skills_ids))
# 3) Отправляем код верификации
emp.email_activation_code = get_random_string(6, "0123456789")
emp.save(update_fields=["email_activation_code"])
send_templated_mail(
subject="Подтверждение регистрации",
to=[emp.email],
template_base="emails/registration_verify_code",
context={"code": emp.email_activation_code, "user": emp},
)
return Response(
{
"id": emp.id,
"email": emp.email,
"email_verified": emp.email_verified,
"is_active": emp.is_active,
},
status=status.HTTP_201_CREATED,
)
__all__ = [
"AnonymousAPIView",
"RegisterAPIView",
"ResendEmailAPIView",
"VerifyEmailAPIView",
]

View File

@ -34,6 +34,9 @@ class LdapUserCreationMixin:
create_db_user(): Создаёт пользователя напрямую в БД (fallback без LDAP)
"""
def is_ldap_enabled(self) -> bool:
return _is_ldap_enabled()
def create_user(
self,
*,
@ -63,7 +66,7 @@ class LdapUserCreationMixin:
- (Employee, None) при успехе
- (None, Response) при ошибке (Response для возврата клиенту)
"""
if _is_ldap_enabled():
if self.is_ldap_enabled():
# Режим с LDAP: создаём disabled учётку в LDAP + пароль
return self.create_ldap_user(
first_name=first_name,

View File

@ -24,10 +24,7 @@ from .employees.views import (
EmployeeViewSet,
GroupViewSet,
PositionViewSet,
RegisterAPIView,
ResendEmailAPIView,
SkillViewSet,
VerifyEmailAPIView,
)
from .feed.views import PostViewSet
from .requests_app.views import RequestViewSet
@ -90,17 +87,7 @@ router.register(r"communications/polls", PollViewSet, basename="polls")
urlpatterns = [
path("auth/register/", RegisterAPIView.as_view(), name="register"),
path(
"auth/resend-email/",
ResendEmailAPIView.as_view(),
name="resend-email",
),
path(
"auth/verify-email/",
VerifyEmailAPIView.as_view(),
name="verify-email",
),
path("auth/", include("api.auth.urls")),
# Notifications API (из самого модуля notifications)
path("notifications/", include("notifications.api.urls")),
# Procurement API

View File

@ -6,6 +6,7 @@ from urllib.parse import parse_qs
from channels.db import database_sync_to_async
from channels.middleware import BaseMiddleware
from django.contrib.auth.models import AnonymousUser
class JWTAuthMiddleware(BaseMiddleware):
@ -28,22 +29,23 @@ class JWTAuthMiddleware(BaseMiddleware):
return None
@database_sync_to_async
def _get_user(self, validated_token):
from rest_framework_simplejwt.authentication import JWTAuthentication
def _authenticate_raw_token(self, raw_token: str):
from api.auth.authentication import SessionAwareJWTAuthentication
auth = JWTAuthentication()
auth = SessionAwareJWTAuthentication()
validated_token = auth.get_validated_token(raw_token)
user = auth.get_user(validated_token)
return user
auth.validate_session(validated_token, user)
return user, validated_token
async def __call__(self, scope, receive, send):
scope.setdefault("user", AnonymousUser())
try:
raw = self._get_raw_token(scope)
if raw:
from rest_framework_simplejwt.tokens import UntypedToken
token = UntypedToken(raw) # валидация подписи/срока
user = await self._get_user(token)
user, token = await self._authenticate_raw_token(raw)
scope["user"] = user
scope["auth"] = token
except Exception:
# оставим scope["user"] анонимным — соединение сам consumer решит
pass

View File

@ -417,7 +417,7 @@ CHANNEL_LAYERS = {
# -----------------------------------------------------------------------------
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
"api.auth.authentication.SessionAwareJWTAuthentication",
"rest_framework.authentication.SessionAuthentication",
],
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",

View File

@ -18,6 +18,21 @@ CHANNEL_LAYERS = {
}
}
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "eusrr-test-cache",
}
}
REST_FRAMEWORK = {
**REST_FRAMEWORK,
"DEFAULT_THROTTLE_RATES": {
**REST_FRAMEWORK.get("DEFAULT_THROTTLE_RATES", {}),
"anon": "10000/min",
},
}
# Celery - синхронное выполнение в тестах (без отдельного worker)
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True

View File

@ -6,8 +6,10 @@ from django.core import mail
from django.urls import reverse
from django.utils import timezone
from django.contrib.auth import get_user_model
from api.auth.models import UserAuthSession
from rest_framework import status
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import AccessToken, RefreshToken
pytestmark = pytest.mark.django_db
User = get_user_model()
@ -25,6 +27,8 @@ def api():
def _locmem_email_backend(settings):
# Все письма складываются в mail.outbox
settings.EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
settings.LDAP_ENABLED = False
settings.LDAP_WRITE_ENABLED = False
# ======== утилиты ========
@ -100,6 +104,10 @@ def jwt_obtain(
return api.post(url, payload, format="json")
def auth_headers(access: str) -> dict[str, str]:
return {"HTTP_AUTHORIZATION": f"Bearer {access}"}
# ======== регистрация: happy-path ========
@ -288,3 +296,110 @@ def test_login_wrong_password(api):
r = jwt_obtain(api, email="ivan@example.com", password="BAD")
assert r.status_code in (status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED)
@pytest.mark.parametrize(
("route_name", "path"),
[
("api:register", "/api/auth/register/"),
("api:v1:register", "/api/v1/auth/register/"),
("api:resend-email", "/api/auth/resend-email/"),
("api:v1:resend-email", "/api/v1/auth/resend-email/"),
("api:verify-email", "/api/auth/verify-email/"),
("api:v1:verify-email", "/api/v1/auth/verify-email/"),
("api:token_obtain_pair", "/api/auth/token/"),
("api:v1:token_obtain_pair", "/api/v1/auth/token/"),
("api:token_refresh", "/api/auth/token/refresh/"),
("api:v1:token_refresh", "/api/v1/auth/token/refresh/"),
("api:sessions", "/api/auth/sessions/"),
("api:v1:sessions", "/api/v1/auth/sessions/"),
("api:logout-others", "/api/auth/sessions/logout-others/"),
("api:v1:logout-others", "/api/v1/auth/sessions/logout-others/"),
],
)
def test_auth_routes_are_mirrored(route_name, path):
assert reverse(route_name) == path
def test_login_creates_auth_session_and_embeds_session_id(api):
assert register(api).status_code == status.HTTP_201_CREATED
code = extract_code_from_last_email()
assert verify(api, email="ivan@example.com", code=code).status_code in (200, 204)
response = jwt_obtain(api, email="ivan@example.com")
assert response.status_code == status.HTTP_200_OK
payload = response.json()
access = AccessToken(payload["access"])
refresh = RefreshToken(payload["refresh"])
assert access["session_id"] == refresh["session_id"]
session = UserAuthSession.objects.get(session_id=access["session_id"])
assert session.user.email == "ivan@example.com"
assert session.refresh_token_hash
def test_sessions_list_and_logout_others_revoke_other_session(api):
assert register(api).status_code == status.HTTP_201_CREATED
code = extract_code_from_last_email()
assert verify(api, email="ivan@example.com", code=code).status_code in (200, 204)
first = jwt_obtain(api, email="ivan@example.com")
second = jwt_obtain(api, email="ivan@example.com")
assert first.status_code == second.status_code == status.HTTP_200_OK
access1 = first.json()["access"]
access2 = second.json()["access"]
refresh2 = second.json()["refresh"]
session_id_2 = str(AccessToken(access2)["session_id"])
listed = api.get(reverse("api:sessions"), **auth_headers(access1))
assert listed.status_code == status.HTTP_200_OK
assert len(listed.json()) == 2
logout_others = api.post(reverse("api:logout-others"), **auth_headers(access1))
assert logout_others.status_code == status.HTTP_200_OK
assert logout_others.json()["revoked"] == 1
listed_after = api.get(reverse("api:v1:sessions"), **auth_headers(access1))
assert listed_after.status_code == status.HTTP_200_OK
assert len(listed_after.json()) == 1
assert listed_after.json()[0]["is_current"] is True
profile = api.get(reverse("api:v1:employees-me"), **auth_headers(access2))
assert profile.status_code == status.HTTP_401_UNAUTHORIZED
refresh_resp = api.post(
reverse("api:v1:token_refresh"),
{"refresh": refresh2},
format="json",
)
assert refresh_resp.status_code == status.HTTP_401_UNAUTHORIZED
session = UserAuthSession.objects.get(session_id=session_id_2)
assert session.revoked_at is not None
def test_delete_current_session_revokes_access_immediately(api):
assert register(api).status_code == status.HTTP_201_CREATED
code = extract_code_from_last_email()
assert verify(api, email="ivan@example.com", code=code).status_code in (200, 204)
login = jwt_obtain(api, email="ivan@example.com")
assert login.status_code == status.HTTP_200_OK
access = login.json()["access"]
session_id = AccessToken(access)["session_id"]
before = api.get(reverse("api:v1:employees-me"), **auth_headers(access))
assert before.status_code == status.HTTP_200_OK
deleted = api.delete(
reverse("api:session-detail", kwargs={"session_id": session_id}),
**auth_headers(access),
)
assert deleted.status_code == status.HTTP_204_NO_CONTENT
after = api.get(reverse("api:v1:employees-me"), **auth_headers(access))
assert after.status_code == status.HTTP_401_UNAUTHORIZED

View File

@ -23,7 +23,11 @@ def test_openapi_schema_is_available(api_client: APIClient):
payload = response.json()
assert payload["openapi"].startswith("3.")
assert "/api/auth/token/" in payload["paths"]
assert "/api/auth/register/" in payload["paths"]
assert "/api/auth/sessions/" in payload["paths"]
assert "/api/v1/auth/token/" in payload["paths"]
assert "/api/v1/auth/register/" in payload["paths"]
assert "/api/v1/auth/sessions/" in payload["paths"]
assert "/api/v1/documents/" in payload["paths"]
assert "/api/v1/notifications/" in payload["paths"]
assert "/api/v1/procurement/stats/overview/" in payload["paths"]

View File

@ -283,7 +283,7 @@ def test_dismissal_handles_ldap_error_gracefully(
):
"""Проверяет graceful handling ошибки LDAP при увольнении."""
from employees.ldap.errors import DirectoryLdapError
from employees.models import LdapSyncState
from employees.models import LdapSyncQueue, LdapSyncState
mock_is_enabled.return_value = True
mock_update.side_effect = DirectoryLdapError("LDAP connection failed")
@ -317,8 +317,20 @@ def test_dismissal_handles_ldap_error_gracefully(
# БД-изменения должны быть применены
assert emp.is_active is False
# LDAP был вызван (и упал)
mock_update.assert_called_once()
# LDAP sync был предпринят хотя бы один раз.
# При eager Celery retry может вызвать update_user повторно в рамках
# того же теста, поэтому количество вызовов здесь недетерминировано.
assert mock_update.call_count >= 1
first_call = mock_update.call_args_list[0]
assert first_call.kwargs['emp'].id == emp.id
assert first_call.kwargs['changes'] == {'is_active': False}
queue_item = LdapSyncQueue.objects.filter(
operation="employee_save",
model_name="employee",
object_pk=str(emp.pk),
).first()
assert queue_item is not None
@pytest.mark.django_db
@ -591,4 +603,3 @@ def test_dismissal_without_department_moves_to_dismissed(
assert call_args.kwargs['changes'] == {'is_active': False}
mock_ensure_dn.assert_called_once()
assert mock_ensure_dn.call_args.args[0].id == emp.id

View File

@ -4,6 +4,7 @@
Покрывает тест-кейсы R1-R9 из плана тестирования.
"""
import itertools
from unittest.mock import patch
import pytest
from django.urls import reverse
@ -45,8 +46,9 @@ def test_user_data():
# ---------- Тесты БЕЗ LDAP (LDAP_ENABLED=False) ----------
@pytest.mark.parametrize("route_name", ["api:register", "api:v1:register"])
def test_register_without_ldap_creates_user_only_in_db(
api_client, test_user_data, settings
api_client, test_user_data, settings, route_name
):
"""
R7: Успешная регистрация без LDAP.
@ -56,7 +58,7 @@ def test_register_without_ldap_creates_user_only_in_db(
"""
settings.LDAP_ENABLED = False
url = reverse("api:v1:register")
url = reverse(route_name)
response = api_client.post(url, test_user_data, format="json")
assert response.status_code == status.HTTP_201_CREATED
@ -69,8 +71,9 @@ def test_register_without_ldap_creates_user_only_in_db(
assert user.check_password(test_user_data["password"])
@pytest.mark.parametrize("route_name", ["api:register", "api:v1:register"])
def test_register_without_ldap_duplicate_email_returns_400(
api_client, test_user_data, settings
api_client, test_user_data, settings, route_name
):
"""R9: Дублирование email без LDAP возвращает 400"""
settings.LDAP_ENABLED = False
@ -84,8 +87,35 @@ def test_register_without_ldap_duplicate_email_returns_400(
email_verified=True, # Верифицирован - нельзя регистрировать снова
)
url = reverse("api:v1:register")
url = reverse(route_name)
response = api_client.post(url, test_user_data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.parametrize("route_name", ["api:register", "api:v1:register"])
@patch("api.auth.views.RegisterAPIView.create_ldap_user")
def test_register_with_ldap_uses_ldap_creation_path(
mock_create_ldap_user,
api_client,
test_user_data,
settings,
route_name,
):
settings.LDAP_ENABLED = True
emp = Employee(
email=test_user_data["email"],
phone_number=test_user_data["phone_number"],
first_name=test_user_data["first_name"],
last_name=test_user_data["last_name"],
is_active=False,
email_verified=False,
is_ldap_managed=True,
)
mock_create_ldap_user.return_value = (emp, None)
response = api_client.post(reverse(route_name), test_user_data, format="json")
assert response.status_code == status.HTTP_201_CREATED
mock_create_ldap_user.assert_called_once()

View File

@ -55,13 +55,14 @@ def verified_user(db):
@pytest.mark.django_db
@pytest.mark.parametrize("route_name", ["api:verify-email", "api:v1:verify-email"])
def test_verify_email_without_ldap_activates_user_only_in_db(
api_client, unverified_user, settings
api_client, unverified_user, settings, route_name
):
"""V2: Верификация email без LDAP активирует пользователя только в БД"""
settings.LDAP_ENABLED = False
url = reverse("api:v1:verify-email")
url = reverse(route_name)
data = {
"email": unverified_user.email,
"code": unverified_user.email_activation_code,
@ -80,13 +81,14 @@ def test_verify_email_without_ldap_activates_user_only_in_db(
@pytest.mark.django_db
@pytest.mark.parametrize("route_name", ["api:verify-email", "api:v1:verify-email"])
def test_verify_email_with_wrong_code_returns_400_without_ldap(
api_client, unverified_user, settings
api_client, unverified_user, settings, route_name
):
"""V3: Неверный код возвращает 400"""
settings.LDAP_ENABLED = False
url = reverse("api:v1:verify-email")
url = reverse(route_name)
data = {
"email": unverified_user.email,
"code": "999999", # Неправильный, но валидный формат (6 цифр)
@ -106,13 +108,14 @@ def test_verify_email_with_wrong_code_returns_400_without_ldap(
@pytest.mark.django_db
@pytest.mark.parametrize("route_name", ["api:verify-email", "api:v1:verify-email"])
def test_verify_email_already_verified_returns_400_without_ldap(
api_client, verified_user, settings
api_client, verified_user, settings, route_name
):
"""V4: Повторная верификация уже верифицированного email возвращает 400"""
settings.LDAP_ENABLED = False
url = reverse("api:v1:verify-email")
url = reverse(route_name)
data = {
"email": verified_user.email,
"code": "123456", # Валидный формат, но неправильный код
@ -128,13 +131,14 @@ def test_verify_email_already_verified_returns_400_without_ldap(
@pytest.mark.django_db
@pytest.mark.parametrize("route_name", ["api:verify-email", "api:v1:verify-email"])
def test_verify_email_nonexistent_user_returns_404_without_ldap(
api_client, settings
api_client, settings, route_name
):
"""V5: Верификация несуществующего пользователя возвращает 404"""
settings.LDAP_ENABLED = False
url = reverse("api:v1:verify-email")
url = reverse(route_name)
data = {
"email": "nonexistent@example.com",
"code": "123456",
@ -146,13 +150,14 @@ def test_verify_email_nonexistent_user_returns_404_without_ldap(
@pytest.mark.django_db
@pytest.mark.parametrize("route_name", ["api:verify-email", "api:v1:verify-email"])
def test_verify_email_empty_code_returns_400_without_ldap(
api_client, unverified_user, settings
api_client, unverified_user, settings, route_name
):
"""V6: Пустой код возвращает 400"""
settings.LDAP_ENABLED = False
url = reverse("api:v1:verify-email")
url = reverse(route_name)
data = {
"email": unverified_user.email,
"code": "",
@ -162,4 +167,3 @@ def test_verify_email_empty_code_returns_400_without_ldap(
assert response.status_code == status.HTTP_400_BAD_REQUEST

View File

@ -0,0 +1,60 @@
import pytest
from asgiref.sync import sync_to_async
from channels.testing import WebsocketCommunicator
from django.contrib.auth import get_user_model
from rest_framework_simplejwt.tokens import AccessToken
from api.auth.models import UserAuthSession
from eusrr_backend.channels_jwt import JWTAuthMiddleware
User = get_user_model()
@pytest.mark.django_db(transaction=True)
@pytest.mark.asyncio
async def test_revoked_session_becomes_anonymous_for_websocket():
user = await sync_to_async(User.objects.create_user)(
email="ws-auth@example.com",
password="testpass123",
phone_number="+79997770011",
first_name="Ws",
last_name="User",
is_active=True,
email_verified=True,
)
session = await sync_to_async(UserAuthSession.objects.create)(
user=user,
refresh_token_hash="stub",
)
token = AccessToken.for_user(user)
token["session_id"] = str(session.session_id)
captured = {}
async def app(scope, receive, send):
captured["user"] = scope["user"]
await send({"type": "websocket.accept"})
await send({"type": "websocket.close"})
communicator = WebsocketCommunicator(
JWTAuthMiddleware(app),
f"/ws/?token={str(token)}",
)
connected, _ = await communicator.connect()
assert connected is True
assert captured["user"].is_authenticated is True
await communicator.disconnect()
await sync_to_async(session.revoke)(reason="test")
captured.clear()
communicator = WebsocketCommunicator(
JWTAuthMiddleware(app),
f"/ws/?token={str(token)}",
)
connected, _ = await communicator.connect()
assert connected is True
assert captured["user"].is_authenticated is False
await communicator.disconnect()

View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,13 @@
import type { AuthSession, SessionBulkActionResult } from "@/types/api";
import type { RequestFn } from "./utils";
export function createAuthApi(request: RequestFn) {
return {
getAuthSessions: (): Promise<AuthSession[]> => request("/api/auth/sessions/"),
deleteAuthSession: (sessionId: string): Promise<void> =>
request(`/api/auth/sessions/${sessionId}/`, { method: "DELETE" }),
logoutOtherSessions: (): Promise<SessionBulkActionResult> =>
request("/api/auth/sessions/logout-others/", { method: "POST" }),
};
}

View File

@ -4,6 +4,7 @@
*/
import { ApiClientBase } from './client';
import { createEmployeesApi } from './employees';
import { createAuthApi } from './auth';
import { createDocumentsApi } from './documents';
import { createMessagesApi } from './messages';
import { createNotificationsApi } from './notifications';
@ -18,6 +19,7 @@ const req = base.request.bind(base);
const tok = base.getToken.bind(base);
export const apiClient = Object.assign(base, {
...createAuthApi(req),
...createEmployeesApi(req),
...createDocumentsApi(req),
...createMessagesApi(req, tok),

View File

@ -61,6 +61,20 @@ export interface User {
};
}
export interface AuthSession {
session_id: string;
is_current: boolean;
device_name?: string | null;
ip_address?: string | null;
created_at: string;
last_seen_at: string;
revoked_at?: string | null;
}
export interface SessionBulkActionResult {
revoked: number;
}
export interface Department {
id: number;
name: string;