login route

This commit is contained in:
Timofey
2025-08-29 14:44:26 +03:00
parent d314303066
commit 5a06a625fb
11 changed files with 701 additions and 5 deletions

View File

@@ -0,0 +1,62 @@
from typing import Any, Optional
from rest_framework import serializers
from django.conf import settings
from api.types import User
class UserResponseSerializer(serializers.Serializer):
id = serializers.IntegerField(read_only=True)
email = serializers.EmailField(read_only=True)
account_type = serializers.CharField(
source='userprofile.account_type',
read_only=True
)
name = serializers.CharField(source='first_name', read_only=True)
surname = serializers.CharField(source='last_name', read_only=True)
imageURL = serializers.SerializerMethodField()
uuid = serializers.SerializerMethodField()
class Meta:
ref_name = "UserResponse" # для OpenAPI
def get_uuid(self, obj: User) -> Optional[str]:
"""Получает короткий UUID (первые 6 символов) из профиля пользователя"""
return obj.userprofile.short_uuid if hasattr(obj, 'userprofile') else None
def get_imageURL(self, obj: User) -> Optional[str]:
"""Получает полный URL для изображения профиля пользователя"""
try:
if not hasattr(obj, 'userprofile') or not obj.userprofile.imageURL:
return None
relative_url = obj.userprofile.imageURL.lstrip('/')
base_url = settings.BASE_URL.rstrip('/')
return f"{base_url}/{relative_url}"
except Exception:
return None
def to_representation(self, instance: User) -> dict[str, Any]:
"""Переопределяется для добавления проверки типа для вывода"""
data = super().to_representation(instance)
return {
'id': data['id'], # int
'email': data['email'], # str
'account_type': data['account_type'], # AccountTypeLiteral
'name': data['name'], # str
'surname': data['surname'], # str
'imageURL': data['imageURL'], # Optional[str]
'uuid': data['uuid'], # Optional[str]
}
class LoginRequestSerializer(serializers.Serializer):
"""Сериализатор для запроса авторизации"""
login = serializers.CharField(help_text="Логин пользователя")
password = serializers.CharField(help_text="Пароль пользователя", write_only=True)
class LoginResponseSerializer(serializers.Serializer):
"""Сериализатор для ответа при успешной авторизации"""
message = serializers.CharField()
access = serializers.CharField()
refresh = serializers.CharField()
user = UserResponseSerializer()

View File

@@ -1,6 +1,28 @@
from django.urls import path
# from . views import ()
from django.urls import path, include
from .views import LoginViewSet, LogoutView
from rest_framework.routers import DefaultRouter
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularSwaggerView,
SpectacularRedocView,
)
router = DefaultRouter()
router.register(r'', LoginViewSet, basename='auth')
urlpatterns = [
path('', include(router.urls)),
path('logout/', LogoutView.as_view(), name='auth-logout'),
path('schema/', SpectacularAPIView.as_view(), name='schema'),
path(
'docs/',
SpectacularSwaggerView.as_view(url_name='schema'),
name='swagger-ui',
),
# ReDoc UI - альтернативный вариант отображения доков:
path(
'redoc/',
SpectacularRedocView.as_view(url_name='schema'),
name='redoc',
),
]

View File

