diff --git a/backend/api/auth/serializers.py b/backend/api/auth/serializers.py index 3377cb5..4eda53e 100644 --- a/backend/api/auth/serializers.py +++ b/backend/api/auth/serializers.py @@ -87,4 +87,28 @@ class LogoutResponseSerializer(serializers.Serializer): message = serializers.CharField( help_text="Сообщение о успешном выходе", read_only=True + ) + + +class RefreshTokenRequestSerializer(serializers.Serializer): + """Сериализатор для запроса обновления токена""" + refresh = serializers.CharField( + help_text="Refresh token для обновления", + required=True + ) + + +class RefreshTokenResponseSerializer(serializers.Serializer): + """Сериализатор для ответа с обновленными токенами""" + access = serializers.CharField( + help_text="Новый JWT access token", + read_only=True + ) + refresh = serializers.CharField( + help_text="Новый JWT refresh token", + read_only=True + ) + expires_at = serializers.FloatField( + help_text="Timestamp времени истечения access token", + read_only=True ) \ No newline at end of file diff --git a/backend/api/auth/urls.py b/backend/api/auth/urls.py index 8862821..0f80943 100644 --- a/backend/api/auth/urls.py +++ b/backend/api/auth/urls.py @@ -1,5 +1,5 @@ from django.urls import path, include -from .views import LoginViewSet, LogoutView +from .views import LoginViewSet, LogoutView, RefreshTokenView from rest_framework.routers import DefaultRouter from drf_spectacular.views import ( SpectacularAPIView, @@ -13,6 +13,7 @@ router.register(r'', LoginViewSet, basename='auth') urlpatterns = [ path('', include(router.urls)), path('logout/', LogoutView.as_view(), name='auth-logout'), + path('refresh/', RefreshTokenView.as_view(), name='token-refresh'), path('schema/', SpectacularAPIView.as_view(), name='schema'), path( 'docs/', diff --git a/backend/api/auth/views.py b/backend/api/auth/views.py index 24cfd49..d58e6d2 100644 --- a/backend/api/auth/views.py +++ b/backend/api/auth/views.py @@ -1,4 +1,7 @@ from rest_framework import status +from datetime import datetime +import traceback +from django.conf import settings from rest_framework.decorators import action from rest_framework.response import Response from rest_framework_simplejwt.tokens import RefreshToken @@ -12,13 +15,16 @@ from .serializers import ( UserResponseSerializer, LoginRequestSerializer, LoginResponseSerializer, - LogoutResponseSerializer + LogoutResponseSerializer, + RefreshTokenRequestSerializer, + RefreshTokenResponseSerializer ) from api.utils.cookies import AuthBaseViewSet from api.types import User +@extend_schema(tags=['Логин']) class LoginViewSet(AuthBaseViewSet): """ViewSet для авторизации пользователей""" serializer_class = LoginRequestSerializer @@ -136,7 +142,89 @@ class LoginViewSet(AuthBaseViewSet): {"error": "Ошибка авторизации"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) + +@extend_schema(tags=['Логин']) +class RefreshTokenView(APIView): + """View для обновления JWT токенов""" + serializer_class = RefreshTokenRequestSerializer + + @extend_schema( + summary="Обновление токенов", + description="Эндпоинт для обновления JWT токенов. Принимает refresh token и возвращает новую пару токенов.", + request=RefreshTokenRequestSerializer, + responses={ + 200: OpenApiResponse( + response=RefreshTokenResponseSerializer, + description="Токены успешно обновлены", + examples=[ + OpenApiExample( + 'Успешное обновление', + value={ + "access": "eyJ0eXAiOiJKV1QiLCJhbGc...", + "refresh": "eyJ0eXAiOiJKV1QiLCJhbGc...", + "expires_at": 1679831642.0 + } + ) + ] + ), + 400: OpenApiResponse( + description="Ошибка обновления токена", + response=OpenApiTypes.OBJECT, + examples=[ + OpenApiExample( + 'Отсутствует refresh token', + value={"error": "Refresh token is required"} + ), + OpenApiExample( + 'Невалидный refresh token', + value={"error": "Invalid refresh token: Token is invalid or expired"} + ) + ] + ) + } + ) + def post(self, request): + try: + refresh_token = request.data.get('refresh') + if not refresh_token: + return Response( + {'error': 'Требуется refresh token'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + token = RefreshToken(refresh_token) + + # точное время истечения токена + expires_at = datetime.now() + settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'] + + response_data = { + 'access': str(token.access_token), + 'refresh': str(token), + 'expires_at': datetime.timestamp(expires_at) + } + + return Response(response_data) + + except Exception as e: + # логируем ошибки + + print(f"Token refresh error: {str(e)}") + print(traceback.format_exc()) + + return Response( + {'error': f'Невалидный refresh token: {str(e)}'}, + status=status.HTTP_400_BAD_REQUEST + ) + + except Exception as e: + return Response( + {'error': f'Ошибка обновления токена: {str(e)}'}, + status=status.HTTP_400_BAD_REQUEST + ) + +@extend_schema(tags=['Логаут']) class LogoutView(APIView): """View для выхода из системы""" serializer_class = LogoutResponseSerializer @@ -155,8 +243,7 @@ class LogoutView(APIView): ) ] ) - }, - tags=['Аутентификация'] + } ) def post(self, request): """Выход из системы с очисткой всех токенов и куки""" diff --git a/backend/base/settings.py b/backend/base/settings.py index 603fa36..bd40943 100644 --- a/backend/base/settings.py +++ b/backend/base/settings.py @@ -90,7 +90,8 @@ SPECTACULAR_SETTINGS = { # сортировка тегов и операций 'TAGS': [ - {'name': 'Аутентификация', 'description': 'Методы для работы с аутентификацией'}, + {'name': 'Логаут', 'description': 'Метод для работы с логаутом'}, + {'name': 'Логин', 'description': 'Методы для работы с логином'}, ], }