From 4d58608abbf1a9c3bee6f5ca6ff494e552c9a982 Mon Sep 17 00:00:00 2001 From: Screxy Date: Sun, 1 Dec 2024 16:32:41 +0300 Subject: [PATCH] feat: vkid oauth --- .env.example | 3 ++ procollab/settings.py | 5 ++ users/urls.py | 7 ++- users/utils.py | 15 +++++- users/views.py | 108 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 136 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 13e8e71d..8cde81e4 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,6 @@ TELEGRAM_CHANNEL= CLICKUP_API_TOKEN= CLICKUP_SPACE_ID= + +VKID_APP_ID= +VKID_REDIRECT_URI= diff --git a/procollab/settings.py b/procollab/settings.py index f41c2b7a..eaab85a1 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -403,3 +403,8 @@ CELERY_ACCEPT_CONTENT = ["application/json"] CELERY_RESULT_SERIALIZER = "json" CELERY_TASK_SERIALIZER = "json" + +VKID_APP_ID = config("VKID_APP_ID", cast=int, default="52467498") +VKID_REDIRECT_URI = config( + "VKID_REDIRECT_URI", cast=str, default="https://app.procollab.ru/auth/login/" +) diff --git a/users/urls.py b/users/urls.py index 28e5ea89..4a8b62c2 100644 --- a/users/urls.py +++ b/users/urls.py @@ -29,6 +29,7 @@ RemoteCreatePayment, UserCVDownload, UserCVMailing, + VKIDOauth2View, ) app_name = "users" @@ -54,7 +55,10 @@ path("users//news//", NewsDetail.as_view()), path("users//news//set_viewed/", NewsDetailSetViewed.as_view()), path("users//news//set_liked/", NewsDetailSetLiked.as_view()), - path("users//approve_skill//", UserSkillsApproveDeclineView.as_view()), + path( + "users//approve_skill//", + UserSkillsApproveDeclineView.as_view(), + ), path("users/current/", CurrentUser.as_view()), # todo: change password view path("users/current/programs/", CurrentUserPrograms.as_view()), @@ -87,4 +91,5 @@ # copy from skills path("subscription/", RemoteViewSubscriptions.as_view()), path("subscription/buy/", RemoteCreatePayment.as_view()), + path("vkid/", VKIDOauth2View.as_view()), ] diff --git a/users/utils.py b/users/utils.py index 230a288a..57843758 100644 --- a/users/utils.py +++ b/users/utils.py @@ -1,3 +1,5 @@ +import binascii +import os from datetime import datetime, timedelta from django.db.models import Q @@ -31,7 +33,18 @@ def normalize_user_phone(phone_num: str): try: phone_number = phonenumbers.parse(phone_num, None) if phonenumbers.is_valid_number(phone_number): - return phonenumbers.format_number(phone_number, phonenumbers.PhoneNumberFormat.INTERNATIONAL) + return phonenumbers.format_number( + phone_number, phonenumbers.PhoneNumberFormat.INTERNATIONAL + ) raise ValidationError(NOT_VALID_NUMBER_MESSAGE) except phonenumbers.phonenumberutil.NumberParseException: raise ValidationError(NOT_VALID_NUMBER_MESSAGE) + + +def random_bytes_in_hex(count: int) -> str: + """Генерация случайных байтов в формате hex.""" + try: + random_bytes = os.urandom(count) + return binascii.hexlify(random_bytes).decode() + except Exception as e: + raise ValueError(f"Could not generate {count} random bytes: {e}") diff --git a/users/views.py b/users/views.py index 3d8f22f8..b6ae3271 100644 --- a/users/views.py +++ b/users/views.py @@ -1,3 +1,5 @@ +import base64 +import hashlib import jwt import requests import urllib.parse @@ -81,6 +83,7 @@ from .services.cv_data_prepare import UserCVDataPreparerV2 from .schema import USER_PK_PARAM, SKILL_PK_PARAM from .tasks import send_mail_cv +from .utils import random_bytes_in_hex User = get_user_model() Project = apps.get_model("projects", "Project") @@ -655,3 +658,108 @@ def get(self, request, *args, **kwargs): cache.set(cache_key, timezone.now(), timeout=cooldown_time) return Response(data={"detail": "success"}, status=status.HTTP_200_OK) + + +class VKIDOauth2View(APIView): + permission_classes = [AllowAny] + + def get(self, request, *args, **kwargs): + """ + Генерация state и code_challenge для OAuth2. + """ + code_verifier = random_bytes_in_hex(32) + code_challenge = ( + base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()) + .decode() + .rstrip("=") + ) + state = random_bytes_in_hex(24) + cache_timeout = 15 * 60 + cache.set(state, code_verifier, cache_timeout) + + return Response( + { + "redirect_uri": settings.VKID_REDIRECT_URI, + "state": state, + "code_challenge": code_challenge, + "client_id": settings.VKID_APP_ID, + "scope": "email", + }, + status=status.HTTP_200_OK, + ) + + def post(self, request, *args, **kwargs): + """ + Обработка callback после авторизации пользователя. + """ + required_fields = ["code", "device_id", "state"] + data = request.data + missing_fields = [field for field in required_fields if field not in data] + if missing_fields: + return Response( + {"detail": f"Missing required fields: {', '.join(missing_fields)}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + code_verifier = cache.get(data.get("state")) + client_id = settings.VKID_APP_ID + request_data = { + "code_verifier": code_verifier, + "code": data.get("code"), + "device_id": data.get("device_id"), + "client_id": client_id, + "redirect_uri": settings.VKID_REDIRECT_URI, + "grant_type": "authorization_code", + "scope": "email", + } + try: + token_response = requests.post( + "https://id.vk.com/oauth2/auth", data=request_data + ) + token_response.raise_for_status() + token_data = token_response.json() + except requests.RequestException as e: + return Response( + {"detail": f"Failed to fetch token: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + access_token = token_data.get("access_token") + if not access_token: + return Response( + {"detail": "Access token not provided by VK"}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + user_info_response = requests.post( + "https://id.vk.com/oauth2/user_info", + data={"access_token": access_token, "client_id": client_id}, + ) + user_info_response.raise_for_status() + user_info = user_info_response.json() + except requests.RequestException as e: + return Response( + {"detail": f"Failed to fetch user info: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + user_email = user_info.get("user", {}).get("email") + if not user_email: + return Response( + {"detail": "User email not provided by VK"}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + user = User.objects.get(email=user_email) + except User.DoesNotExist: + return Response( + {"error": "User does not exist"}, status=status.HTTP_404_NOT_FOUND + ) + access_token = str(RefreshToken.for_user(user).access_token) + refresh_token = str(RefreshToken.for_user(user)) + return Response( + { + "access": access_token, + "refresh": refresh_token, + }, + status=status.HTTP_200_OK, + )