Skip to content

Commit 4d58608

Browse files
committed
feat: vkid oauth
1 parent 8ec3da2 commit 4d58608

File tree

5 files changed

+136
-2
lines changed

5 files changed

+136
-2
lines changed

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,6 @@ TELEGRAM_CHANNEL=
2727

2828
CLICKUP_API_TOKEN=
2929
CLICKUP_SPACE_ID=
30+
31+
VKID_APP_ID=
32+
VKID_REDIRECT_URI=

procollab/settings.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,3 +403,8 @@
403403
CELERY_ACCEPT_CONTENT = ["application/json"]
404404
CELERY_RESULT_SERIALIZER = "json"
405405
CELERY_TASK_SERIALIZER = "json"
406+
407+
VKID_APP_ID = config("VKID_APP_ID", cast=int, default="52467498")
408+
VKID_REDIRECT_URI = config(
409+
"VKID_REDIRECT_URI", cast=str, default="https://app.procollab.ru/auth/login/"
410+
)

users/urls.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
RemoteCreatePayment,
3030
UserCVDownload,
3131
UserCVMailing,
32+
VKIDOauth2View,
3233
)
3334

3435
app_name = "users"
@@ -54,7 +55,10 @@
5455
path("users/<int:user_pk>/news/<int:pk>/", NewsDetail.as_view()),
5556
path("users/<int:user_pk>/news/<int:pk>/set_viewed/", NewsDetailSetViewed.as_view()),
5657
path("users/<int:user_pk>/news/<int:pk>/set_liked/", NewsDetailSetLiked.as_view()),
57-
path("users/<int:user_pk>/approve_skill/<int:skill_pk>/", UserSkillsApproveDeclineView.as_view()),
58+
path(
59+
"users/<int:user_pk>/approve_skill/<int:skill_pk>/",
60+
UserSkillsApproveDeclineView.as_view(),
61+
),
5862
path("users/current/", CurrentUser.as_view()),
5963
# todo: change password view
6064
path("users/current/programs/", CurrentUserPrograms.as_view()),
@@ -87,4 +91,5 @@
8791
# copy from skills
8892
path("subscription/", RemoteViewSubscriptions.as_view()),
8993
path("subscription/buy/", RemoteCreatePayment.as_view()),
94+
path("vkid/", VKIDOauth2View.as_view()),
9095
]

users/utils.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import binascii
2+
import os
13
from datetime import datetime, timedelta
24

35
from django.db.models import Q
@@ -31,7 +33,18 @@ def normalize_user_phone(phone_num: str):
3133
try:
3234
phone_number = phonenumbers.parse(phone_num, None)
3335
if phonenumbers.is_valid_number(phone_number):
34-
return phonenumbers.format_number(phone_number, phonenumbers.PhoneNumberFormat.INTERNATIONAL)
36+
return phonenumbers.format_number(
37+
phone_number, phonenumbers.PhoneNumberFormat.INTERNATIONAL
38+
)
3539
raise ValidationError(NOT_VALID_NUMBER_MESSAGE)
3640
except phonenumbers.phonenumberutil.NumberParseException:
3741
raise ValidationError(NOT_VALID_NUMBER_MESSAGE)
42+
43+
44+
def random_bytes_in_hex(count: int) -> str:
45+
"""Генерация случайных байтов в формате hex."""
46+
try:
47+
random_bytes = os.urandom(count)
48+
return binascii.hexlify(random_bytes).decode()
49+
except Exception as e:
50+
raise ValueError(f"Could not generate {count} random bytes: {e}")

users/views.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import base64
2+
import hashlib
13
import jwt
24
import requests
35
import urllib.parse
@@ -81,6 +83,7 @@
8183
from .services.cv_data_prepare import UserCVDataPreparerV2
8284
from .schema import USER_PK_PARAM, SKILL_PK_PARAM
8385
from .tasks import send_mail_cv
86+
from .utils import random_bytes_in_hex
8487

