login route
This commit is contained in:
@@ -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()
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
9
backend/api/types.py
Normal 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'
|
||||
65
backend/api/utils/cookies.py
Normal file
65
backend/api/utils/cookies.py
Normal 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
|
||||
)
|
||||
Reference in New Issue
Block a user