From 695c29ab62196071d38dcfbe015c8ddd13305627 Mon Sep 17 00:00:00 2001 From: Timofey Date: Sun, 18 May 2025 13:37:27 +0300 Subject: [PATCH] register logic --- backend/Pipfile | 2 + backend/Pipfile.lock | 28 ++- backend/api/account/__init__.py | 0 backend/api/account/client/__init__.py | 0 backend/api/account/client/serializers.py | 0 backend/api/account/client/views.py | 35 ++++ backend/api/auth/serializers.py | 22 ++- backend/api/auth/views.py | 169 +++++++++++++++++- backend/api/models.py | 2 +- backend/api/urls.py | 17 +- backend/api/utils/cookiesSet.py | 65 +++++++ backend/base/settings.py | 28 ++- backend/requirements.txt | 11 ++ frontend/app/(urls)/account/page.tsx | 7 + .../components/ClientRegistrationForm.tsx | 3 +- frontend/app/api/auth/[...nextauth]/route.ts | 55 ++++-- frontend/app/layout.tsx | 11 +- frontend/app/providers/AuthProvider.tsx | 62 +++++++ frontend/app/providers/Providers.tsx | 17 ++ 19 files changed, 498 insertions(+), 36 deletions(-) create mode 100644 backend/api/account/__init__.py create mode 100644 backend/api/account/client/__init__.py create mode 100644 backend/api/account/client/serializers.py create mode 100644 backend/api/account/client/views.py create mode 100644 backend/api/utils/cookiesSet.py create mode 100644 backend/requirements.txt create mode 100644 frontend/app/(urls)/account/page.tsx create mode 100644 frontend/app/providers/AuthProvider.tsx create mode 100644 frontend/app/providers/Providers.tsx diff --git a/backend/Pipfile b/backend/Pipfile index 0bc2492..e5725df 100644 --- a/backend/Pipfile +++ b/backend/Pipfile @@ -10,6 +10,8 @@ python-dotenv = "*" psycopg2 = "*" pillow = "*" transliterate = "*" +djangorestframework-simplejwt = "*" +django-cors-headers = "*" [dev-packages] diff --git a/backend/Pipfile.lock b/backend/Pipfile.lock index b0fee4a..36ee80c 100644 --- a/backend/Pipfile.lock +++ b/backend/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c254f2788ffdf030b1b0fbe90f05c0c47f93704170c42e2607f2a77b22f6ede2" + "sha256": "2620449b3502893be81fa9ab722c0beac295dd1dcad39705d6b962eddf45cc21" }, "pipfile-spec": 6, "requires": { @@ -33,6 +33,15 @@ "markers": "python_version >= '3.10'", "version": "==5.2.1" }, + "django-cors-headers": { + "hashes": [ + "sha256:6fdf31bf9c6d6448ba09ef57157db2268d515d94fc5c89a0a1028e1fc03ee52b", + "sha256:f1c125dcd58479fe7a67fe2499c16ee38b81b397463cf025f0e2c42937421070" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==4.7.0" + }, "djangorestframework": { "hashes": [ "sha256:bea7e9f6b96a8584c5224bfb2e4348dfb3f8b5e34edbecb98da258e892089361", @@ -42,6 +51,15 @@ "markers": "python_version >= '3.9'", "version": "==3.16.0" }, + "djangorestframework-simplejwt": { + "hashes": [ + "sha256:474a1b737067e6462b3609627a392d13a4da8a08b1f0574104ac6d7b1406f90e", + "sha256:4ef6b38af20cdde4a4a51d1fd8e063cbbabb7b45f149cc885d38d905c5a62edb" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==5.5.0" + }, "pillow": { "hashes": [ "sha256:014ca0050c85003620526b0ac1ac53f56fc93af128f7546623cc8e31875ab928", @@ -147,6 +165,14 @@ "markers": "python_version >= '3.8'", "version": "==2.9.10" }, + "pyjwt": { + "hashes": [ + "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850", + "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c" + ], + "markers": "python_version >= '3.8'", + "version": "==2.9.0" + }, "python-dotenv": { "hashes": [ "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", diff --git a/backend/api/account/__init__.py b/backend/api/account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/account/client/__init__.py b/backend/api/account/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/account/client/serializers.py b/backend/api/account/client/serializers.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/account/client/views.py b/backend/api/account/client/views.py new file mode 100644 index 0000000..4c253d7 --- /dev/null +++ b/backend/api/account/client/views.py @@ -0,0 +1,35 @@ +from rest_framework import status +from rest_framework.viewsets import ViewSet +from rest_framework.permissions import IsAuthenticated +from rest_framework.decorators import action +from rest_framework.response import Response + +from api.auth.serializers import UserResponseSerializer + +from api.models import UserProfile + +from api.utils.decorators import handle_exceptions + +class UserDataView(ViewSet): + permission_classes = [IsAuthenticated] + + def initial(self, request, *args, **kwargs): + try: + super().initial(request, *args, **kwargs) + except Exception as e: + print(f"Authentication error: {e}") + raise + + @action(detail=False, methods=['get']) + @handle_exceptions + def user_data(self, request): + user = request.user + + try: + user_data = UserResponseSerializer(user).data + return Response(user_data, status=status.HTTP_200_OK) + except UserProfile.DoesNotExist: + return Response( + {"error": "User profile not found"}, + status=status.HTTP_404_NOT_FOUND + ) \ No newline at end of file diff --git a/backend/api/auth/serializers.py b/backend/api/auth/serializers.py index 3a9e6f1..1771830 100644 --- a/backend/api/auth/serializers.py +++ b/backend/api/auth/serializers.py @@ -5,7 +5,7 @@ from django.db.utils import IntegrityError from api.models import UserProfile -class UserResponceSerializer(serializers.Serializer): +class UserResponseSerializer(serializers.Serializer): id = serializers.IntegerField() email = serializers.EmailField() name = serializers.CharField(source='first_name') @@ -24,16 +24,17 @@ class ClientRegisterSerializer(serializers.ModelSerializer): phone_number = serializers.CharField(max_length=13) privacy_accepted = serializers.BooleanField() email = serializers.EmailField(required=True) - username = serializers.CharField(source='first_name') + name = serializers.CharField(required=True) + surname = serializers.CharField(required=True) uuid = serializers.SerializerMethodField() class Meta: model = User - fields = ['id', 'uuid', 'username', 'email', 'password', 'phone_number', 'privacy_accepted'] + fields = ['id', 'uuid', 'name', 'surname', 'email', 'password', 'phone_number', 'privacy_accepted'] extra_kwargs = { - 'password' : {'write_only':True}, - 'email' : {'required' : True, 'unique': True}, - 'phone_number' : {'required' : True, 'unique': True}, + 'password': {'write_only': True}, + 'email': {'required': True}, + 'phone_number': {'required': True}, } def validate_phone_number(self, value): @@ -44,18 +45,21 @@ class ClientRegisterSerializer(serializers.ModelSerializer): def validate_privacy_accepted(self, value): if not value: raise serializers.ValidationError("Необходимо принять условия политики конфиденциальности") + return value def create(self, validated_data): privacy_accepted = validated_data.pop('privacy_accepted') phone_number = validated_data.pop('phone_number') - name = validated_data.pop('first_name') + name = validated_data.pop('name') + surname = validated_data.pop('surname') try: user = User.objects.create_user( - username=validated_data['name'], + username=validated_data['email'], # используем email как username email=validated_data['email'], password=validated_data['password'], - first_name = name, + first_name=name, + last_name=surname ) UserProfile.objects.create( diff --git a/backend/api/auth/views.py b/backend/api/auth/views.py index f650b88..f558c76 100644 --- a/backend/api/auth/views.py +++ b/backend/api/auth/views.py @@ -1,6 +1,171 @@ -from rest_framework import serializers +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework_simplejwt.tokens import RefreshToken +import traceback from django.contrib.auth.models import User +from django.conf import settings from django.db.utils import IntegrityError -from django.contrib.auth.hashers import make_password +from django.db import IntegrityError, transaction +from .serializers import ClientRegisterSerializer, UserResponseSerializer + +from api.utils.cookiesSet import AuthBaseViewSet +from datetime import datetime + + +class RegisterViewSet(AuthBaseViewSet): + """Регистрация клиента""" + @action(detail=False, methods=['post'], url_path="clients") + def register_client(self, request): + serializer = ClientRegisterSerializer(data=request.data) + if serializer.is_valid(): + try: + with transaction.atomic(): + user = serializer.save() + refresh = RefreshToken.for_user(user) + + user_data = UserResponseSerializer(user).data + + response = Response( + { + "access": str(refresh.access_token), + "refresh": str(refresh), + "user": user_data + }, + status=status.HTTP_201_CREATED + ) + + # используем метод из базового класса вместо прямой установки куки + return self._set_auth_cookies(response, refresh) + except IntegrityError as e: + return Response( + {"error": "Пользователь с таким email уже существует"}, + status=status.HTTP_400_BAD_REQUEST + ) + except Exception as e: + return Response( + {"error": str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) + + # ошибка валидации + return Response( + { + "error": "Ошибка валидации", + "details": serializer.errors + }, + status=status.HTTP_400_BAD_REQUEST + ) + + + +class LoginViewSet(AuthBaseViewSet): + """Логин для клиента""" + + @action(detail=False, methods=['post'], url_path="clients") + def login_client(self, request): + try: + email = request.data.get("email") + password = request.data.get("password") + + if not email or not password: + return Response( + {"error": "Email и пароль обязательны"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + user = User.objects.get(email=email) + 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 + user_data["userType"] = "client" + + 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): + """Логаут""" + + 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 + +class RefreshTokenView(APIView): + def post(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) + + # Сохраняем user_type при обновлении токена + if 'user_type' in token: + token.access_token['user_type'] = token['user_type'] + + # Добавляем точное время истечения токена + 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'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 + ) \ No newline at end of file diff --git a/backend/api/models.py b/backend/api/models.py index 063ed0f..23ef371 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -17,4 +17,4 @@ class UserProfile(models.Model): additionalDetails = models.TextField(null=True, blank=True, verbose_name="Дополнительные детали") def __str__(self): - return {self.phone_number} \ No newline at end of file + return str(self.phone_number) \ No newline at end of file diff --git a/backend/api/urls.py b/backend/api/urls.py index 1affeba..68d73de 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -2,7 +2,22 @@ from django.urls import path from api.main.views import FAQView, NewsView +from api.auth.views import ( +RegisterViewSet, +LoginViewSet, +LogoutView, +RefreshTokenView) + +from api.account.client.views import UserDataView + urlpatterns = [ path("v1/faq/", FAQView.as_view(), name='faqMain'), - path("v1/news/", NewsView.as_view(), name="newsmain") + path("v1/news/", NewsView.as_view(), name="newsmain"), + + path("auth/refresh/", RefreshTokenView.as_view(), name="token-refresh"), + path("register/clients/", RegisterViewSet.as_view({'post': 'register_client'}), name="register-client"), + path("auth/login/clients/", LoginViewSet.as_view({'post': 'login_client'}), name="login-client"), + path("v1/logout", LogoutView.as_view(), name="logout"), + + path ("v1/user/", UserDataView.as_view({'get': 'user_data'}), name="user"), ] \ No newline at end of file diff --git a/backend/api/utils/cookiesSet.py b/backend/api/utils/cookiesSet.py new file mode 100644 index 0000000..2eab99f --- /dev/null +++ b/backend/api/utils/cookiesSet.py @@ -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 + ) diff --git a/backend/base/settings.py b/backend/base/settings.py index ff33459..1e7ce41 100644 --- a/backend/base/settings.py +++ b/backend/base/settings.py @@ -1,6 +1,7 @@ import os from pathlib import Path from dotenv import load_dotenv +from datetime import timedelta BASE_DIR = Path(__file__).resolve().parent.parent load_dotenv(dotenv_path=BASE_DIR / './.env') @@ -8,11 +9,16 @@ load_dotenv(dotenv_path=BASE_DIR / './.env') BASE_URL = os.environ.get("BASE_URL") MEDIA_URL = '/media/' MEDIA_ROOT = os.environ.get("MEDIA_ROOT") -# MEDIA_ROOT = '/root/tripwb/uploads/' -- закинь в .env.production +# !MEDIA_ROOT = '/root/tripwb/uploads/' -- закинь в .env.production SECRET_KEY = os.environ.get("SECRET_KEY") DEBUG = os.environ.get("DEBUG_MODE") +GOOGLE_CLIENT_ID = os.environ.get("CLIENT_ID") + +BOT_TOKEN = os.environ.get("BOT_TOKEN") +CHAT_ID = os.environ.get("CHAT_ID") + ALLOWED_HOSTS = ['localhost', 'tripwb.com', '127.0.0.1', 'tripwb-backend-app', 'v2.tripwb.com'] CSRF_TRUSTED_ORIGINS = [ @@ -22,6 +28,7 @@ CSRF_TRUSTED_ORIGINS = [ ] INSTALLED_APPS = [ + 'corsheaders', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -32,9 +39,11 @@ INSTALLED_APPS = [ 'routes.apps.RoutesConfig', 'sitemanagement.apps.SitemanagementConfig', 'rest_framework', + "rest_framework_simplejwt", ] MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -44,6 +53,20 @@ MIDDLEWARE = [ 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': True, + 'UPDATE_LAST_LOGIN': True, + 'ALGORITHM': 'HS256', + 'SIGNING_KEY': SECRET_KEY, + 'VERIFYING_KEY': None, + 'AUTH_HEADER_TYPES': ('Bearer',), + 'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',), +} + ROOT_URLCONF = 'base.urls' TEMPLATES = [ @@ -74,8 +97,7 @@ DATABASES = { } } -BOT_TOKEN = os.environ.get("BOT_TOKEN") -CHAT_ID = os.environ.get("CHAT_ID") + CORS_ALLOW_CREDENTIALS = True # для разрешения cookie diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..2054deb --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,11 @@ +asgiref==3.8.1 +Django==5.2.1 +djangorestframework==3.16.0 +djangorestframework_simplejwt==5.5.0 +pillow==11.2.1 +psycopg2==2.9.10 +PyJWT==2.9.0 +python-dotenv==1.1.0 +six==1.17.0 +sqlparse==0.5.3 +transliterate==1.10.2 diff --git a/frontend/app/(urls)/account/page.tsx b/frontend/app/(urls)/account/page.tsx new file mode 100644 index 0000000..2868bf2 --- /dev/null +++ b/frontend/app/(urls)/account/page.tsx @@ -0,0 +1,7 @@ +import React from 'react' + +const page = () => { + return
page
+} + +export default page diff --git a/frontend/app/(urls)/register/components/ClientRegistrationForm.tsx b/frontend/app/(urls)/register/components/ClientRegistrationForm.tsx index 18f1f64..6386825 100644 --- a/frontend/app/(urls)/register/components/ClientRegistrationForm.tsx +++ b/frontend/app/(urls)/register/components/ClientRegistrationForm.tsx @@ -43,7 +43,8 @@ export default function ClientRegistrationForm() { const result = await signIn('register-credentials', { email: values.email, password: values.password, - username: values.name, + name: values.name, + surname: values.surname, phone_number: values.phone_number, privacy_accepted: values.privacy_accepted.toString(), redirect: false, diff --git a/frontend/app/api/auth/[...nextauth]/route.ts b/frontend/app/api/auth/[...nextauth]/route.ts index 6306521..cef2d8c 100644 --- a/frontend/app/api/auth/[...nextauth]/route.ts +++ b/frontend/app/api/auth/[...nextauth]/route.ts @@ -45,35 +45,56 @@ interface GoogleToken extends JWT { const authOptions: NextAuthOptions = { providers: [ //google login flow - GoogleProvider({ - clientId: process.env.GOOGLE_CLIENT_ID!, - clientSecret: process.env.GOOGLE_CLIENT_SECRET!, - }), + // GoogleProvider({ + // clientId: process.env.GOOGLE_CLIENT_ID!, + // clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + // }), //регистрация клиента CredentialsProvider({ id: 'register-credentials', name: 'Register', credentials: { + name: { label: 'Name', type: 'text' }, + surname: { label: 'Surname', type: 'text' }, email: { label: 'Email', type: 'email' }, password: { label: 'Password', type: 'password' }, - name: { label: 'Name', type: 'text' }, phone_number: { label: 'Phone Number', type: 'tel' }, privacy_accepted: { label: 'Privacy Accepted', type: 'boolean' }, }, async authorize(credentials) { try { + if ( + !credentials?.email || + !credentials?.password || + !credentials?.name || + !credentials?.phone_number || + !credentials?.privacy_accepted || + !credentials?.surname + ) { + throw new Error('Все поля обязательны для заполнения') + } + + // console.log('Registration data:', { + // email: credentials.email, + // name: credentials.name, + // surname: credentials.surname, + // phone_number: credentials.phone_number, + // privacy_accepted: credentials.privacy_accepted, + // }) + const res = await fetch( `${process.env.BACKEND_URL}/register/clients/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - email: credentials?.email, - password: credentials?.password, - name: credentials?.name, - phone_number: credentials?.phone_number, - privacy_accepted: credentials?.privacy_accepted === 'true', + email: credentials.email, + password: credentials.password, + name: credentials.name, + surname: credentials.surname, + phone_number: credentials.phone_number, + privacy_accepted: credentials.privacy_accepted === 'true', }), } ) @@ -81,21 +102,25 @@ const authOptions: NextAuthOptions = { const data = await res.json() if (!res.ok) { - throw new Error( - data.error || data.details?.toString() || 'Registration failed' - ) + console.error('Registration error response:', data) + const errorMessage = + typeof data === 'object' + ? data.error || Object.values(data).flat().join(', ') + : 'Registration failed' + throw new Error(errorMessage) } return { id: data.user.id.toString(), email: data.user.email, - name: data.user.firstName, + name: data.user.name, + surname: data.user.surname, accessToken: data.access, refreshToken: data.refresh, } } catch (error) { console.error('Registration error:', error) - return null + throw error } }, }), diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 818fb82..c5c29bc 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -2,6 +2,8 @@ import type { Metadata } from 'next' import './globals.css' import Header from '@/components/Header' import Footer from '@/components/Footer' +import { Toaster } from 'react-hot-toast' +import { Providers } from './providers/Providers' export const metadata: Metadata = { title: 'Отправка посылок в любую точку мира | TripWB', @@ -17,9 +19,12 @@ export default function RootLayout({ return ( -
-
{children}
-