@@ -0,0 +1,167 @@
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework.views import APIView
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample
from drf_spectacular.types import OpenApiTypes
from django.contrib.auth.models import User
from .serializers import (
UserResponseSerializer,
LoginRequestSerializer,
LoginResponseSerializer
)
from api.utils.cookies import AuthBaseViewSet
from api.types import User
class LoginViewSet(AuthBaseViewSet):
"""ViewSet для авторизации пользователей"""
serializer_class = LoginRequestSerializer
@extend_schema(
summary="Авторизация пользователя",
description="Эндпоинт для авторизации пользователя по логину и паролю",
request=LoginRequestSerializer,
responses={
200: OpenApiResponse(
response=LoginResponseSerializer,
description="Успешная авторизация",
examples=[
OpenApiExample(
'Успешный ответ',
value={
"message": "Успешная авторизация",
"access": "eyJ0eXAiOiJKV1QiLCJhbGc...",
"refresh": "eyJ0eXAiOiJKV1QiLCJhbGc...",
"user": {
"id": 1,
"email": "user@example.com",
"account_type": "engieneer",
"name": "Иван",
"surname": "Иванов",
"imageURL": "https://example.com/avatar.jpg",
"uuid": "abc123"
}
}
)
]
),
400: OpenApiResponse(
description="Неверные параметры запроса",
response=OpenApiTypes.OBJECT,
examples=[
OpenApiExample(
'Отсутствуют обязательные поля',
value={"error": "Логин и пароль обязательны"}
)
]
),
403: OpenApiResponse(
description="Неверный пароль",
response=OpenApiTypes.OBJECT,
examples=[
OpenApiExample(
'Неверный пароль',
value={"error": "Неверный пароль"}
)
]
),
404: OpenApiResponse(
description="Пользователь не найден",
response=OpenApiTypes.OBJECT,
examples=[
OpenApiExample(
'Пользователь не найден',
value={"error": "Пользователь не найден"}
)
]
),
500: OpenApiResponse(
description="Внутренняя ошибка сервера",
response=OpenApiTypes.OBJECT,
examples=[
OpenApiExample(
'Ошибка сервера',
value={"error": "Ошибка авторизации"}
)
]
)
}
)
@action(detail=False, methods=['post'], url_path="login")
def login_client(self, request):
try:
login = request.data.get("login")
password = request.data.get("password")
if not login or not password:
return Response(
{"error": "Логин и пароль обязательны"},
status=status.HTTP_400_BAD_REQUEST
)
try:
user = User.objects.get(login=login)
except User.DoesNotExist:
return Response(
{"error": "Пользователь не найден"},
status=status.HTTP_404_NOT_FOUND
)
if not user.check_password(password):
return Response(
{"error": "Неверный пароль"},
status=status.HTTP_403_FORBIDDEN
)
refresh = RefreshToken.for_user(user)
user_data = UserResponseSerializer(user).data
response = Response({
"message": "Успешная авторизация",
"access": str(refresh.access_token),
"refresh": str(refresh),
"user": user_data
}, status=status.HTTP_200_OK)
# сеттим куки
return self._set_auth_cookies(response, refresh)
except Exception as e:
return Response(
{"error": "Ошибка авторизации"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class LogoutView(APIView):
"""ViewSet для выхода из системы"""
@extend_schema(
summary="Выход из системы",
description="Эндпоинт для выхода из системы, очищает все токены и куки",
responses={
200: OpenApiResponse(
description="Успешный выход",
response=OpenApiTypes.OBJECT,
examples=[
OpenApiExample(
'Успешный выход',
value={"message": "Logged out"}
)
]
)
}
)
def post(self, request):
response = Response({'message': 'Logged out'}, status=status.HTTP_200_OK)
# чистим куки и sessionID
response.delete_cookie('access_token')
response.delete_cookie('refresh_token')
response.delete_cookie('sessionid')
return response

View File

@@ -1,3 +1,56 @@
from django.contrib.auth.models import User
from django.db import models
from django.db.models.fields.related import OneToOneField
from typing import Optional
import uuid
# Create your models here.
from sitemanagement.constants.account_types import account_types, AccountType, AccountTypeLiteral
class UserProfile(models.Model):
"""
Профиль пользователя с дополнительной информацией
"""
def get_account_type_display(self) -> str:
"""Автоматически добавляется Django для полей с choices"""
...
user: OneToOneField[User] = models.OneToOneField(
User,
on_delete=models.CASCADE,
related_name='userprofile'
)
uuid: models.UUIDField = models.UUIDField(
default=uuid.uuid4,
editable=False,
unique=True,
null=True
)
account_type: models.CharField = models.CharField(
max_length=10,
verbose_name="Тип аккаунта",
choices=account_types,
db_index=True
)
imageURL: models.CharField = models.CharField(
max_length=255,
null=True,
blank=True,
verbose_name="URL изображения профиля"
)
class Meta:
verbose_name = "Профиль пользователя"
verbose_name_plural = "Профили пользователей"
def __str__(self) -> str:
return f"{self.user.first_name} ({self.get_account_type_display()})"
@property
def short_uuid(self) -> Optional[str]:
"""Возвращает первые 6 символов UUID или None, если UUID не установлен"""
return str(self.uuid)[:6] if self.uuid else None
def get_account_type(self) -> AccountTypeLiteral:
"""Возвращает тип аккаунта пользователя"""
return AccountType(self.account_type).value

9
backend/api/types.py Normal file
View File

@@ -0,0 +1,9 @@
from typing import TYPE_CHECKING
from django.contrib.auth.models import User as DjangoUser
if TYPE_CHECKING:
from .models import UserProfile
class User(DjangoUser):
"""Тип для Django User с кастомной моделью UserProfile"""
userprofile: 'UserProfile'

View File

@@ -0,0 +1,65 @@
from rest_framework.viewsets import ViewSet
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework import status
from datetime import datetime
from django.conf import settings
from rest_framework_simplejwt.tokens import RefreshToken
class AuthBaseViewSet(ViewSet):
"""Базовый класс для аутентификации с общими методами"""
def _set_auth_cookies(self, response, refresh):
"""Устанавливает куки для токенов аутентификации"""
response.set_cookie(
'access_token',
str(refresh.access_token),
httponly=True,
secure=True,
samesite='Lax',
max_age=300
)
response.set_cookie(
'refresh_token',
str(refresh),
httponly=True,
secure=True,
samesite='Lax',
max_age=86400
)
return response
@action(detail=False, methods=['post'], url_path="refresh")
def refresh_token(self, request):
try:
refresh_token = request.data.get('refresh')
if not refresh_token:
return Response(
{'error': 'Refresh token is required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
token = RefreshToken(refresh_token)
response_data = {
'access': str(token.access_token),
'refresh': str(token),
'expires_at': datetime.timestamp(
datetime.now() + settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME']
)
}
return Response(response_data)
except Exception as e:
return Response(
{'error': f'Invalid refresh token: {str(e)}'},
status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
return Response(
{'error': f'Token refresh failed: {str(e)}'},
status=status.HTTP_400_BAD_REQUEST
)