8588
User = get_user_model()
8689
Project = apps.get_model("projects", "Project")
@@ -655,3 +658,108 @@ def get(self, request, *args, **kwargs):
655658
cache.set(cache_key, timezone.now(), timeout=cooldown_time)
656659

657660
return Response(data={"detail": "success"}, status=status.HTTP_200_OK)
661+
662+
663+
class VKIDOauth2View(APIView):
664+
permission_classes = [AllowAny]
665+
666+
def get(self, request, *args, **kwargs):
667+
"""
668+
Генерация state и code_challenge для OAuth2.
669+
"""
670+
code_verifier = random_bytes_in_hex(32)
671+
code_challenge = (
672+
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
673+
.decode()
674+
.rstrip("=")
675+
)
676+
state = random_bytes_in_hex(24)
677+
cache_timeout = 15 * 60
678+
cache.set(state, code_verifier, cache_timeout)
679+
680+
return Response(
681+
{
682+
"redirect_uri": settings.VKID_REDIRECT_URI,
683+
"state": state,
684+
"code_challenge": code_challenge,
685+
"client_id": settings.VKID_APP_ID,
686+
"scope": "email",
687+
},
688+
status=status.HTTP_200_OK,
689+
)
690+
691+
def post(self, request, *args, **kwargs):
692+
"""
693+
Обработка callback после авторизации пользователя.
694+
"""
695+
required_fields = ["code", "device_id", "state"]
696+
data = request.data
697+
missing_fields = [field for field in required_fields if field not in data]
698+
if missing_fields:
699+
return Response(
700+
{"detail": f"Missing required fields: {', '.join(missing_fields)}"},
701+
status=status.HTTP_400_BAD_REQUEST,
702+
)
703+
code_verifier = cache.get(data.get("state"))
704+
client_id = settings.VKID_APP_ID
705+
request_data = {
706+
"code_verifier": code_verifier,
707+
"code": data.get("code"),
708+
"device_id": data.get("device_id"),
709+
"client_id": client_id,
710+
"redirect_uri": settings.VKID_REDIRECT_URI,
711+
"grant_type": "authorization_code",
712+
"scope": "email",
713+
}
714+
try:
715+
token_response = requests.post(
716+
"https://id.vk.com/oauth2/auth", data=request_data
717+
)
718+
token_response.raise_for_status()
719+
token_data = token_response.json()
720+
except requests.RequestException as e:
721+
return Response(
722+
{"detail": f"Failed to fetch token: {str(e)}"},
723+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
724+
)
725+
726+
access_token = token_data.get("access_token")
727+
if not access_token:
728+
return Response(
729+
{"detail": "Access token not provided by VK"},
730+
status=status.HTTP_400_BAD_REQUEST,
731+
)
732+
try:
733+
user_info_response = requests.post(
734+
"https://id.vk.com/oauth2/user_info",
735+
data={"access_token": access_token, "client_id": client_id},
736+
)
737+
user_info_response.raise_for_status()
738+
user_info = user_info_response.json()
739+
except requests.RequestException as e:
740+
return Response(
741+
{"detail": f"Failed to fetch user info: {str(e)}"},
742+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
743+
)
744+
745+
user_email = user_info.get("user", {}).get("email")
746+
if not user_email:
747+
return Response(
748+
{"detail": "User email not provided by VK"},
749+
status=status.HTTP_400_BAD_REQUEST,
750+
)
751+
try:
752+
user = User.objects.get(email=user_email)
753+
except User.DoesNotExist:
754+
return Response(
755+
{"error": "User does not exist"}, status=status.HTTP_404_NOT_FOUND
756+
)
757+
access_token = str(RefreshToken.for_user(user).access_token)
758+
refresh_token = str(RefreshToken.for_user(user))
759+
return Response(
760+
{
761+
"access": access_token,
762+
"refresh": refresh_token,
763+
},
764+
status=status.HTTP_200_OK,
765+
)

0 commit comments

Comments
 (0)