diff --git a/backend/api/auth/models.py b/backend/api/auth/models.py index ecc7df0e..47c508ee 100644 --- a/backend/api/auth/models.py +++ b/backend/api/auth/models.py @@ -80,3 +80,191 @@ class UserAuthSession(models.Model): self.revoked_reason = reason if commit: self.save(update_fields=["revoked_at", "revoked_reason"]) + + +class QrLoginToken(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="qr_login_tokens", + verbose_name="Пользователь", + ) + token_hash = models.CharField( + max_length=128, + unique=True, + db_index=True, + verbose_name="Хеш QR-токена", + ) + created_by_session = models.ForeignKey( + UserAuthSession, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="created_qr_login_tokens", + verbose_name="Сессия-источник", + ) + used_session = models.ForeignKey( + UserAuthSession, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="used_qr_login_tokens", + verbose_name="Созданная сессия", + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создан") + expires_at = models.DateTimeField(db_index=True, verbose_name="Истекает") + used_at = models.DateTimeField( + null=True, + blank=True, + db_index=True, + verbose_name="Использован", + ) + created_ip_address = models.GenericIPAddressField( + null=True, + blank=True, + verbose_name="IP создания", + ) + created_user_agent = models.TextField( + blank=True, + default="", + verbose_name="User-Agent создания", + ) + used_ip_address = models.GenericIPAddressField( + null=True, + blank=True, + verbose_name="IP использования", + ) + used_user_agent = models.TextField( + blank=True, + default="", + verbose_name="User-Agent использования", + ) + + class Meta: + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["user", "used_at", "expires_at"]), + ] + verbose_name = "QR-токен входа" + verbose_name_plural = "QR-токены входа" + + def __str__(self) -> str: + return f"{self.user_id}:{self.created_at:%Y-%m-%d %H:%M:%S}" + + @property + def is_used(self) -> bool: + return self.used_at is not None + + @property + def is_expired(self) -> bool: + return timezone.now() >= self.expires_at + + +class QrLoginRequest(models.Model): + scan_token_hash = models.CharField( + max_length=128, + unique=True, + db_index=True, + verbose_name="Хеш токена сканирования", + ) + client_secret_hash = models.CharField( + max_length=128, + unique=True, + db_index=True, + verbose_name="Хеш секрета клиента", + ) + requester_ip_address = models.GenericIPAddressField( + null=True, + blank=True, + verbose_name="IP запрашивающего устройства", + ) + requester_user_agent = models.TextField( + blank=True, + default="", + verbose_name="User-Agent запрашивающего устройства", + ) + requester_device_name = models.CharField( + max_length=255, + blank=True, + default="", + verbose_name="Запрашивающее устройство", + ) + approved_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="approved_qr_login_requests", + verbose_name="Подтвердил пользователь", + ) + approved_by_session = models.ForeignKey( + UserAuthSession, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="approved_qr_login_requests", + verbose_name="Подтвердила сессия", + ) + used_session = models.ForeignKey( + UserAuthSession, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="qr_login_requests", + verbose_name="Созданная сессия", + ) + denied_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="denied_qr_login_requests", + verbose_name="Отклонил пользователь", + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создан") + expires_at = models.DateTimeField(db_index=True, verbose_name="Истекает") + approved_at = models.DateTimeField( + null=True, + blank=True, + db_index=True, + verbose_name="Подтвержден", + ) + denied_at = models.DateTimeField( + null=True, + blank=True, + db_index=True, + verbose_name="Отклонен", + ) + claimed_at = models.DateTimeField( + null=True, + blank=True, + db_index=True, + verbose_name="Получен клиентом", + ) + + class Meta: + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["expires_at", "approved_at", "denied_at"]), + ] + verbose_name = "QR-запрос входа" + verbose_name_plural = "QR-запросы входа" + + def __str__(self) -> str: + return f"{self.requester_device_name or 'unknown'}:{self.created_at:%Y-%m-%d %H:%M:%S}" + + @property + def is_expired(self) -> bool: + return timezone.now() >= self.expires_at + + @property + def status(self) -> str: + if self.claimed_at is not None: + return "claimed" + if self.denied_at is not None: + return "denied" + if self.is_expired: + return "expired" + if self.approved_at is not None: + return "approved" + return "pending" diff --git a/backend/api/auth/serializers.py b/backend/api/auth/serializers.py index f4e0be1b..8cb17589 100644 --- a/backend/api/auth/serializers.py +++ b/backend/api/auth/serializers.py @@ -14,8 +14,7 @@ from rest_framework_simplejwt.serializers import ( from .models import UserAuthSession from .services import ( SESSION_ID_CLAIM, - create_auth_session, - hash_refresh_token, + create_token_pair_for_session, validate_refresh_session, ) @@ -27,6 +26,45 @@ class TokenPairResponseSerializer(serializers.Serializer): access = serializers.CharField(read_only=True) +class QrLoginCreateResponseSerializer(serializers.Serializer): + token = serializers.CharField(read_only=True) + expires_at = serializers.DateTimeField(read_only=True) + + +class QrLoginExchangeRequestSerializer(serializers.Serializer): + token = serializers.CharField(write_only=True, trim_whitespace=True) + + +class QrLoginRequestCreateResponseSerializer(serializers.Serializer): + scan_token = serializers.CharField(read_only=True) + client_secret = serializers.CharField(read_only=True) + expires_at = serializers.DateTimeField(read_only=True) + device_name = serializers.CharField(read_only=True) + ip_address = serializers.IPAddressField(read_only=True, allow_null=True) + + +class QrLoginRequestStatusRequestSerializer(serializers.Serializer): + client_secret = serializers.CharField(write_only=True, trim_whitespace=True) + + +class QrLoginRequestStatusResponseSerializer(serializers.Serializer): + status = serializers.CharField(read_only=True) + refresh = serializers.CharField(read_only=True, required=False) + access = serializers.CharField(read_only=True, required=False) + + +class QrLoginRequestDetailResponseSerializer(serializers.Serializer): + status = serializers.CharField(read_only=True) + device_name = serializers.CharField(read_only=True) + ip_address = serializers.IPAddressField(read_only=True, allow_null=True) + created_at = serializers.DateTimeField(read_only=True) + expires_at = serializers.DateTimeField(read_only=True) + + +class QrLoginRequestActionResponseSerializer(serializers.Serializer): + status = serializers.CharField(read_only=True) + + class TokenRefreshRequestSerializer(serializers.Serializer): refresh = serializers.CharField( help_text="Refresh token, полученный при логине." @@ -196,18 +234,14 @@ class PhoneOrEmailTokenObtainPairSerializer(TokenObtainPairSerializer): 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"]) + refresh, access, _session = create_token_pair_for_session( + user=self.user, + request=request, + ) return { - "refresh": str(refresh), - "access": str(access), + "refresh": refresh, + "access": access, } diff --git a/backend/api/auth/services.py b/backend/api/auth/services.py index d72b4941..4937b69a 100644 --- a/backend/api/auth/services.py +++ b/backend/api/auth/services.py @@ -1,16 +1,20 @@ from __future__ import annotations +import secrets from datetime import timedelta from typing import Any +from django.conf import settings from django.contrib.auth import get_user_model +from django.db import transaction 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 rest_framework_simplejwt.tokens import RefreshToken -from .models import UserAuthSession +from .models import QrLoginRequest, QrLoginToken, UserAuthSession User = get_user_model() @@ -22,6 +26,18 @@ def hash_refresh_token(raw_token: str) -> str: return salted_hmac("api.auth.refresh", raw_token).hexdigest() +def hash_qr_login_token(raw_token: str) -> str: + return salted_hmac("api.auth.qr_login", raw_token).hexdigest() + + +def hash_qr_login_request_scan_token(raw_token: str) -> str: + return salted_hmac("api.auth.qr_login_request.scan", raw_token).hexdigest() + + +def hash_qr_login_request_client_secret(raw_token: str) -> str: + return salted_hmac("api.auth.qr_login_request.client", raw_token).hexdigest() + + def extract_client_ip(request) -> str: forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "") if forwarded_for: @@ -68,14 +84,289 @@ def build_device_name(user_agent: str) -> str: def create_auth_session(*, user: User, request) -> UserAuthSession: user_agent = extract_user_agent(request) - return UserAuthSession.objects.create( + return create_auth_session_from_client_info( user=user, ip_address=extract_client_ip(request) or None, user_agent=user_agent, + ) + + +def create_auth_session_from_client_info( + *, + user: User, + ip_address: str | None, + user_agent: str, +) -> UserAuthSession: + return UserAuthSession.objects.create( + user=user, + ip_address=ip_address, + user_agent=user_agent, device_name=build_device_name(user_agent), ) +def create_token_pair_for_auth_session( + *, + user: User, + session: UserAuthSession, +) -> tuple[str, str, UserAuthSession]: + refresh = RefreshToken.for_user(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 str(refresh), str(access), session + + +def create_token_pair_for_session( + *, + user: User, + request, +) -> tuple[str, str, UserAuthSession]: + session = create_auth_session(user=user, request=request) + return create_token_pair_for_auth_session(user=user, session=session) + + +def get_qr_login_ttl() -> timedelta: + raw_seconds = getattr(settings, "QR_LOGIN_TOKEN_TTL_SECONDS", 120) + try: + seconds = int(raw_seconds) + except (TypeError, ValueError): + seconds = 120 + return timedelta(seconds=max(30, min(seconds, 600))) + + +def get_qr_login_request_ttl() -> timedelta: + raw_seconds = getattr(settings, "QR_LOGIN_REQUEST_TTL_SECONDS", 120) + try: + seconds = int(raw_seconds) + except (TypeError, ValueError): + seconds = 120 + return timedelta(seconds=max(30, min(seconds, 600))) + + +def create_qr_login_token( + *, + user: User, + request, + current_session_id: str | None = None, +) -> tuple[str, QrLoginToken]: + raw_token = secrets.token_urlsafe(32) + current_session = None + if current_session_id: + current_session = UserAuthSession.objects.filter( + user=user, + session_id=current_session_id, + revoked_at__isnull=True, + ).first() + + token = QrLoginToken.objects.create( + user=user, + token_hash=hash_qr_login_token(raw_token), + created_by_session=current_session, + expires_at=timezone.now() + get_qr_login_ttl(), + created_ip_address=extract_client_ip(request) or None, + created_user_agent=extract_user_agent(request), + ) + return raw_token, token + + +@transaction.atomic +def exchange_qr_login_token(*, raw_token: str, request) -> tuple[str, str]: + token_hash = hash_qr_login_token(raw_token) + qr_token = ( + QrLoginToken.objects.select_for_update() + .select_related("user") + .filter(token_hash=token_hash) + .first() + ) + + if ( + qr_token is None + or qr_token.is_used + or qr_token.is_expired + or not qr_token.user.is_active + or not getattr(qr_token.user, "email_verified", False) + ): + raise InvalidToken("qr_login_token_invalid") + + refresh, access, session = create_token_pair_for_session( + user=qr_token.user, + request=request, + ) + qr_token.used_at = timezone.now() + qr_token.used_session = session + qr_token.used_ip_address = extract_client_ip(request) or None + qr_token.used_user_agent = extract_user_agent(request) + qr_token.save( + update_fields=[ + "used_at", + "used_session", + "used_ip_address", + "used_user_agent", + ] + ) + + return refresh, access + + +def create_qr_login_request(*, request) -> tuple[str, str, QrLoginRequest]: + scan_token = secrets.token_urlsafe(32) + client_secret = secrets.token_urlsafe(32) + user_agent = extract_user_agent(request) + qr_request = QrLoginRequest.objects.create( + scan_token_hash=hash_qr_login_request_scan_token(scan_token), + client_secret_hash=hash_qr_login_request_client_secret(client_secret), + requester_ip_address=extract_client_ip(request) or None, + requester_user_agent=user_agent, + requester_device_name=build_device_name(user_agent), + expires_at=timezone.now() + get_qr_login_request_ttl(), + ) + return scan_token, client_secret, qr_request + + +def _load_qr_login_request_by_scan_token( + raw_scan_token: str, +) -> QrLoginRequest: + qr_request = QrLoginRequest.objects.filter( + scan_token_hash=hash_qr_login_request_scan_token(raw_scan_token), + ).first() + if qr_request is None: + raise InvalidToken("qr_login_request_not_found") + return qr_request + + +def _load_qr_login_request_by_client_secret( + raw_client_secret: str, +) -> QrLoginRequest: + qr_request = QrLoginRequest.objects.filter( + client_secret_hash=hash_qr_login_request_client_secret(raw_client_secret), + ).first() + if qr_request is None: + raise InvalidToken("qr_login_request_not_found") + return qr_request + + +def get_qr_login_request_for_approval(raw_scan_token: str) -> QrLoginRequest: + return _load_qr_login_request_by_scan_token(raw_scan_token) + + +@transaction.atomic +def approve_qr_login_request( + *, + raw_scan_token: str, + user: User, + current_session_id: str | None, +) -> QrLoginRequest: + qr_request = ( + QrLoginRequest.objects.select_for_update() + .filter(scan_token_hash=hash_qr_login_request_scan_token(raw_scan_token)) + .first() + ) + if qr_request is None or qr_request.status != "pending": + raise InvalidToken("qr_login_request_not_pending") + + current_session = None + if current_session_id: + current_session = UserAuthSession.objects.filter( + user=user, + session_id=current_session_id, + revoked_at__isnull=True, + ).first() + + qr_request.approved_by = user + qr_request.approved_by_session = current_session + qr_request.approved_at = timezone.now() + qr_request.save( + update_fields=["approved_by", "approved_by_session", "approved_at"] + ) + return qr_request + + +@transaction.atomic +def deny_qr_login_request( + *, + raw_scan_token: str, + user: User, +) -> QrLoginRequest: + qr_request = ( + QrLoginRequest.objects.select_for_update() + .filter(scan_token_hash=hash_qr_login_request_scan_token(raw_scan_token)) + .first() + ) + if qr_request is None or qr_request.status not in ("pending", "approved"): + raise InvalidToken("qr_login_request_closed") + + qr_request.denied_by = user + qr_request.denied_at = timezone.now() + qr_request.save(update_fields=["denied_by", "denied_at"]) + return qr_request + + +@transaction.atomic +def cancel_qr_login_request(*, raw_client_secret: str) -> QrLoginRequest: + qr_request = ( + QrLoginRequest.objects.select_for_update() + .filter( + client_secret_hash=hash_qr_login_request_client_secret(raw_client_secret), + ) + .first() + ) + if qr_request is None: + raise InvalidToken("qr_login_request_not_found") + if qr_request.status == "pending": + qr_request.denied_at = timezone.now() + qr_request.save(update_fields=["denied_at"]) + return qr_request + + +@transaction.atomic +def poll_qr_login_request( + *, + raw_client_secret: str, + request, +) -> dict[str, str | None]: + qr_request = ( + QrLoginRequest.objects.select_for_update() + .select_related("approved_by") + .filter( + client_secret_hash=hash_qr_login_request_client_secret(raw_client_secret), + ) + .first() + ) + if qr_request is None: + raise InvalidToken("qr_login_request_not_found") + + current_status = qr_request.status + if current_status != "approved": + return {"status": current_status} + + user = qr_request.approved_by + if user is None or not user.is_active or not getattr(user, "email_verified", False): + qr_request.denied_at = timezone.now() + qr_request.save(update_fields=["denied_at"]) + return {"status": "denied"} + + user_agent = extract_user_agent(request) or qr_request.requester_user_agent + session = create_auth_session_from_client_info( + user=user, + ip_address=extract_client_ip(request) or qr_request.requester_ip_address, + user_agent=user_agent, + ) + refresh, access, session = create_token_pair_for_auth_session( + user=user, + session=session, + ) + qr_request.claimed_at = timezone.now() + qr_request.used_session = session + qr_request.save(update_fields=["claimed_at", "used_session"]) + + return {"status": "approved", "refresh": refresh, "access": access} + + def _load_session_from_token(token: Any, *, user: User | None = None) -> UserAuthSession: session_id = token.get(SESSION_ID_CLAIM) if not session_id: diff --git a/backend/api/auth/urls.py b/backend/api/auth/urls.py index 1979e84b..1c117536 100644 --- a/backend/api/auth/urls.py +++ b/backend/api/auth/urls.py @@ -7,6 +7,14 @@ from .views import ( PasswordResetAPIView, PasswordResetConfirmAPIView, PhoneOrEmailTokenObtainPairView, + QrLoginCreateAPIView, + QrLoginExchangeAPIView, + QrLoginRequestApproveAPIView, + QrLoginRequestCancelAPIView, + QrLoginRequestCreateAPIView, + QrLoginRequestDenyAPIView, + QrLoginRequestDetailAPIView, + QrLoginRequestStatusAPIView, RegisterAPIView, ResendEmailAPIView, SessionDetailAPIView, @@ -17,6 +25,42 @@ from .views import ( urlpatterns = [ path("token/", PhoneOrEmailTokenObtainPairView.as_view(), name="token_obtain_pair"), path("token/refresh/", JWTTokenRefreshView.as_view(), name="token_refresh"), + path("qr-login/", QrLoginCreateAPIView.as_view(), name="qr-login"), + path( + "qr-login/exchange/", + QrLoginExchangeAPIView.as_view(), + name="qr-login-exchange", + ), + path( + "qr-login/requests/", + QrLoginRequestCreateAPIView.as_view(), + name="qr-login-request", + ), + path( + "qr-login/requests/status/", + QrLoginRequestStatusAPIView.as_view(), + name="qr-login-request-status", + ), + path( + "qr-login/requests/cancel/", + QrLoginRequestCancelAPIView.as_view(), + name="qr-login-request-cancel", + ), + path( + "qr-login/requests//", + QrLoginRequestDetailAPIView.as_view(), + name="qr-login-request-detail", + ), + path( + "qr-login/requests//approve/", + QrLoginRequestApproveAPIView.as_view(), + name="qr-login-request-approve", + ), + path( + "qr-login/requests//deny/", + QrLoginRequestDenyAPIView.as_view(), + name="qr-login-request-deny", + ), path("password-reset/", PasswordResetAPIView.as_view(), name="password-reset"), path( "password-reset/confirm/", diff --git a/backend/api/auth/views.py b/backend/api/auth/views.py index 35a04598..3ca54f70 100644 --- a/backend/api/auth/views.py +++ b/backend/api/auth/views.py @@ -35,6 +35,7 @@ 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.exceptions import InvalidToken from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView from .models import UserAuthSession @@ -46,6 +47,13 @@ from .serializers import ( PasswordResetConfirmResponseSerializer, PasswordResetRequestResponseSerializer, PasswordResetRequestSerializer, + QrLoginCreateResponseSerializer, + QrLoginExchangeRequestSerializer, + QrLoginRequestActionResponseSerializer, + QrLoginRequestCreateResponseSerializer, + QrLoginRequestDetailResponseSerializer, + QrLoginRequestStatusRequestSerializer, + QrLoginRequestStatusResponseSerializer, SessionBulkActionResponseSerializer, SessionSerializer, SessionTokenRefreshSerializer, @@ -53,7 +61,17 @@ from .serializers import ( TokenRefreshRequestSerializer, TokenRefreshResponseSerializer, ) -from .services import SESSION_ID_CLAIM +from .services import ( + SESSION_ID_CLAIM, + approve_qr_login_request, + cancel_qr_login_request, + create_qr_login_request, + create_qr_login_token, + deny_qr_login_request, + exchange_qr_login_token, + get_qr_login_request_for_approval, + poll_qr_login_request, +) logger = logging.getLogger(__name__) @@ -135,6 +153,189 @@ class JWTTokenRefreshView(TokenRefreshView): serializer_class = SessionTokenRefreshSerializer +class QrLoginExchangeAPIView(AnonymousAPIView): + @extend_schema( + tags=["Auth"], + summary="Обменять одноразовый QR-токен на JWT access и refresh", + request=QrLoginExchangeRequestSerializer, + responses={ + 200: TokenPairResponseSerializer, + 401: OpenApiResponse( + description="QR-токен недействителен, истек или уже использован." + ), + }, + ) + def post(self, request): + serializer = QrLoginExchangeRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + refresh, access = exchange_qr_login_token( + raw_token=serializer.validated_data["token"], + request=request, + ) + except InvalidToken: + return Response( + {"detail": "qr_login_token_invalid"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + return Response( + {"refresh": refresh, "access": access}, + status=status.HTTP_200_OK, + ) + + +def _serialize_qr_login_request(qr_request) -> dict: + return { + "status": qr_request.status, + "device_name": qr_request.requester_device_name, + "ip_address": qr_request.requester_ip_address, + "created_at": qr_request.created_at, + "expires_at": qr_request.expires_at, + } + + +def _get_current_session_id_from_request(request) -> str | None: + token = getattr(request, "auth", None) + if token is None: + return None + return token.get(SESSION_ID_CLAIM) + + +class QrLoginRequestCreateAPIView(AnonymousAPIView): + @extend_schema( + tags=["Auth"], + summary="Создать QR-запрос входа для подтверждения на другом устройстве", + responses={200: QrLoginRequestCreateResponseSerializer}, + ) + def post(self, request): + scan_token, client_secret, qr_request = create_qr_login_request( + request=request, + ) + return Response( + { + "scan_token": scan_token, + "client_secret": client_secret, + "expires_at": qr_request.expires_at, + "device_name": qr_request.requester_device_name, + "ip_address": qr_request.requester_ip_address, + }, + status=status.HTTP_200_OK, + ) + + +class QrLoginRequestStatusAPIView(AnonymousAPIView): + @extend_schema( + tags=["Auth"], + summary="Проверить статус QR-запроса входа", + request=QrLoginRequestStatusRequestSerializer, + responses={200: QrLoginRequestStatusResponseSerializer}, + ) + def post(self, request): + serializer = QrLoginRequestStatusRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + payload = poll_qr_login_request( + raw_client_secret=serializer.validated_data["client_secret"], + request=request, + ) + except InvalidToken: + return Response( + {"detail": "qr_login_request_invalid"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + return Response(payload, status=status.HTTP_200_OK) + + +class QrLoginRequestCancelAPIView(AnonymousAPIView): + @extend_schema( + tags=["Auth"], + summary="Отменить QR-запрос входа с устройства, которое показывает QR", + request=QrLoginRequestStatusRequestSerializer, + responses={200: QrLoginRequestActionResponseSerializer}, + ) + def post(self, request): + serializer = QrLoginRequestStatusRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + qr_request = cancel_qr_login_request( + raw_client_secret=serializer.validated_data["client_secret"], + ) + except InvalidToken: + return Response( + {"detail": "qr_login_request_invalid"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + return Response({"status": qr_request.status}, status=status.HTTP_200_OK) + + +class QrLoginRequestDetailAPIView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Auth"], + summary="Получить параметры QR-запроса входа", + responses={200: QrLoginRequestDetailResponseSerializer}, + ) + def get(self, request, scan_token): + try: + qr_request = get_qr_login_request_for_approval(scan_token) + except InvalidToken: + return Response( + {"detail": "qr_login_request_invalid"}, + status=status.HTTP_404_NOT_FOUND, + ) + return Response( + _serialize_qr_login_request(qr_request), + status=status.HTTP_200_OK, + ) + + +class QrLoginRequestApproveAPIView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Auth"], + summary="Подтвердить QR-запрос входа", + responses={200: QrLoginRequestActionResponseSerializer}, + ) + def post(self, request, scan_token): + try: + qr_request = approve_qr_login_request( + raw_scan_token=scan_token, + user=request.user, + current_session_id=_get_current_session_id_from_request(request), + ) + except InvalidToken: + return Response( + {"detail": "qr_login_request_invalid"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + return Response({"status": qr_request.status}, status=status.HTTP_200_OK) + + +class QrLoginRequestDenyAPIView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Auth"], + summary="Отклонить QR-запрос входа", + responses={200: QrLoginRequestActionResponseSerializer}, + ) + def post(self, request, scan_token): + try: + qr_request = deny_qr_login_request( + raw_scan_token=scan_token, + user=request.user, + ) + except InvalidToken: + return Response( + {"detail": "qr_login_request_invalid"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + return Response({"status": qr_request.status}, status=status.HTTP_200_OK) + + class ResendEmailAPIView(AnonymousAPIView): @extend_schema( tags=["Auth"], @@ -406,6 +607,24 @@ class SessionBaseAPIView(APIView): return token.get(SESSION_ID_CLAIM) +class QrLoginCreateAPIView(SessionBaseAPIView): + @extend_schema( + tags=["Auth"], + summary="Создать одноразовый QR-токен для входа", + responses={200: QrLoginCreateResponseSerializer}, + ) + def post(self, request): + raw_token, token = create_qr_login_token( + user=request.user, + request=request, + current_session_id=self.get_current_session_id(request), + ) + return Response( + {"token": raw_token, "expires_at": token.expires_at}, + status=status.HTTP_200_OK, + ) + + class ChangePasswordAPIView(LdapPasswordMixin, SessionBaseAPIView): @extend_schema( tags=["Auth"], diff --git a/backend/api/migrations/0003_qrlogintoken.py b/backend/api/migrations/0003_qrlogintoken.py new file mode 100644 index 00000000..f1054214 --- /dev/null +++ b/backend/api/migrations/0003_qrlogintoken.py @@ -0,0 +1,131 @@ +# Generated manually for QR login tokens. + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0002_rename_api_useraut_user_id_1e9721_idx_api_useraut_user_id_70da51_idx_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="QrLoginToken", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "token_hash", + models.CharField( + db_index=True, + max_length=128, + unique=True, + verbose_name="Хеш QR-токена", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создан"), + ), + ( + "expires_at", + models.DateTimeField(db_index=True, verbose_name="Истекает"), + ), + ( + "used_at", + models.DateTimeField( + blank=True, + db_index=True, + null=True, + verbose_name="Использован", + ), + ), + ( + "created_ip_address", + models.GenericIPAddressField( + blank=True, + null=True, + verbose_name="IP создания", + ), + ), + ( + "created_user_agent", + models.TextField( + blank=True, + default="", + verbose_name="User-Agent создания", + ), + ), + ( + "used_ip_address", + models.GenericIPAddressField( + blank=True, + null=True, + verbose_name="IP использования", + ), + ), + ( + "used_user_agent", + models.TextField( + blank=True, + default="", + verbose_name="User-Agent использования", + ), + ), + ( + "created_by_session", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_qr_login_tokens", + to="api.userauthsession", + verbose_name="Сессия-источник", + ), + ), + ( + "used_session", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="used_qr_login_tokens", + to="api.userauthsession", + verbose_name="Созданная сессия", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="qr_login_tokens", + to=settings.AUTH_USER_MODEL, + verbose_name="Пользователь", + ), + ), + ], + options={ + "verbose_name": "QR-токен входа", + "verbose_name_plural": "QR-токены входа", + "ordering": ["-created_at"], + }, + ), + migrations.AddIndex( + model_name="qrlogintoken", + index=models.Index( + fields=["user", "used_at", "expires_at"], + name="api_qrlogin_user_id_8e45eb_idx", + ), + ), + ] diff --git a/backend/api/migrations/0004_qrloginrequest.py b/backend/api/migrations/0004_qrloginrequest.py new file mode 100644 index 00000000..9ad38ed7 --- /dev/null +++ b/backend/api/migrations/0004_qrloginrequest.py @@ -0,0 +1,164 @@ +# Generated manually for QR login requests. + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0003_qrlogintoken"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="QrLoginRequest", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "scan_token_hash", + models.CharField( + db_index=True, + max_length=128, + unique=True, + verbose_name="Хеш токена сканирования", + ), + ), + ( + "client_secret_hash", + models.CharField( + db_index=True, + max_length=128, + unique=True, + verbose_name="Хеш секрета клиента", + ), + ), + ( + "requester_ip_address", + models.GenericIPAddressField( + blank=True, + null=True, + verbose_name="IP запрашивающего устройства", + ), + ), + ( + "requester_user_agent", + models.TextField( + blank=True, + default="", + verbose_name="User-Agent запрашивающего устройства", + ), + ), + ( + "requester_device_name", + models.CharField( + blank=True, + default="", + max_length=255, + verbose_name="Запрашивающее устройство", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создан"), + ), + ( + "expires_at", + models.DateTimeField(db_index=True, verbose_name="Истекает"), + ), + ( + "approved_at", + models.DateTimeField( + blank=True, + db_index=True, + null=True, + verbose_name="Подтвержден", + ), + ), + ( + "denied_at", + models.DateTimeField( + blank=True, + db_index=True, + null=True, + verbose_name="Отклонен", + ), + ), + ( + "claimed_at", + models.DateTimeField( + blank=True, + db_index=True, + null=True, + verbose_name="Получен клиентом", + ), + ), + ( + "approved_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="approved_qr_login_requests", + to=settings.AUTH_USER_MODEL, + verbose_name="Подтвердил пользователь", + ), + ), + ( + "approved_by_session", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="approved_qr_login_requests", + to="api.userauthsession", + verbose_name="Подтвердила сессия", + ), + ), + ( + "denied_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="denied_qr_login_requests", + to=settings.AUTH_USER_MODEL, + verbose_name="Отклонил пользователь", + ), + ), + ( + "used_session", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="qr_login_requests", + to="api.userauthsession", + verbose_name="Созданная сессия", + ), + ), + ], + options={ + "verbose_name": "QR-запрос входа", + "verbose_name_plural": "QR-запросы входа", + "ordering": ["-created_at"], + }, + ), + migrations.AddIndex( + model_name="qrloginrequest", + index=models.Index( + fields=["expires_at", "approved_at", "denied_at"], + name="api_qrlogin_expires_48a8f2_idx", + ), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index f35a6e3f..502f271c 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -1,3 +1,3 @@ -from api.auth.models import UserAuthSession +from api.auth.models import QrLoginRequest, QrLoginToken, UserAuthSession -__all__ = ["UserAuthSession"] +__all__ = ["QrLoginRequest", "QrLoginToken", "UserAuthSession"] diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 85ecf912..8b9aa29c 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -372,6 +372,8 @@ DEFAULT_FROM_EMAIL = os.getenv( # Название сайта для email уведомлений и UI SITE_NAME = os.getenv("SITE_NAME", "CORP - HiRo") SITE_URL = os.getenv("SITE_URL", "https://corp.robotail.pro") +QR_LOGIN_TOKEN_TTL_SECONDS = int(os.getenv("QR_LOGIN_TOKEN_TTL_SECONDS", "120")) +QR_LOGIN_REQUEST_TTL_SECONDS = int(os.getenv("QR_LOGIN_REQUEST_TTL_SECONDS", "120")) # ----------------------------------------------------------------------------- # NOTIFICATIONS diff --git a/backend/notifications/senders/push.py b/backend/notifications/senders/push.py index 6e3b4244..5f4ca0cd 100644 --- a/backend/notifications/senders/push.py +++ b/backend/notifications/senders/push.py @@ -16,6 +16,14 @@ class PushNotificationSender(BaseNotificationSender): Доставляет уведомления в браузер даже когда вкладка закрыта. """ + CHAT_NOTIFICATION_VERBS = { + "chat_new_message", + "chat_mention", + "chat_reply", + "announcement_new_message", + } + DEFAULT_TITLE = "Новое уведомление" + def can_send(self, notification, user_preferences) -> bool: """Проверяет, включены ли push уведомления""" if not user_preferences.push_enabled: @@ -23,6 +31,25 @@ class PushNotificationSender(BaseNotificationSender): return False return True + def _get_actor_name(self, notification) -> str: + actor = notification.actor + if not actor: + return "" + return str(actor).strip() + + def _get_data_title(self, notification) -> str: + data = notification.data if isinstance(notification.data, dict) else {} + title = data.get("title") + if not isinstance(title, str): + return "" + return title.strip() + + def _build_title(self, notification) -> str: + if notification.verb in self.CHAT_NOTIFICATION_VERBS: + return self._get_actor_name(notification) or self.DEFAULT_TITLE + + return self._get_data_title(notification) or self.DEFAULT_TITLE + def send(self, notification, **kwargs) -> bool: """ Отправляет Web Push уведомление. @@ -44,11 +71,8 @@ class PushNotificationSender(BaseNotificationSender): self.log_skip(notification, "no active push devices") return False - # Формируем данные для push - actor_str = ( - str(notification.actor) if notification.actor else "Система" - ) - title = f"{actor_str} {notification.verb}" + # Формируем данные для push без технических verb в видимом тексте. + title = self._build_title(notification) # Ограничиваем длину body. # Web Push имеет лимит ~4KB на весь payload. @@ -66,6 +90,7 @@ class PushNotificationSender(BaseNotificationSender): # django-push-notifications использует другой формат # Формируем данные для Web Push API message_data = { + "title": title, "head": title, "body": body, "url": notification.action_url or "/", @@ -74,7 +99,6 @@ class PushNotificationSender(BaseNotificationSender): "data": { "url": notification.action_url or "/", "notification_id": notification.id, - "verb": notification.verb, }, } diff --git a/backend/tests/api/auth/test_auth_and_registration.py b/backend/tests/api/auth/test_auth_and_registration.py index 5258ffd5..fab80a39 100644 --- a/backend/tests/api/auth/test_auth_and_registration.py +++ b/backend/tests/api/auth/test_auth_and_registration.py @@ -8,7 +8,7 @@ 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 api.auth.models import QrLoginRequest, QrLoginToken, UserAuthSession from rest_framework import status from rest_framework.test import APIClient from rest_framework_simplejwt.tokens import AccessToken, RefreshToken @@ -358,6 +358,16 @@ def test_login_wrong_password(api): ("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:qr-login", "/api/auth/qr-login/"), + ("api:v1:qr-login", "/api/v1/auth/qr-login/"), + ("api:qr-login-exchange", "/api/auth/qr-login/exchange/"), + ("api:v1:qr-login-exchange", "/api/v1/auth/qr-login/exchange/"), + ("api:qr-login-request", "/api/auth/qr-login/requests/"), + ("api:v1:qr-login-request", "/api/v1/auth/qr-login/requests/"), + ("api:qr-login-request-status", "/api/auth/qr-login/requests/status/"), + ("api:v1:qr-login-request-status", "/api/v1/auth/qr-login/requests/status/"), + ("api:qr-login-request-cancel", "/api/auth/qr-login/requests/cancel/"), + ("api:v1:qr-login-request-cancel", "/api/v1/auth/qr-login/requests/cancel/"), ("api:password-reset", "/api/auth/password-reset/"), ("api:v1:password-reset", "/api/v1/auth/password-reset/"), ("api:password-reset-confirm", "/api/auth/password-reset/confirm/"), @@ -374,6 +384,35 @@ def test_auth_routes_are_mirrored(route_name, path): assert reverse(route_name) == path +def test_qr_login_request_action_routes_are_mirrored(): + scan_token = "scan-token" + + assert ( + reverse("api:qr-login-request-detail", kwargs={"scan_token": scan_token}) + == f"/api/auth/qr-login/requests/{scan_token}/" + ) + assert ( + reverse("api:v1:qr-login-request-detail", kwargs={"scan_token": scan_token}) + == f"/api/v1/auth/qr-login/requests/{scan_token}/" + ) + assert ( + reverse("api:qr-login-request-approve", kwargs={"scan_token": scan_token}) + == f"/api/auth/qr-login/requests/{scan_token}/approve/" + ) + assert ( + reverse("api:v1:qr-login-request-approve", kwargs={"scan_token": scan_token}) + == f"/api/v1/auth/qr-login/requests/{scan_token}/approve/" + ) + assert ( + reverse("api:qr-login-request-deny", kwargs={"scan_token": scan_token}) + == f"/api/auth/qr-login/requests/{scan_token}/deny/" + ) + assert ( + reverse("api:v1:qr-login-request-deny", kwargs={"scan_token": scan_token}) + == f"/api/v1/auth/qr-login/requests/{scan_token}/deny/" + ) + + 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() @@ -393,6 +432,218 @@ def test_login_creates_auth_session_and_embeds_session_id(api): assert session.refresh_token_hash +def test_qr_login_creates_one_time_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) + + login = jwt_obtain(api, email="ivan@example.com") + assert login.status_code == status.HTTP_200_OK + access = login.json()["access"] + + created = api.post(reverse("api:qr-login"), **auth_headers(access)) + assert created.status_code == status.HTTP_200_OK + body = created.json() + assert body["token"] + assert body["expires_at"] + + qr_token = QrLoginToken.objects.get() + assert qr_token.user.email == "ivan@example.com" + assert qr_token.token_hash + assert qr_token.token_hash != body["token"] + assert qr_token.used_at is None + + exchanged = api.post( + reverse("api:qr-login-exchange"), + {"token": body["token"]}, + format="json", + ) + assert exchanged.status_code == status.HTTP_200_OK + exchanged_body = exchanged.json() + assert {"access", "refresh"} <= set(exchanged_body.keys()) + + qr_token.refresh_from_db() + assert qr_token.used_at is not None + assert qr_token.used_session is not None + + new_access = exchanged_body["access"] + profile = api.get(reverse("api:v1:employees-me"), **auth_headers(new_access)) + assert profile.status_code == status.HTTP_200_OK + + reused = api.post( + reverse("api:v1:qr-login-exchange"), + {"token": body["token"]}, + format="json", + ) + assert reused.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_qr_login_rejects_expired_token(api, settings): + settings.QR_LOGIN_TOKEN_TTL_SECONDS = 30 + 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") + access = login.json()["access"] + created = api.post(reverse("api:v1:qr-login"), **auth_headers(access)) + assert created.status_code == status.HTTP_200_OK + + QrLoginToken.objects.update(expires_at=timezone.now() - timedelta(seconds=1)) + + exchanged = api.post( + reverse("api:qr-login-exchange"), + {"token": created.json()["token"]}, + format="json", + ) + assert exchanged.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_qr_login_request_approval_creates_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) + + login = jwt_obtain(api, email="ivan@example.com") + assert login.status_code == status.HTTP_200_OK + access = login.json()["access"] + + created = api.post( + reverse("api:qr-login-request"), + format="json", + HTTP_USER_AGENT="Mozilla/5.0 (Windows NT 10.0) Chrome/124.0", + REMOTE_ADDR="10.10.0.25", + ) + assert created.status_code == status.HTTP_200_OK + body = created.json() + assert body["scan_token"] + assert body["client_secret"] + assert body["expires_at"] + assert body["device_name"] == "Google Chrome on Windows" + assert body["ip_address"] == "10.10.0.25" + + qr_request = QrLoginRequest.objects.get() + assert qr_request.status == "pending" + assert qr_request.scan_token_hash != body["scan_token"] + assert qr_request.client_secret_hash != body["client_secret"] + + detail = api.get( + reverse( + "api:qr-login-request-detail", + kwargs={"scan_token": body["scan_token"]}, + ), + **auth_headers(access), + ) + assert detail.status_code == status.HTTP_200_OK + detail_body = detail.json() + assert detail_body["status"] == "pending" + assert detail_body["device_name"] == "Google Chrome on Windows" + + approved = api.post( + reverse( + "api:qr-login-request-approve", + kwargs={"scan_token": body["scan_token"]}, + ), + **auth_headers(access), + ) + assert approved.status_code == status.HTTP_200_OK + assert approved.json()["status"] == "approved" + + polled = api.post( + reverse("api:v1:qr-login-request-status"), + {"client_secret": body["client_secret"]}, + format="json", + HTTP_USER_AGENT="Mozilla/5.0 (Macintosh) Chrome/124.0", + REMOTE_ADDR="10.10.0.26", + ) + assert polled.status_code == status.HTTP_200_OK + polled_body = polled.json() + assert polled_body["status"] == "approved" + assert {"access", "refresh"} <= set(polled_body.keys()) + + qr_request.refresh_from_db() + assert qr_request.status == "claimed" + assert qr_request.used_session is not None + assert qr_request.used_session.user.email == "ivan@example.com" + + profile = api.get( + reverse("api:v1:employees-me"), + **auth_headers(polled_body["access"]), + ) + assert profile.status_code == status.HTTP_200_OK + + reused = api.post( + reverse("api:qr-login-request-status"), + {"client_secret": body["client_secret"]}, + format="json", + ) + assert reused.status_code == status.HTTP_200_OK + assert reused.json() == {"status": "claimed"} + + +def test_qr_login_request_can_be_denied(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") + access = login.json()["access"] + + created = api.post(reverse("api:qr-login-request"), format="json") + assert created.status_code == status.HTTP_200_OK + body = created.json() + + denied = api.post( + reverse( + "api:qr-login-request-deny", + kwargs={"scan_token": body["scan_token"]}, + ), + **auth_headers(access), + ) + assert denied.status_code == status.HTTP_200_OK + assert denied.json()["status"] == "denied" + + polled = api.post( + reverse("api:qr-login-request-status"), + {"client_secret": body["client_secret"]}, + format="json", + ) + assert polled.status_code == status.HTTP_200_OK + assert polled.json() == {"status": "denied"} + + +def test_qr_login_request_rejects_expired_request(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") + access = login.json()["access"] + + created = api.post(reverse("api:qr-login-request"), format="json") + assert created.status_code == status.HTTP_200_OK + body = created.json() + + QrLoginRequest.objects.update(expires_at=timezone.now() - timedelta(seconds=1)) + + approved = api.post( + reverse( + "api:qr-login-request-approve", + kwargs={"scan_token": body["scan_token"]}, + ), + **auth_headers(access), + ) + assert approved.status_code == status.HTTP_401_UNAUTHORIZED + + polled = api.post( + reverse("api:qr-login-request-status"), + {"client_secret": body["client_secret"]}, + format="json", + ) + assert polled.status_code == status.HTTP_200_OK + assert polled.json() == {"status": "expired"} + + 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() diff --git a/backend/tests/api/v1/notifications/test_push_sender.py b/backend/tests/api/v1/notifications/test_push_sender.py new file mode 100644 index 00000000..681a7090 --- /dev/null +++ b/backend/tests/api/v1/notifications/test_push_sender.py @@ -0,0 +1,142 @@ +import json +from unittest.mock import patch + +import pytest +from django.contrib.contenttypes.models import ContentType +from push_notifications.models import WebPushDevice + +from notifications.models import Notification, UserChannelPreferences +from notifications.senders.push import PushNotificationSender + + +pytestmark = pytest.mark.django_db + + +def _make_push_device(user): + return WebPushDevice.objects.create( + user=user, + registration_id="https://push.example.test/endpoint", + p256dh="test-p256dh", + auth="test-auth", + browser="CHROME", + active=True, + ) + + +def _disable_auto_delivery(user): + UserChannelPreferences.objects.create( + user=user, + web_enabled=False, + email_enabled=False, + push_enabled=False, + ) + + +def _make_notification(user, **kwargs): + _disable_auto_delivery(user) + return Notification.objects.create(recipient=user, **kwargs) + + +def _send_and_load_payload(notification): + sender = PushNotificationSender() + with patch.object(WebPushDevice, "send_message", autospec=True) as send_message: + result = sender.send(notification) + + assert result is True + send_message.assert_called_once() + return json.loads(send_message.call_args.args[1]) + + +class TestPushNotificationSenderPayload: + def test_chat_push_uses_actor_name_without_technical_verb( + self, user_factory + ): + recipient = user_factory(email="recipient@example.com") + actor = user_factory( + email="actor@example.com", + first_name="Олеся", + last_name="Рубцова", + ) + _make_push_device(recipient) + + notification = _make_notification( + recipient, + actor_content_type=ContentType.objects.get_for_model(actor), + actor_object_id=actor.pk, + verb="chat_new_message", + description="Полинина", + action_url="/messages/123", + data={ + "title": "Новое сообщение от Рубцова Олеся", + "chat_id": 123, + "message_id": 456, + }, + ) + + payload = _send_and_load_payload(notification) + + assert payload["title"] == "Рубцова Олеся" + assert payload["head"] == "Рубцова Олеся" + assert payload["body"] == "Полинина" + assert payload["url"] == "/messages/123" + assert payload["data"] == { + "url": "/messages/123", + "notification_id": notification.id, + } + assert "chat_new_message" not in json.dumps(payload) + assert "verb" not in payload["data"] + + def test_non_chat_push_uses_notification_data_title(self, user_factory): + recipient = user_factory(email="request-recipient@example.com") + _make_push_device(recipient) + notification = _make_notification( + recipient, + verb="request_approved", + description="Ваша заявка одобрена", + action_url="/requests?request=10", + data={"title": "Заявка одобрена"}, + ) + + payload = _send_and_load_payload(notification) + + assert payload["title"] == "Заявка одобрена" + assert payload["head"] == "Заявка одобрена" + assert "request_approved" not in json.dumps(payload) + + def test_push_title_falls_back_without_actor_or_data_title( + self, user_factory + ): + recipient = user_factory(email="fallback-recipient@example.com") + _make_push_device(recipient) + notification = _make_notification( + recipient, + verb="system_announcement", + description="Системное уведомление", + action_url="/", + data={}, + ) + + payload = _send_and_load_payload(notification) + + assert payload["title"] == "Новое уведомление" + assert payload["head"] == "Новое уведомление" + assert "system_announcement" not in json.dumps(payload) + + def test_push_body_is_still_truncated_to_300_characters( + self, user_factory + ): + recipient = user_factory(email="long-body-recipient@example.com") + _make_push_device(recipient) + long_body = "а" * 350 + notification = _make_notification( + recipient, + verb="document_ready", + description=long_body, + action_url="/documents", + data={"title": "Документ на ознакомление"}, + ) + + payload = _send_and_load_payload(notification) + + assert len(payload["body"]) == 300 + assert payload["body"] == ("а" * 297) + "..." diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 1fcaede8..d2ddad4d 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -2,21 +2,136 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { Eye, EyeOff, KeyRound, UserRound } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import QRCode from "qrcode"; +import { + Eye, + EyeOff, + KeyRound, + Loader2, + QrCode, + RefreshCw, + UserRound, +} from "lucide-react"; import { AuthLegalNotice } from "@/components/auth/AuthLegalNotice"; import { useUser } from "@/contexts/UserContext"; import { apiClient } from "@/lib/api"; +import type { QrLoginRequestStatus } from "@/types/api"; + +type LoginMode = "password" | "qr"; + +type QrLoginViewState = + | "idle" + | "creating" + | "pending" + | "approved" + | "denied" + | "expired" + | "claimed" + | "error"; export default function Login() { const router = useRouter(); const { refreshUser } = useUser(); + const pollTimerRef = useRef | null>(null); + const qrCompletedRef = useRef(false); + const [mode, setMode] = useState("password"); const [login, setLogin] = useState(""); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const [qrState, setQrState] = useState("idle"); + const [qrError, setQrError] = useState(""); + const [qrRequest, setQrRequest] = useState<{ + clientSecret: string; + qrDataUrl: string; + expiresAt: string; + deviceName: string; + ipAddress?: string | null; + } | null>(null); + + const clearPollTimer = useCallback(() => { + if (pollTimerRef.current) { + clearTimeout(pollTimerRef.current); + pollTimerRef.current = null; + } + }, []); + + const startQrLogin = useCallback(async () => { + clearPollTimer(); + qrCompletedRef.current = false; + const previousClientSecret = qrRequest?.clientSecret; + setQrRequest(null); + setQrError(""); + setQrState("creating"); + + try { + if (previousClientSecret) { + await apiClient.cancelQrLoginRequest(previousClientSecret).catch((err) => { + console.warn("Не удалось отменить предыдущий QR-запрос:", err); + }); + } + + const request = await apiClient.createQrLoginRequest(); + const scanUrl = `${window.location.origin}/login/qr/confirm?token=${encodeURIComponent(request.scan_token)}`; + const qrDataUrl = await QRCode.toDataURL(scanUrl, { + errorCorrectionLevel: "M", + margin: 1, + width: 240, + color: { + dark: "#111827", + light: "#ffffff", + }, + }); + + setQrRequest({ + clientSecret: request.client_secret, + qrDataUrl, + expiresAt: request.expires_at, + deviceName: request.device_name, + ipAddress: request.ip_address, + }); + setQrState("pending"); + } catch (err) { + console.error("Ошибка создания QR-входа:", err); + setQrError("Не удалось создать QR-код для входа"); + setQrState("error"); + } + }, [clearPollTimer, qrRequest?.clientSecret]); + + const handleQrStatus = useCallback( + async (status: QrLoginRequestStatus) => { + clearPollTimer(); + setQrState(status); + if (status === "approved") { + qrCompletedRef.current = true; + setQrError(""); + await refreshUser().catch((err) => { + console.warn("Не удалось обновить пользователя после QR-входа:", err); + }); + window.location.replace("/"); + return; + } + if (status === "claimed" && apiClient.getToken()) { + qrCompletedRef.current = true; + window.location.replace("/"); + return; + } + if (status === "denied") { + qrCompletedRef.current = true; + setQrError("Вход по QR отменен на авторизованном устройстве"); + } else if (status === "expired") { + qrCompletedRef.current = true; + setQrError("QR-код устарел"); + } else if (status === "claimed") { + qrCompletedRef.current = true; + setQrError("Этот QR-код уже был использован"); + } + }, + [clearPollTimer, refreshUser], + ); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -39,6 +154,70 @@ export default function Login() { } }; + const handleCancelQrLogin = async () => { + clearPollTimer(); + qrCompletedRef.current = true; + const clientSecret = qrRequest?.clientSecret; + setMode("password"); + setQrRequest(null); + setQrState("idle"); + setQrError(""); + if (!clientSecret) return; + + try { + await apiClient.cancelQrLoginRequest(clientSecret); + } catch (err) { + console.warn("Не удалось отменить QR-запрос:", err); + } + }; + + useEffect(() => { + if (mode === "qr" && !qrRequest && qrState === "idle") { + void startQrLogin(); + } + }, [mode, qrRequest, qrState, startQrLogin]); + + useEffect(() => { + if (mode !== "qr" || qrState !== "pending" || !qrRequest) return; + + let cancelled = false; + + const poll = async () => { + if (qrCompletedRef.current) return; + + try { + const response = await apiClient.pollQrLoginRequest(qrRequest.clientSecret); + if (cancelled || qrCompletedRef.current) return; + if (response.status === "pending") { + pollTimerRef.current = setTimeout(poll, 1800); + return; + } + qrCompletedRef.current = true; + await handleQrStatus(response.status); + } catch (err) { + if (cancelled || qrCompletedRef.current) return; + console.error("Ошибка проверки QR-входа:", err); + setQrError("Не удалось проверить статус QR-входа"); + setQrState("error"); + } + }; + + pollTimerRef.current = setTimeout(poll, 900); + + return () => { + cancelled = true; + clearPollTimer(); + }; + }, [clearPollTimer, handleQrStatus, mode, qrRequest, qrState]); + + const qrExpiresAt = qrRequest + ? new Intl.DateTimeFormat("ru-RU", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).format(new Date(qrRequest.expiresAt)) + : ""; + return (
@@ -56,80 +235,171 @@ export default function Login() {
) : null} -
-
- -
- - - - setLogin(e.target.value)} - required - className="app-input w-full rounded-lg py-3 pl-11 pr-4 text-sm" - placeholder="example@mail.com" - /> -
-
+
+ + +
-
- -
- - - - setPassword(e.target.value)} - required - className="app-input w-full rounded-lg py-3 pl-11 pr-12 text-sm" - placeholder="••••••••" - /> + {mode === "password" ? ( + +
+ +
+ + + + setLogin(e.target.value)} + required + className="app-input w-full rounded-lg py-3 pl-11 pr-4 text-sm" + placeholder="example@mail.com" + /> +
+
+ +
+ +
+ + + + setPassword(e.target.value)} + required + className="app-input w-full rounded-lg py-3 pl-11 pr-12 text-sm" + placeholder="••••••••" + /> + +
+
+ +
+ + Забыли пароль? + +
+ + + + + Создать аккаунт + + + ) : ( +
+
+ {qrState === "creating" ? ( + + ) : qrRequest ? ( + <> +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + QR для входа +
+

+ {qrState === "pending" + ? "Ожидаем подтверждение на авторизованном устройстве" + : qrError || "QR-вход недоступен"} +

+
+ + {qrRequest.deviceName} + + {qrRequest.ipAddress ? ( + + IP: {qrRequest.ipAddress} + + ) : null} + + До {qrExpiresAt} + +
+ + ) : ( +

+ {qrError || "QR-код не создан"} +

+ )} +
+ + {qrError ? ( +
+ {qrError} +
+ ) : null} + +
+
- -
- - Забыли пароль? - -
- - - - - Создать аккаунт - - + )}
diff --git a/frontend/src/app/login/qr/confirm/page.tsx b/frontend/src/app/login/qr/confirm/page.tsx new file mode 100644 index 00000000..497f610e --- /dev/null +++ b/frontend/src/app/login/qr/confirm/page.tsx @@ -0,0 +1,281 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + ArrowLeft, + Ban, + CheckCircle2, + Clock3, + Loader2, + LogIn, + MonitorSmartphone, + ShieldCheck, + XCircle, +} from "lucide-react"; + +import { useUser } from "@/contexts/UserContext"; +import { apiClient } from "@/lib/api"; +import type { QrLoginRequestDetailResult, QrLoginRequestStatus } from "@/types/api"; + +type ConfirmState = + | "loading" + | "ready" + | "approving" + | "denying" + | "approved" + | "denied" + | "expired" + | "claimed" + | "unauthorized" + | "error"; + +const closedStateText: Record, string> = { + approved: "Запрос подтвержден. Новое устройство завершает вход.", + denied: "Запрос входа отменен.", + expired: "QR-код устарел.", + claimed: "Этот QR-код уже был использован.", +}; + +function getClosedStateText(status: QrLoginRequestStatus): string { + if (status === "approved" || status === "denied" || status === "expired" || status === "claimed") { + return closedStateText[status]; + } + return "Запрос обработан."; +} + +function getConfirmStateFromStatus(status: QrLoginRequestStatus): ConfirmState { + return status === "pending" ? "ready" : status; +} + +export default function QrLoginConfirmPage() { + const { user, loading } = useUser(); + const [scanToken, setScanToken] = useState(""); + const [state, setState] = useState("loading"); + const [message, setMessage] = useState(""); + const [requestDetails, setRequestDetails] = + useState(null); + + const currentUserName = useMemo(() => { + if (!user) return ""; + return [user.last_name, user.first_name, user.patronymic] + .filter(Boolean) + .join(" ") || user.email; + }, [user]); + + const loadRequest = useCallback(async (token: string) => { + setState("loading"); + setMessage(""); + + try { + const details = await apiClient.getQrLoginRequest(token); + setRequestDetails(details); + + if (details.status === "pending") { + setState("ready"); + return; + } + + setState(details.status); + setMessage(getClosedStateText(details.status)); + } catch (error) { + console.error("Ошибка загрузки QR-запроса:", error); + setState("error"); + setMessage("QR-запрос не найден или уже недоступен."); + } + }, []); + + const loadInitialRequest = useCallback(async () => { + const token = new URLSearchParams(window.location.search).get("token")?.trim(); + if (!token) { + setState("error"); + setMessage("В ссылке нет QR-токена."); + return; + } + + setScanToken(token); + + if (!user) { + setState("unauthorized"); + setMessage("Откройте эту ссылку на устройстве, где уже выполнен вход."); + return; + } + + await loadRequest(token); + }, [loadRequest, user]); + + useEffect(() => { + if (loading) return; + + const timerId = window.setTimeout(() => { + void loadInitialRequest(); + }, 0); + + return () => window.clearTimeout(timerId); + }, [loadInitialRequest, loading]); + + const handleApprove = async () => { + if (!scanToken || state !== "ready") return; + setState("approving"); + setMessage(""); + + try { + const result = await apiClient.approveQrLoginRequest(scanToken); + setState(getConfirmStateFromStatus(result.status)); + setMessage(getClosedStateText(result.status)); + } catch (error) { + console.error("Ошибка подтверждения QR-входа:", error); + setState("error"); + setMessage("Не удалось подтвердить вход. Запрос мог устареть."); + } + }; + + const handleDeny = async () => { + if (!scanToken || state !== "ready") return; + setState("denying"); + setMessage(""); + + try { + const result = await apiClient.denyQrLoginRequest(scanToken); + setState(getConfirmStateFromStatus(result.status)); + setMessage(getClosedStateText(result.status)); + } catch (error) { + console.error("Ошибка отмены QR-входа:", error); + setState("error"); + setMessage("Не удалось отменить вход. Запрос мог устареть."); + } + }; + + const expiresAt = requestDetails + ? new Intl.DateTimeFormat("ru-RU", { + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).format(new Date(requestDetails.expires_at)) + : ""; + + const isBusy = state === "loading" || state === "approving" || state === "denying"; + const statusIcon = + state === "approved" ? ( + + ) : state === "denied" || state === "expired" || state === "claimed" || state === "error" ? ( + + ) : isBusy ? ( + + ) : ( + + ); + + return ( +
+
+
+
+ {statusIcon} +
+ +

+ Подтверждение входа +

+

+ Проверьте устройство и аккаунт перед подтверждением. +

+ + {requestDetails ? ( +
+
+ + + +
+

+ {requestDetails.device_name} +

+

+ {requestDetails.ip_address ? `IP: ${requestDetails.ip_address}` : "IP не определен"} +

+
+
+ +
+ + + +
+

+ Действует до {expiresAt} +

+

+ Статус: {requestDetails.status} +

+
+
+
+ ) : null} + + {user ? ( +
+

Вход будет подтвержден аккаунтом

+

+ {currentUserName} +

+

{user.email}

+
+ ) : null} + + {message ? ( +
+ {message} +
+ ) : null} + + {state === "unauthorized" ? ( + + Войти на этом устройстве + + ) : null} + + {state === "ready" || state === "approving" || state === "denying" ? ( +
+ + +
+ ) : null} + + + + Назад + +
+
+
+ ); +} diff --git a/frontend/src/app/login/qr/page.tsx b/frontend/src/app/login/qr/page.tsx new file mode 100644 index 00000000..280881f3 --- /dev/null +++ b/frontend/src/app/login/qr/page.tsx @@ -0,0 +1,77 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; +import { Loader2, QrCode } from "lucide-react"; + +import { useUser } from "@/contexts/UserContext"; +import { apiClient } from "@/lib/api"; + +type QrLoginState = "loading" | "success" | "error"; + +export default function QrLoginPage() { + const { refreshUser } = useUser(); + const startedRef = useRef(false); + const [state, setState] = useState("loading"); + const [message, setMessage] = useState("Проверяем одноразовую ссылку..."); + + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + + async function exchangeToken() { + const token = new URLSearchParams(window.location.search).get("token")?.trim(); + if (!token) { + setState("error"); + setMessage("В ссылке нет QR-токена."); + return; + } + + try { + await apiClient.loginWithQrToken(token); + setState("success"); + setMessage("Вход выполнен."); + await refreshUser().catch((error) => { + console.warn("Не удалось обновить пользователя после QR-входа:", error); + }); + window.location.replace("/"); + } catch (error) { + console.error("QR login failed", error); + apiClient.clearToken(); + setState("error"); + setMessage("QR-ссылка недействительна, устарела или уже использована."); + } + } + + void exchangeToken(); + }, [refreshUser]); + + return ( +
+
+
+
+ {state === "loading" ? ( + + ) : ( + + )} +
+

+ Вход по QR +

+

{message}

+ + {state === "error" ? ( + + Войти с паролем + + ) : null} +
+
+
+ ); +} diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 8166cdc8..abee414d 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -91,6 +91,7 @@ function MessageDialogPageContent() { const messageInputRef = useRef(null); const searchInputRef = useRef(null); const highlightTimerRef = useRef(null); + const pendingRetryFilesRef = useRef>(new Map()); /* ────────────────────────────────────────────────────── * HOOK: Chat messages (state, pagination, pending) @@ -155,10 +156,19 @@ function MessageDialogPageContent() { * stays visible even after mark-read flushes (until user scrolls down). */ const displayedUnreadCount = Math.max(cm.chat?.unread_count ?? 0, cm.newMessagesBelowCount); + const { + allowOneOlderProbe: cmAllowOneOlderProbe, + hasMoreOlder: cmHasMoreOlder, + jumpToMessage: cmJumpToMessage, + loadOlderMessages: cmLoadOlderMessages, + loadingOlder: cmLoadingOlder, + messagesLoading: cmMessagesLoading, + } = cm; /* ── UI state ── */ const [messageText, setMessageText] = useState(""); const [sending, setSending] = useState(false); + const [retryingLocalIds, setRetryingLocalIds] = useState>(() => new Set()); const [editingMessageId, setEditingMessageId] = useState(null); const [replyTo, setReplyTo] = useState(null); const [expandedReplyActionForId, setExpandedReplyActionForId] = useState(null); @@ -448,11 +458,11 @@ function MessageDialogPageContent() { }, [cm, markRead, scroll]); useEffect(() => { - if (cm.messagesLoading || cm.loadingOlder || displayMessages.length === 0) { + if (cmMessagesLoading || cmLoadingOlder || displayMessages.length === 0) { return; } - if (!cm.hasMoreOlder && !cm.allowOneOlderProbe) { + if (!cmHasMoreOlder && !cmAllowOneOlderProbe) { return; } @@ -471,7 +481,7 @@ function MessageDialogPageContent() { const hasScrollableOverflow = viewport.scrollHeight > viewport.clientHeight + 8; if (!hasScrollableOverflow) { - void cm.loadOlderMessages(); + void cmLoadOlderMessages(); } }); @@ -480,11 +490,11 @@ function MessageDialogPageContent() { window.cancelAnimationFrame(frameId); }; }, [ - cm.allowOneOlderProbe, - cm.hasMoreOlder, - cm.loadingOlder, - cm.loadOlderMessages, - cm.messagesLoading, + cmAllowOneOlderProbe, + cmHasMoreOlder, + cmLoadOlderMessages, + cmLoadingOlder, + cmMessagesLoading, displayMessages.length, ]); @@ -547,6 +557,7 @@ function MessageDialogPageContent() { : `local-${Date.now()}-${Math.random().toString(16).slice(2)}`; optimisticLocalId = localId; const opt = cm.buildOptimistic(text, files, localId, replyToId); + if (files.length > 0) pendingRetryFilesRef.current.set(localId, files); cm.addPending(opt); cm.schedulePendingTimer(localId); resetComposerState(); @@ -556,6 +567,7 @@ function MessageDialogPageContent() { : await apiClient.sendMessage(chatId, text, replyToId); cm.removePendingMessage(localId); + pendingRetryFilesRef.current.delete(localId); cm.setMessages(prev => uniqueMessagesById([...prev, sent])); if (wasNearBottom) requestAnimationFrame(() => scroll.scrollToBottom(false)); @@ -582,6 +594,47 @@ function MessageDialogPageContent() { const removeAttachedFile = (index: number) => setAttachedFiles(prev => prev.filter((_, i) => i !== index)); const handlePickFiles = () => { if (!editingMessageId) fileInputRef.current?.click(); }; + const canRetryMessage = useCallback((message: Message): boolean => { + if (message.send_state !== "failed" || !message.local_id) return false; + const hasAttachments = Boolean(message.attachments?.length); + return !hasAttachments || pendingRetryFilesRef.current.has(message.local_id); + }, []); + + const handleRetryMessage = useCallback(async (message: Message) => { + const localId = message.local_id; + if (!chatId || !localId || retryingLocalIds.has(localId) || !canRetryMessage(message)) return; + + const text = (message.content || "").trim(); + const files = pendingRetryFilesRef.current.get(localId) || []; + const replyToId = getReplyToId(message) ?? undefined; + const wasNearBottom = scroll.isNearBottom(); + + setRetryingLocalIds(prev => new Set(prev).add(localId)); + cm.markPendingSending(localId); + cm.schedulePendingTimer(localId); + + try { + const sent = files.length + ? await apiClient.sendMessageWithFiles(chatId, text, files, replyToId) + : await apiClient.sendMessage(chatId, text, replyToId); + + cm.removePendingMessage(localId); + pendingRetryFilesRef.current.delete(localId); + cm.setMessages(prev => uniqueMessagesById([...prev, sent])); + + if (wasNearBottom) requestAnimationFrame(() => scroll.scrollToBottom(false)); + } catch (e) { + console.error("Ошибка переотправки сообщения:", e); + cm.markPendingFailed(localId); + } finally { + setRetryingLocalIds(prev => { + const next = new Set(prev); + next.delete(localId); + return next; + }); + } + }, [canRetryMessage, chatId, cm, retryingLocalIds, scroll]); + /* ── message actions ── */ const canManageMessage = (message: Message): boolean => { @@ -738,7 +791,7 @@ function MessageDialogPageContent() { let cancelled = false; void (async () => { - const jumped = await cm.jumpToMessage(linkedMessageId); + const jumped = await cmJumpToMessage(linkedMessageId); if (cancelled) { return; } @@ -751,7 +804,7 @@ function MessageDialogPageContent() { return () => { cancelled = true; }; - }, [clearMessageParam, cm.jumpToMessage, highlightFoundMessage, linkedMessageId]); + }, [clearMessageParam, cmJumpToMessage, highlightFoundMessage, linkedMessageId]); const runSearch = useCallback(async (query: string, options?: { append?: boolean; offset?: number }) => { const normalizedQuery = query.trim(); @@ -1114,6 +1167,9 @@ function MessageDialogPageContent() { onQuickReact={handleReact} onOpenReactionPicker={(msg) => setReactionPickerForMessageId(msg.id)} onShowAllReaders={() => setIsReadersModalOpen(true)} + onRetry={handleRetryMessage} + isRetrying={Boolean(message.local_id && retryingLocalIds.has(message.local_id))} + retryDisabled={!canRetryMessage(message)} onReply={handleReplyToMessage} onEdit={handleStartEditMessage} onDelete={handleDeleteMessage} diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index e56b02e7..0dbf0b1b 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -6,6 +6,7 @@ import { ArrowUp, Bell, Check, + Copy, Clock3, Download, Eye, @@ -15,6 +16,7 @@ import { Mail, Monitor, Moon, + QrCode, Save, Smartphone, Sun, @@ -149,6 +151,8 @@ export default function SettingsPage() { profileDirty, profileForm, pushLoading, + qrLogin, + qrLoginLoading, resolvedTheme, savingContacts, savingPreferences, @@ -166,6 +170,7 @@ export default function SettingsPage() { handleChangePassword, handleDndToggle, handleLogoutOthers, + handleCreateQrLogin, handlePushToggle, handleSessionLogout, logout, @@ -790,6 +795,60 @@ export default function SettingsPage() {
+
+
+
+ + + +
+

Вход по QR

+

+ Одноразовая ссылка для авторизации нового устройства. +

+
+
+ +
+ + {qrLogin ? ( +
+
+ QR для входа +
+
+

+ Действует до {formatSessionDateTime(qrLogin.expiresAt)}. +

+
+ +
+
+
+ ) : null} +
+
diff --git a/frontend/src/components/messages/ChatMessageItem.tsx b/frontend/src/components/messages/ChatMessageItem.tsx index abef76fa..276ba713 100644 --- a/frontend/src/components/messages/ChatMessageItem.tsx +++ b/frontend/src/components/messages/ChatMessageItem.tsx @@ -12,6 +12,7 @@ import { Image as ImageIcon, Loader2, PlayCircle, + RotateCcw, } from "lucide-react"; import { RequestAvatar } from "@/components/requests/RequestAvatar"; @@ -73,6 +74,9 @@ type ChatMessageItemProps = { onQuickReact: (message: Message, emoji: string) => void; onOpenReactionPicker: (message: Message) => void; onShowAllReaders?: (message: Message) => void; + onRetry?: (message: Message) => void; + isRetrying?: boolean; + retryDisabled?: boolean; onReply: (message: Message) => void; onEdit: (message: Message) => void; onDelete: (message: Message) => void; @@ -149,6 +153,9 @@ export default function ChatMessageItem({ onQuickReact, onOpenReactionPicker, onShowAllReaders, + onRetry, + isRetrying = false, + retryDisabled = false, onReply, onEdit, onDelete, @@ -448,6 +455,18 @@ export default function ChatMessageItem({ ) : null}
+ {isMine && sendState === "failed" && onRetry ? ( + + ) : null}

{messageTime(message)}

{isMine ? : null}
diff --git a/frontend/src/contexts/UserContext.tsx b/frontend/src/contexts/UserContext.tsx index b6d8cc49..2c6c3856 100644 --- a/frontend/src/contexts/UserContext.tsx +++ b/frontend/src/contexts/UserContext.tsx @@ -36,9 +36,10 @@ export function UserProvider({ children }: { children: ReactNode }) { // ApiClient автоматически обновит токен если он истёк const userData = await apiClient.getCurrentUser(); setUser(userData); - } catch (err: any) { + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); console.error('Ошибка загрузки пользователя:', err); - setError(err.message); + setError(message); setUser(null); // Токены уже очищены в ApiClient если refresh token тоже истёк @@ -46,7 +47,10 @@ export function UserProvider({ children }: { children: ReactNode }) { const hasToken = typeof window !== 'undefined' && localStorage.getItem('access_token'); if (!hasToken) { // Токены очищены - сессия истекла, redirect на login - if (typeof window !== 'undefined' && window.location.pathname !== '/login') { + if ( + typeof window !== 'undefined' && + !['/login', '/login/qr', '/login/qr/confirm'].includes(window.location.pathname) + ) { window.location.href = '/login'; } } diff --git a/frontend/src/hooks/useChatMessages.ts b/frontend/src/hooks/useChatMessages.ts index 97d360c1..642737fd 100644 --- a/frontend/src/hooks/useChatMessages.ts +++ b/frontend/src/hooks/useChatMessages.ts @@ -3,6 +3,12 @@ import { apiClient } from "@/lib/api"; import type { Chat, Message } from "@/types/api"; import { getMessageTimestamp, uniqueMessagesById } from "@/lib/messages/chatUtils"; import { getReplyToId } from "@/lib/messages/messageUtils"; +import { + getPendingMessageStorageIdentity, + loadStoredPendingMessages, + reconcileStoredPendingMessages, + saveStoredPendingMessages, +} from "@/lib/messages/pendingMessageStorage"; const PENDING_MESSAGE_DELAY_MS = 8000; @@ -62,6 +68,8 @@ export interface UseChatMessagesReturn { schedulePendingTimer: (localId: string) => void; /** Mark a pending message as failed */ markPendingFailed: (localId: string) => void; + /** Mark a failed pending message as sending again */ + markPendingSending: (localId: string) => void; /** Jump to a specific date (history navigation) */ jumpToDate: (dateISO: string) => Promise; /** Return to unread boundary (around last_read_message_id) */ @@ -124,6 +132,7 @@ export function useChatMessages({ const hasMoreNewerRef = useRef(false); const initialScrolledRef = useRef(false); const pendingMessagesRef = useRef([]); + const pendingStorageIdentityRef = useRef(null); const localSeqRef = useRef(0); const pendingTimersRef = useRef>(new Map()); const fallbackInFlightRef = useRef(false); @@ -135,6 +144,13 @@ export function useChatMessages({ /* keep refs in sync */ useEffect(() => { hasMoreNewerRef.current = hasMoreNewer; }, [hasMoreNewer]); useEffect(() => { pendingMessagesRef.current = pendingMessages; }, [pendingMessages]); + useEffect(() => { + if (!chatId || Number.isNaN(chatId) || !userId) return; + const identity = getPendingMessageStorageIdentity(chatId, userId); + if (pendingStorageIdentityRef.current !== identity) return; + + saveStoredPendingMessages(chatId, userId, pendingMessages); + }, [chatId, pendingMessages, userId]); /* ── pending message helpers ── */ @@ -172,6 +188,10 @@ export function useChatMessages({ setPendingMessages(prev => prev.map(m => m.local_id === localId ? { ...m, send_state: "failed" } : m)); }, [clearPendingTimer]); + const markPendingSending = useCallback((localId: string) => { + setPendingMessages(prev => prev.map(m => m.local_id === localId ? { ...m, send_state: "pending" } : m)); + }, []); + /* ── attachment signature matching ── */ const getAttachmentSig = useCallback((msg: Message) => @@ -293,6 +313,7 @@ export function useChatMessages({ useEffect(() => { if (!chatId || Number.isNaN(chatId)) { + pendingStorageIdentityRef.current = null; clearAllPendingTimers(); setMessages([]); setPendingMessages([]); setHasMoreOlder(false); setHasMoreNewer(false); setInitialAnchorId(null); setInitialAnchorIndex(null); @@ -301,9 +322,14 @@ export function useChatMessages({ resetNewMessagesBelow(); return; } - if (userLoading || !userId) return; + if (userLoading || !userId) { + pendingStorageIdentityRef.current = null; + return; + } let cancelled = false; + const storageIdentity = getPendingMessageStorageIdentity(chatId, userId); + pendingStorageIdentityRef.current = null; (async () => { try { @@ -323,8 +349,12 @@ export function useChatMessages({ if (aroundMsgs.length > 0) { clearAllPendingTimers(); const norm = uniqueMessagesById(aroundMsgs); + const restoredPending = reconcileStoredPendingMessages( + loadStoredPendingMessages(chatId, userId), + norm, + ); setMessages(norm); - setPendingMessages([]); + setPendingMessages(restoredPending); setHasMoreOlder(typeof around.has_more_before === "boolean" ? around.has_more_before : norm.length >= 50); setHasMoreNewer(Boolean(around.has_more_after)); setInitialAnchorId(around.anchor_id ?? null); @@ -344,7 +374,11 @@ export function useChatMessages({ const resp = await apiClient.getChatMessages(chatId, { limit: 50 }); if (cancelled) return; const norm = uniqueMessagesById(resp.messages || []); - setMessages(norm); setPendingMessages([]); + const restoredPending = reconcileStoredPendingMessages( + loadStoredPendingMessages(chatId, userId), + norm, + ); + setMessages(norm); setPendingMessages(restoredPending); setHasMoreOlder(Boolean(resp.has_more)); setHasMoreNewer(false); setInitialAnchorId(null); setInitialAnchorIndex(null); setAllowOneOlderProbe(false); unreadBelowIdsRef.current = new Set( @@ -356,6 +390,7 @@ export function useChatMessages({ setNewMessagesBelowCount(Math.max(chatDetails.unread_count ?? 0, unreadBelowIdsRef.current.size)); } + pendingStorageIdentityRef.current = storageIdentity; initialScrolledRef.current = false; setAnchorRetryTick(0); } catch (err: unknown) { @@ -416,7 +451,7 @@ export function useChatMessages({ const newest = messages[messages.length - 1]; try { setLoadingNewer(true); - const resp = await apiClient.getChatMessages(chatId, { limit: 10, after_id: newest.id, mark_read: false }); + const resp = await apiClient.getChatMessages(chatId, { limit: 10, after_id: newest.id, mark_read: opts?.markRead ?? false }); const newer = resp.messages || []; if (newer.length > 0) { newer.forEach(removePendingByServer); @@ -605,6 +640,7 @@ export function useChatMessages({ addPending, schedulePendingTimer, markPendingFailed, + markPendingSending, jumpToDate, returnToUnread, jumpToMessage, diff --git a/frontend/src/hooks/useSettingsPage.ts b/frontend/src/hooks/useSettingsPage.ts index d38cffc6..25a295b4 100644 --- a/frontend/src/hooks/useSettingsPage.ts +++ b/frontend/src/hooks/useSettingsPage.ts @@ -1,6 +1,7 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; +import QRCode from "qrcode"; import { toast } from "sonner"; import { useTheme } from "@/contexts/ThemeContext"; @@ -151,6 +152,12 @@ export function useSettingsPage() { const [sessionsLoading, setSessionsLoading] = useState(true); const [sessionsUnavailable, setSessionsUnavailable] = useState(false); const [sessionActionKey, setSessionActionKey] = useState(null); + const [qrLogin, setQrLogin] = useState<{ + loginUrl: string; + qrDataUrl: string; + expiresAt: string; + } | null>(null); + const [qrLoginLoading, setQrLoginLoading] = useState(false); const [passwordForm, setPasswordForm] = useState({ current_password: "", new_password: "", @@ -513,6 +520,34 @@ export function useSettingsPage() { } }; + const handleCreateQrLogin = async () => { + try { + setQrLoginLoading(true); + const response = await apiClient.createQrLogin(); + const loginUrl = `${window.location.origin}/login/qr?token=${encodeURIComponent(response.token)}`; + const qrDataUrl = await QRCode.toDataURL(loginUrl, { + errorCorrectionLevel: "M", + margin: 1, + width: 224, + color: { + dark: "#111827", + light: "#ffffff", + }, + }); + setQrLogin({ + loginUrl, + qrDataUrl, + expiresAt: response.expires_at, + }); + toast.success("QR-вход создан"); + } catch (error) { + console.error(error); + toast.error("Не удалось создать QR для входа"); + } finally { + setQrLoginLoading(false); + } + }; + const handleChangePassword = async () => { if ( !passwordForm.current_password || @@ -567,6 +602,8 @@ export function useSettingsPage() { profileDirty, profileForm, pushLoading, + qrLogin, + qrLoginLoading, resolvedTheme, savingContacts, savingPreferences, @@ -585,6 +622,7 @@ export function useSettingsPage() { handleChangePassword, handleDndToggle, handleLogoutOthers, + handleCreateQrLogin, handlePushToggle, handleSessionLogout, logout, diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts index fe06565a..3c79c990 100644 --- a/frontend/src/lib/api/auth.ts +++ b/frontend/src/lib/api/auth.ts @@ -6,6 +6,9 @@ import type { PasswordResetConfirmResult, PasswordResetRequestPayload, PasswordResetRequestResult, + QrLoginCreateResult, + QrLoginRequestActionResult, + QrLoginRequestDetailResult, SessionBulkActionResult, } from "@/types/api"; @@ -18,6 +21,24 @@ export function createAuthApi(request: RequestFn) { request(`/api/auth/sessions/${sessionId}/`, { method: "DELETE" }), logoutOtherSessions: (): Promise => request("/api/auth/sessions/logout-others/", { method: "POST" }), + createQrLogin: (): Promise => + request("/api/auth/qr-login/", { method: "POST" }), + getQrLoginRequest: ( + scanToken: string, + ): Promise => + request(`/api/auth/qr-login/requests/${encodeURIComponent(scanToken)}/`), + approveQrLoginRequest: ( + scanToken: string, + ): Promise => + request(`/api/auth/qr-login/requests/${encodeURIComponent(scanToken)}/approve/`, { + method: "POST", + }), + denyQrLoginRequest: ( + scanToken: string, + ): Promise => + request(`/api/auth/qr-login/requests/${encodeURIComponent(scanToken)}/deny/`, { + method: "POST", + }), changePassword: ( payload: ChangePasswordPayload, ): Promise => diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index cfb7c610..e62db483 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -3,17 +3,18 @@ * Domain-specific methods live in separate modules. */ +import type { + LoginResponse, + QrLoginRequestCreateResult, + QrLoginRequestStatusResult, +} from "@/types/api"; + interface LoginCredentials { email?: string; phone?: string; password: string; } -interface LoginResponse { - access: string; - refresh: string; -} - export class ApiClientBase { private tokenKey = 'access_token'; private refreshTokenKey = 'refresh_token'; @@ -167,6 +168,52 @@ export class ApiClientBase { this.setRefreshToken(data.refresh); } + async loginWithQrToken(token: string): Promise { + const response = await fetch('/api/auth/qr-login/exchange/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + if (!response.ok) throw new Error(`QR login failed: ${response.status}`); + const data: LoginResponse = await response.json(); + this.setToken(data.access); + this.setRefreshToken(data.refresh); + } + + async createQrLoginRequest(): Promise { + const response = await fetch('/api/auth/qr-login/requests/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + if (!response.ok) throw new Error(`QR request failed: ${response.status}`); + return response.json(); + } + + async pollQrLoginRequest(clientSecret: string): Promise { + const response = await fetch('/api/auth/qr-login/requests/status/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_secret: clientSecret }), + }); + if (!response.ok) throw new Error(`QR status failed: ${response.status}`); + + const data: QrLoginRequestStatusResult = await response.json(); + if (data.status === 'approved' && data.access && data.refresh) { + this.setToken(data.access); + this.setRefreshToken(data.refresh); + } + return data; + } + + async cancelQrLoginRequest(clientSecret: string): Promise { + const response = await fetch('/api/auth/qr-login/requests/cancel/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_secret: clientSecret }), + }); + if (!response.ok) throw new Error(`QR cancel failed: ${response.status}`); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any async getCurrentUser(): Promise { return this.request('/api/v1/employees/me/'); @@ -194,7 +241,7 @@ export class ApiClientBase { const formData = new FormData(); Object.keys(data).forEach((key) => { if (Array.isArray(data[key])) { - data[key].forEach((value: any) => { + data[key].forEach((value: unknown) => { if (value !== null && value !== undefined) formData.append(key, String(value)); }); } diff --git a/frontend/src/lib/messages/pendingMessageStorage.ts b/frontend/src/lib/messages/pendingMessageStorage.ts new file mode 100644 index 00000000..d0a0cfeb --- /dev/null +++ b/frontend/src/lib/messages/pendingMessageStorage.ts @@ -0,0 +1,179 @@ +import type { Message, MessageAttachment } from "@/types/api"; +import { getMessageTimestamp } from "@/lib/messages/chatUtils"; +import { getReplyToId } from "@/lib/messages/messageUtils"; + +const STORAGE_PREFIX = "eusrr_chat_pending_messages:v1"; +const STORAGE_VERSION = 1; +const MAX_STORED_MESSAGES = 50; +const PENDING_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +type StoredPendingPayload = { + version: typeof STORAGE_VERSION; + saved_at: number; + messages: Message[]; +}; + +export function getPendingMessageStorageIdentity(chatId: number, userId: number): string { + return `${userId}:${chatId}`; +} + +function getStorageKey(chatId: number, userId: number): string { + return `${STORAGE_PREFIX}:${getPendingMessageStorageIdentity(chatId, userId)}`; +} + +function canUseLocalStorage(): boolean { + return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; +} + +function sanitizeAttachment(attachment: MessageAttachment, index: number): MessageAttachment | null { + if (!attachment || typeof attachment !== "object") return null; + + const fileName = typeof attachment.file_name === "string" ? attachment.file_name : ""; + if (!fileName.trim()) return null; + + return { + id: typeof attachment.id === "number" ? attachment.id : -(Date.now() * 100 + index), + file_name: fileName, + file_type: typeof attachment.file_type === "string" ? attachment.file_type : undefined, + file_url: typeof attachment.file_url === "string" ? attachment.file_url : "", + file_size: typeof attachment.file_size === "number" ? attachment.file_size : undefined, + mime_type: typeof attachment.mime_type === "string" ? attachment.mime_type : undefined, + width: typeof attachment.width === "number" ? attachment.width : undefined, + height: typeof attachment.height === "number" ? attachment.height : undefined, + thumbnail: typeof attachment.thumbnail === "string" ? attachment.thumbnail : null, + is_local: Boolean(attachment.is_local), + }; +} + +function normalizeSendState(sendState: Message["send_state"]): Message["send_state"] { + if (sendState === "pending" || sendState === "delayed" || sendState === "failed") { + return sendState; + } + + return "failed"; +} + +function sanitizePendingMessage(message: Message): Message | null { + if (!message || typeof message !== "object") return null; + + const localId = typeof message.local_id === "string" && message.local_id.trim() ? message.local_id : null; + if (!localId) return null; + + const timestamp = getMessageTimestamp(message) || Date.now(); + const attachments = (message.attachments || []) + .map((attachment, index) => sanitizeAttachment(attachment, index)) + .filter((attachment): attachment is MessageAttachment => Boolean(attachment)); + const content = typeof message.content === "string" ? message.content : ""; + + if (!content.trim() && attachments.length === 0) return null; + + return { + id: typeof message.id === "number" && message.id < 0 ? message.id : -timestamp, + chat: typeof message.chat === "number" ? message.chat : undefined, + local_id: localId, + author_id: typeof message.author_id === "number" ? message.author_id : undefined, + author_name: typeof message.author_name === "string" ? message.author_name : undefined, + avatar: typeof message.avatar === "string" ? message.avatar : undefined, + content, + is_read: false, + send_state: normalizeSendState(message.send_state), + is_optimistic: true, + created_at: typeof message.created_at === "string" ? message.created_at : new Date(timestamp).toISOString(), + created_ts: timestamp, + has_attachments: attachments.length > 0, + attachments, + reply_to_id: getReplyToId(message) ?? undefined, + }; +} + +function pruneStoredMessages(messages: Message[], now = Date.now()): Message[] { + return messages + .map(sanitizePendingMessage) + .filter((message): message is Message => Boolean(message)) + .filter((message) => now - getMessageTimestamp(message) <= PENDING_TTL_MS) + .slice(-MAX_STORED_MESSAGES); +} + +export function loadStoredPendingMessages(chatId: number, userId: number): Message[] { + if (!canUseLocalStorage()) return []; + + const key = getStorageKey(chatId, userId); + + try { + const raw = window.localStorage.getItem(key); + if (!raw) return []; + + const payload = JSON.parse(raw) as Partial; + if (payload.version !== STORAGE_VERSION || !Array.isArray(payload.messages)) { + window.localStorage.removeItem(key); + return []; + } + + const messages = pruneStoredMessages(payload.messages).map((message) => ({ + ...message, + send_state: "failed" as const, + })); + + if (messages.length === 0) { + window.localStorage.removeItem(key); + } + + return messages; + } catch { + window.localStorage.removeItem(key); + return []; + } +} + +export function saveStoredPendingMessages(chatId: number, userId: number, messages: Message[]): void { + if (!canUseLocalStorage()) return; + + const key = getStorageKey(chatId, userId); + const storedMessages = pruneStoredMessages(messages); + + try { + if (storedMessages.length === 0) { + window.localStorage.removeItem(key); + return; + } + + const payload: StoredPendingPayload = { + version: STORAGE_VERSION, + saved_at: Date.now(), + messages: storedMessages, + }; + + window.localStorage.setItem(key, JSON.stringify(payload)); + } catch { + // Quota/security errors should not break the chat UI. + } +} + +function getAttachmentSignature(message: Message): string { + return (message.attachments || []) + .map((attachment) => `${attachment.file_name}|${attachment.file_size ?? 0}|${attachment.mime_type ?? ""}`) + .sort() + .join("||"); +} + +function isConfirmedMatch(pending: Message, confirmed: Message): boolean { + if (confirmed.id <= 0 || confirmed.is_optimistic) return false; + if (pending.author_id && confirmed.author_id && pending.author_id !== confirmed.author_id) return false; + if ((pending.content || "") !== (confirmed.content || "")) return false; + if ((getReplyToId(pending) ?? null) !== (getReplyToId(confirmed) ?? null)) return false; + if (getAttachmentSignature(pending) !== getAttachmentSignature(confirmed)) return false; + + const pendingTimestamp = getMessageTimestamp(pending); + const confirmedTimestamp = getMessageTimestamp(confirmed); + if (!pendingTimestamp || !confirmedTimestamp) return true; + + return Math.abs(pendingTimestamp - confirmedTimestamp) <= 10 * 60 * 1000; +} + +export function reconcileStoredPendingMessages(pendingMessages: Message[], serverMessages: Message[]): Message[] { + if (pendingMessages.length === 0 || serverMessages.length === 0) return pendingMessages; + + return pendingMessages.filter( + (pending) => !serverMessages.some((serverMessage) => isConfirmedMatch(pending, serverMessage)), + ); +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 0f60eb08..d00725c9 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -97,6 +97,49 @@ export interface SessionBulkActionResult { revoked: number; } +export interface QrLoginCreateResult { + token: string; + expires_at: string; +} + +export type QrLoginRequestStatus = + | "pending" + | "approved" + | "denied" + | "expired" + | "claimed"; + +export interface QrLoginRequestCreateResult { + scan_token: string; + client_secret: string; + expires_at: string; + device_name: string; + ip_address?: string | null; +} + +export interface QrLoginRequestStatusResult { + status: QrLoginRequestStatus; + access?: string; + refresh?: string; +} + +export interface QrLoginRequestDetailResult { + status: QrLoginRequestStatus; + device_name: string; + ip_address?: string | null; + created_at: string; + expires_at: string; +} + +export interface QrLoginRequestActionResult { + status: QrLoginRequestStatus; +} + +export interface LoginResponse { + access: string; + refresh: string; +} + export interface ChangePasswordPayload { current_password: string; new_password: string;