From 7dcf84dcd1d484ee9d47f34863bd925e719c4697 Mon Sep 17 00:00:00 2001 From: lizerk Date: Sat, 11 Apr 2026 23:31:14 +0300 Subject: [PATCH] feat(auth): add session management and settings integration --- backend/api/auth/authentication.py | 23 + backend/api/auth/models.py | 82 ++ backend/api/auth/serializers.py | 81 +- backend/api/auth/services.py | 158 ++ backend/api/auth/urls.py | 31 + backend/api/auth/views.py | 388 ++++- backend/api/migrations/0001_initial.py | 128 ++ backend/api/models.py | 3 + backend/api/urls.py | 12 +- backend/api/v1/employees/views/auth.py | 352 +---- backend/api/v1/employees/views/mixins.py | 5 +- backend/api/v1/urls.py | 15 +- backend/eusrr_backend/channels_jwt.py | 18 +- backend/eusrr_backend/settings.py | 2 +- backend/eusrr_backend/settings_test.py | 15 + .../api/auth/test_auth_and_registration.py | 115 ++ backend/tests/api/test_documentation.py | 4 + .../api/v1/employees/test_employee_actions.py | 19 +- .../employees/test_ldap_optional_register.py | 38 +- .../test_ldap_optional_verify_email.py | 26 +- .../realtime/test_jwt_session_middleware.py | 60 + frontend/next-env.d.ts | 2 +- frontend/src/app/settings/page.tsx | 1269 ++++++++++++++++- frontend/src/lib/api/auth.ts | 13 + frontend/src/lib/api/index.ts | 2 + frontend/src/types/api.ts | 14 + 26 files changed, 2412 insertions(+), 463 deletions(-) create mode 100644 backend/api/auth/authentication.py create mode 100644 backend/api/auth/models.py create mode 100644 backend/api/auth/services.py create mode 100644 backend/api/auth/urls.py create mode 100644 backend/api/migrations/0001_initial.py create mode 100644 backend/api/models.py create mode 100644 backend/tests/realtime/test_jwt_session_middleware.py create mode 100644 frontend/src/lib/api/auth.ts diff --git a/backend/api/auth/authentication.py b/backend/api/auth/authentication.py new file mode 100644 index 00000000..0b644b9d --- /dev/null +++ b/backend/api/auth/authentication.py @@ -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 diff --git a/backend/api/auth/models.py b/backend/api/auth/models.py new file mode 100644 index 00000000..ecc7df0e --- /dev/null +++ b/backend/api/auth/models.py @@ -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"]) diff --git a/backend/api/auth/serializers.py b/backend/api/auth/serializers.py index 592ae02c..80881d80 100644 --- a/backend/api/auth/serializers.py +++ b/backend/api/auth/serializers.py @@ -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)} diff --git a/backend/api/auth/services.py b/backend/api/auth/services.py new file mode 100644 index 00000000..d72b4941 --- /dev/null +++ b/backend/api/auth/services.py @@ -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) diff --git a/backend/api/auth/urls.py b/backend/api/auth/urls.py new file mode 100644 index 00000000..6763d2ef --- /dev/null +++ b/backend/api/auth/urls.py @@ -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//", + SessionDetailAPIView.as_view(), + name="session-detail", + ), +] diff --git a/backend/api/auth/views.py b/backend/api/auth/views.py index fe7cf485..bad4a28e 100644 --- a/backend/api/auth/views.py +++ b/backend/api/auth/views.py @@ -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) diff --git a/backend/api/migrations/0001_initial.py b/backend/api/migrations/0001_initial.py new file mode 100644 index 00000000..ff07ac16 --- /dev/null +++ b/backend/api/migrations/0001_initial.py @@ -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", + ), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py new file mode 100644 index 00000000..f35a6e3f --- /dev/null +++ b/backend/api/models.py @@ -0,0 +1,3 @@ +from api.auth.models import UserAuthSession + +__all__ = ["UserAuthSession"] diff --git a/backend/api/urls.py b/backend/api/urls.py index a151a685..886f235f 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -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")), ] diff --git a/backend/api/v1/employees/views/auth.py b/backend/api/v1/employees/views/auth.py index da201887..32239fcf 100644 --- a/backend/api/v1/employees/views/auth.py +++ b/backend/api/v1/employees/views/auth.py @@ -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", +] diff --git a/backend/api/v1/employees/views/mixins.py b/backend/api/v1/employees/views/mixins.py index 86b814d9..cab0712a 100644 --- a/backend/api/v1/employees/views/mixins.py +++ b/backend/api/v1/employees/views/mixins.py @@ -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, diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index ce981a79..f39ff7a0 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -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 diff --git a/backend/eusrr_backend/channels_jwt.py b/backend/eusrr_backend/channels_jwt.py index ae945c9c..3402d8bd 100644 --- a/backend/eusrr_backend/channels_jwt.py +++ b/backend/eusrr_backend/channels_jwt.py @@ -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 diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 0ed95c31..eea106d0 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -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", diff --git a/backend/eusrr_backend/settings_test.py b/backend/eusrr_backend/settings_test.py index 664bd5e0..a814deeb 100644 --- a/backend/eusrr_backend/settings_test.py +++ b/backend/eusrr_backend/settings_test.py @@ -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 diff --git a/backend/tests/api/auth/test_auth_and_registration.py b/backend/tests/api/auth/test_auth_and_registration.py index d9d17d38..0cf53cd2 100644 --- a/backend/tests/api/auth/test_auth_and_registration.py +++ b/backend/tests/api/auth/test_auth_and_registration.py @@ -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 diff --git a/backend/tests/api/test_documentation.py b/backend/tests/api/test_documentation.py index 088c8f00..5654e185 100644 --- a/backend/tests/api/test_documentation.py +++ b/backend/tests/api/test_documentation.py @@ -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"] diff --git a/backend/tests/api/v1/employees/test_employee_actions.py b/backend/tests/api/v1/employees/test_employee_actions.py index 23ca7c13..ca1c9d03 100644 --- a/backend/tests/api/v1/employees/test_employee_actions.py +++ b/backend/tests/api/v1/employees/test_employee_actions.py @@ -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 - diff --git a/backend/tests/api/v1/employees/test_ldap_optional_register.py b/backend/tests/api/v1/employees/test_ldap_optional_register.py index ffac4b7b..d9ba9918 100644 --- a/backend/tests/api/v1/employees/test_ldap_optional_register.py +++ b/backend/tests/api/v1/employees/test_ldap_optional_register.py @@ -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() diff --git a/backend/tests/api/v1/employees/test_ldap_optional_verify_email.py b/backend/tests/api/v1/employees/test_ldap_optional_verify_email.py index ec1f12a4..22f04671 100644 --- a/backend/tests/api/v1/employees/test_ldap_optional_verify_email.py +++ b/backend/tests/api/v1/employees/test_ldap_optional_verify_email.py @@ -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 - diff --git a/backend/tests/realtime/test_jwt_session_middleware.py b/backend/tests/realtime/test_jwt_session_middleware.py new file mode 100644 index 00000000..60bfbbf8 --- /dev/null +++ b/backend/tests/realtime/test_jwt_session_middleware.py @@ -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() diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index 272524cf..771efbe2 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -1,10 +1,49 @@ "use client"; -import { Check, Monitor, Moon, Sun } from "lucide-react"; +import Image from "next/image"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Bell, + Check, + Clock3, + LogOut, + Mail, + Monitor, + Moon, + Save, + Smartphone, + Sun, + UserRound, +} from "lucide-react"; +import { toast } from "sonner"; import { AppShell, PageHeader } from "@/components/AppShell"; import { useTheme } from "@/contexts/ThemeContext"; +import { useUser } from "@/contexts/UserContext"; +import { useWebPush } from "@/hooks/useWebPush"; +import { apiClient } from "@/lib/api"; import type { ThemePreference } from "@/lib/theme"; +import { resolveMediaUrl } from "@/lib/url"; +import type { AuthSession } from "@/types/api"; +import { getVerbName } from "@/lib/verbTranslations"; + +type NotificationPreferences = { + web_enabled: boolean; + email_enabled: boolean; + email_frequency: "instant" | "daily" | "weekly" | "never"; + push_enabled: boolean; + dnd_enabled: boolean; + dnd_start_time: string | null; + dnd_end_time: string | null; + disabled_verbs: string[]; +}; + +type VerbType = { + verb: string; + name: string; + total: number; + unread: number; +}; const themeCards: Array<{ value: ThemePreference; @@ -21,84 +60,1200 @@ const themeCards: Array<{ { value: "dark", title: "Темная", - description: "Темные поверхности, ручное включение без браузерной подкраски.", + description: "Темные поверхности без browser force-dark.", icon: Moon, }, { value: "auto", title: "Авто", - description: "Следует системной теме устройства и браузера.", + description: "Следует системной теме устройства.", icon: Monitor, }, ]; +const defaultPreferences: NotificationPreferences = { + web_enabled: true, + email_enabled: false, + email_frequency: "instant", + push_enabled: false, + dnd_enabled: false, + dnd_start_time: null, + dnd_end_time: null, + disabled_verbs: [], +}; + +function preferencesSignature(value: NotificationPreferences | null) { + if (!value) return ""; + return JSON.stringify({ + ...value, + disabled_verbs: [...value.disabled_verbs].sort(), + dnd_start_time: value.dnd_start_time || null, + dnd_end_time: value.dnd_end_time || null, + }); +} + +function initials(firstName?: string, lastName?: string) { + return `${lastName?.[0] || ""}${firstName?.[0] || ""}`.trim() || "П"; +} + +function formatSessionDateTime(value?: string | null) { + if (!value) return "—"; + + try { + return new Intl.DateTimeFormat("ru-RU", { + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); + } catch { + return value; + } +} + +function getSessionDeviceIcon(deviceName?: string | null) { + const normalized = (deviceName || "").toLowerCase(); + return /iphone|android|mobile|ipad|phone/.test(normalized) ? Smartphone : Monitor; +} + +function getSessionDeviceName(session: AuthSession) { + return session.device_name?.trim() || "Неизвестное устройство"; +} + +function isApiNotFoundError(error: unknown) { + return error instanceof Error && error.message.includes("API Error: 404"); +} + +function Toggle({ + checked, + onChange, + disabled, +}: { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; +}) { + return ( +