diff --git a/.env.sample b/.env.sample index 45fa934..c3ae071 100644 --- a/.env.sample +++ b/.env.sample @@ -40,5 +40,9 @@ CELERY_RESULT_BACKEND= LANGUAGE_CODE=en-us TIME_ZONE=Asia/Tehran -SMS_APIKEY= -BASE_URL= +SMS_APIKEY= +BASE_URL= +GOOGLE_OAUTH_CLIENT_ID= +GOOGLE_OAUTH_CLIENT_SECRET= +GOOGLE_OAUTH_REDIRECT_URI=http://localhost:8000/api/users/oauth/google/callback/ +GOOGLE_OAUTH_FRONTEND_CALLBACK_URL=http://localhost:5173/auth/google/callback diff --git a/apps/users/api/serializers.py b/apps/users/api/serializers.py index 258dd5e..9a97de8 100644 --- a/apps/users/api/serializers.py +++ b/apps/users/api/serializers.py @@ -1,23 +1,11 @@ -import logging -import random -import string - from django.contrib.auth import get_user_model -from django.db import transaction -from django.utils import timezone -from django_redis import get_redis_connection from drf_spectacular.utils import extend_schema_serializer from rest_framework import serializers from core.serializers.base import BaseModelSerializer -from apps.users.tasks import send_verification_sms -from apps.users.utils import record_login_attempt - User = get_user_model() -logger = logging.getLogger(__name__) - class UserProfilePictureSerializer(BaseModelSerializer): class Meta: @@ -51,10 +39,10 @@ class RegisterSerializer(serializers.Serializer): re_password = data.get("re_password", "") if not (mobile.isdigit() and len(mobile) == 11): - raise serializers.ValidationError({"mobile": "فرمت شماره موبایل نادرست است."}) + raise serializers.ValidationError({"mobile": "فرمت شماره موبایل نادرست است."}) if password != re_password: - raise serializers.ValidationError({"password": "رمز عبور مطابقت ندارد."}) + raise serializers.ValidationError({"password": "رمز عبور مطابقت ندارد."}) return data @@ -65,11 +53,8 @@ class SendOTPSerializer(serializers.Serializer): mode = serializers.ChoiceField(choices=["register", "login", "forget_password"]) def validate_mobile(self, value): - """ - Normalize and validate Iranian mobile numbers (example: 09XXXXXXXXX). - """ if not value.isdigit() or len(value) != 11 or not value.startswith("09"): - raise serializers.ValidationError("شماره موبایل معتبر نیست.") + raise serializers.ValidationError("شماره موبایل معتبر نیست.") return value @@ -80,7 +65,7 @@ class LoginOtpSerializer(serializers.Serializer): def validate_mobile(self, value): if not (value.isdigit() and len(value) == 11): - raise serializers.ValidationError("فرمت شماره موبایل نادرست است.") + raise serializers.ValidationError("فرمت شماره موبایل نادرست است.") return value @@ -90,10 +75,30 @@ class LoginSerializer(serializers.Serializer): def validate_mobile(self, value): if not (value.isdigit() and len(value) == 11): - raise serializers.ValidationError("فرمت شماره موبایل نادرست است.") + raise serializers.ValidationError("فرمت شماره موبایل نادرست است.") return value +class GoogleOAuthFlowSerializer(serializers.Serializer): + flow = serializers.CharField() + + +class GoogleOAuthCompleteSerializer(serializers.Serializer): + flow = serializers.CharField() + mobile = serializers.CharField(max_length=11) + + def validate_mobile(self, value): + normalized = "".join(ch for ch in value if ch.isdigit()) + if len(normalized) != 11 or not normalized.startswith("09"): + raise serializers.ValidationError("فرمت شماره موبایل نادرست است.") + return normalized + + +class GoogleOAuthClaimVerifySerializer(serializers.Serializer): + flow = serializers.CharField() + code = serializers.CharField(max_length=6) + + class ResetPasswordSerializer(serializers.Serializer): mobile = serializers.CharField(max_length=11) code = serializers.CharField(max_length=6) @@ -102,7 +107,7 @@ class ResetPasswordSerializer(serializers.Serializer): def validate(self, data): if data.get("password") != data.get("re_password"): - raise serializers.ValidationError({"password": "رمز عبور مطابقت ندارد."}) + raise serializers.ValidationError({"password": "رمز عبور مطابقت ندارد."}) return data @@ -113,7 +118,7 @@ class ChangePasswordSerializer(serializers.Serializer): def validate(self, data): if data.get("new_password") != data.get("re_password"): - raise serializers.ValidationError({"new_password": "رمز عبور جدید و تکرار آن مطابقت ندارند."}) + raise serializers.ValidationError({"new_password": "رمز عبور جدید Ùˆ تکرار آن مطابقت ندارند."}) return data @@ -138,9 +143,16 @@ class UserProfileSerializer(BaseModelSerializer): class Meta: model = User fields = BaseModelSerializer.Meta.fields + ( - "mobile", "email", "first_name", "last_name", - "description", "profile_picture", "birth_date", - "is_verified", "full_name", "age" + "mobile", + "email", + "first_name", + "last_name", + "description", + "profile_picture", + "birth_date", + "is_verified", + "full_name", + "age", ) read_only_fields = BaseModelSerializer.Meta.fields + ("mobile", "is_verified") @@ -149,9 +161,9 @@ class UserSearchSerializer(serializers.ModelSerializer): class Meta: model = User fields = ( - 'id', - 'first_name', - 'last_name', - 'mobile', - 'profile_picture', + "id", + "first_name", + "last_name", + "mobile", + "profile_picture", ) diff --git a/apps/users/api/throttles.py b/apps/users/api/throttles.py index 9d49697..b592295 100644 --- a/apps/users/api/throttles.py +++ b/apps/users/api/throttles.py @@ -83,6 +83,35 @@ class ScopedMobileThrottle(SimpleRateThrottle): return allowed +class ScopedFlowMobileThrottle(ScopedMobileThrottle): + def get_mobile_identifier(self, request) -> str | None: + mobile = super().get_mobile_identifier(request) + if mobile: + return mobile + + try: + flow = request.data.get("flow") + except Exception: + flow = None + + if not isinstance(flow, str) or not flow: + return None + + try: + from apps.users.services.google_oauth import get_google_flow + + flow_payload = get_google_flow(flow) + except Exception: + return None + + mobile = flow_payload.get("mobile") + if not isinstance(mobile, str): + return None + + normalized = "".join(ch for ch in mobile if ch.isdigit()) + return normalized or None + + class OTPSendBurstThrottle(ScopedMobileThrottle): scope = "otp_send_burst" @@ -97,3 +126,15 @@ class PasswordLoginThrottle(ScopedMobileThrottle): class OTPLoginThrottle(ScopedMobileThrottle): scope = "login_otp" + + +class GoogleClaimSendBurstThrottle(ScopedFlowMobileThrottle): + scope = "otp_send_burst" + + +class GoogleClaimSendSustainedThrottle(ScopedFlowMobileThrottle): + scope = "otp_send_sustained" + + +class GoogleClaimVerifyThrottle(ScopedFlowMobileThrottle): + scope = "login_otp" diff --git a/apps/users/api/urls.py b/apps/users/api/urls.py index 02e09a1..af4fd01 100644 --- a/apps/users/api/urls.py +++ b/apps/users/api/urls.py @@ -9,9 +9,16 @@ app_name = "users" urlpatterns = [ path("register/", views.RegisterWithOTPView.as_view(), name="register_verify"), + path("register/password/", views.RegisterWithPasswordView.as_view(), name="register_password"), path("otp/send/", views.SendOTPView.as_view(), name="send_otp"), path("otp/login/", views.LoginOTPView.as_view(), name="login_otp"), path("login/", views.LoginView.as_view(), name="login"), + path("oauth/google/start/", views.GoogleOAuthStartView.as_view(), name="google_oauth_start"), + path("oauth/google/callback/", views.GoogleOAuthCallbackView.as_view(), name="google_oauth_callback"), + path("oauth/google/flow/", views.GoogleOAuthFlowView.as_view(), name="google_oauth_flow"), + path("oauth/google/complete/", views.GoogleOAuthCompleteView.as_view(), name="google_oauth_complete"), + path("oauth/google/claim/send-otp/", views.GoogleOAuthClaimSendOtpView.as_view(), name="google_oauth_claim_send_otp"), + path("oauth/google/claim/verify/", views.GoogleOAuthClaimVerifyView.as_view(), name="google_oauth_claim_verify"), path("logout/", views.LogoutView.as_view(), name="logout"), path("password/set/", views.SetPasswordView.as_view(), name="set_password"), path("password/reset/", views.ResetPasswordView.as_view(), name="reset_password"), diff --git a/apps/users/api/views.py b/apps/users/api/views.py index 1d85d1b..781fca3 100644 --- a/apps/users/api/views.py +++ b/apps/users/api/views.py @@ -1,3 +1,4 @@ +from django.http import HttpResponseRedirect from django.contrib.auth import get_user_model from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import extend_schema, inline_serializer @@ -19,6 +20,9 @@ from apps.users.api.serializers import ( ChangePasswordSerializer, LoginOtpSerializer, LoginSerializer, + GoogleOAuthClaimVerifySerializer, + GoogleOAuthCompleteSerializer, + GoogleOAuthFlowSerializer, RegisterSerializer, ResetPasswordSerializer, SendOTPSerializer, @@ -35,6 +39,9 @@ from apps.users.api.throttles import ( OTPSendBurstThrottle, OTPSendSustainedThrottle, PasswordLoginThrottle, + GoogleClaimSendBurstThrottle, + GoogleClaimSendSustainedThrottle, + GoogleClaimVerifyThrottle, ) from apps.users.services.auth import ( register_user_with_password, @@ -46,6 +53,20 @@ from apps.users.services.auth import ( change_password, logout_user ) +from apps.users.services.google_oauth import ( + build_authenticated_flow_payload, + build_google_authorization_url, + build_google_callback_redirect_url, + build_pending_google_flow_payload, + complete_google_signup, + consume_google_state, + create_google_flow, + exchange_code_for_google_profile, + find_social_account_for_profile, + get_google_flow, + send_google_claim_otp, + verify_google_claim, +) User = get_user_model() @@ -146,6 +167,88 @@ class LoginOTPView(APIView): return Response(tokens, status=status.HTTP_200_OK) +class GoogleOAuthStartView(APIView): + permission_classes = (AllowAny,) + + @extend_schema(responses=None) + def get(self, request): + return HttpResponseRedirect(build_google_authorization_url()) + + +class GoogleOAuthCallbackView(APIView): + permission_classes = (AllowAny,) + + @extend_schema(responses=None) + def get(self, request): + if request.query_params.get("error"): + raise serializers.ValidationError( + {"detail": request.query_params.get("error_description") or "Google sign-in was cancelled."} + ) + + consume_google_state(request.query_params.get("state")) + profile = exchange_code_for_google_profile(request.query_params.get("code")) + social_account = find_social_account_for_profile(profile) + + if social_account: + flow_payload = build_authenticated_flow_payload(social_account.user) + else: + flow_payload = build_pending_google_flow_payload(profile) + + flow = create_google_flow(flow_payload) + return HttpResponseRedirect(build_google_callback_redirect_url(flow)) + + +class GoogleOAuthFlowView(APIView): + permission_classes = (AllowAny,) + + @extend_schema(responses=None) + def get(self, request): + serializer = GoogleOAuthFlowSerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + return Response(get_google_flow(serializer.validated_data["flow"]), status=status.HTTP_200_OK) + + +class GoogleOAuthCompleteView(APIView): + permission_classes = (AllowAny,) + + @extend_schema(request=GoogleOAuthCompleteSerializer, responses=None) + def post(self, request): + serializer = GoogleOAuthCompleteSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = complete_google_signup( + flow=serializer.validated_data["flow"], + mobile=serializer.validated_data["mobile"], + ) + return Response(payload, status=status.HTTP_200_OK) + + +class GoogleOAuthClaimSendOtpView(APIView): + permission_classes = (AllowAny,) + throttle_classes = [GoogleClaimSendBurstThrottle, GoogleClaimSendSustainedThrottle] + + @extend_schema(request=GoogleOAuthFlowSerializer, responses=None) + def post(self, request): + serializer = GoogleOAuthFlowSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = send_google_claim_otp(serializer.validated_data["flow"]) + return Response(payload, status=status.HTTP_200_OK) + + +class GoogleOAuthClaimVerifyView(APIView): + permission_classes = (AllowAny,) + throttle_classes = [GoogleClaimVerifyThrottle] + + @extend_schema(request=GoogleOAuthClaimVerifySerializer, responses=None) + def post(self, request): + serializer = GoogleOAuthClaimVerifySerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = verify_google_claim( + flow=serializer.validated_data["flow"], + code=serializer.validated_data["code"], + ) + return Response(payload, status=status.HTTP_200_OK) + + class ResetPasswordView(APIView): permission_classes = (AllowAny,) serializer_class = ResetPasswordSerializer diff --git a/apps/users/migrations/0002_usersocialaccount.py b/apps/users/migrations/0002_usersocialaccount.py new file mode 100644 index 0000000..9dcceaa --- /dev/null +++ b/apps/users/migrations/0002_usersocialaccount.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.12 on 2026-04-30 20:35 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='UserSocialAccount', + fields=[ + ('id', models.UUIDField(default=uuid.uuid7, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('is_deleted', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=False)), + ('provider', models.CharField(choices=[('google', 'google')], max_length=32)), + ('provider_user_id', models.CharField(max_length=255)), + ('email', models.EmailField(blank=True, default='', max_length=254)), + ('email_verified', models.BooleanField(default=False)), + ('avatar_url', models.URLField(blank=True, default='')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL)), + ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='social_accounts', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'user_social_account', + 'verbose_name_plural': 'user_social_accounts', + 'db_table': 'user_social_account', + 'ordering': ('-updated_at', '-created_at'), + 'indexes': [models.Index(fields=['provider', 'provider_user_id'], name='user_social_provider_uid_idx'), models.Index(fields=['provider', 'email'], name='user_social_provider_email_idx')], + 'constraints': [models.UniqueConstraint(fields=('provider', 'provider_user_id'), name='user_social_account_provider_uid_uniq')], + }, + ), + ] diff --git a/apps/users/models.py b/apps/users/models.py index f6da783..7e7bd96 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -69,3 +69,34 @@ class LoginAttempt(BaseModel): def __str__(self): return f"LoginAttempt for User: {self.user} ({'✅' if self.status else '❌'})" + + +class UserSocialAccount(BaseModel): + class ProviderType(models.TextChoices): + GOOGLE = "google", "google" + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="social_accounts") + provider = models.CharField(max_length=32, choices=ProviderType.choices) + provider_user_id = models.CharField(max_length=255) + email = models.EmailField(blank=True, default="") + email_verified = models.BooleanField(default=False) + avatar_url = models.URLField(blank=True, default="") + + class Meta: + verbose_name = "user_social_account" + verbose_name_plural = "user_social_accounts" + db_table = "user_social_account" + ordering = ("-updated_at", "-created_at") + constraints = ( + models.UniqueConstraint( + fields=("provider", "provider_user_id"), + name="user_social_account_provider_uid_uniq", + ), + ) + indexes = ( + models.Index(fields=["provider", "provider_user_id"], name="user_social_provider_uid_idx"), + models.Index(fields=["provider", "email"], name="user_social_provider_email_idx"), + ) + + def __str__(self): + return f"{self.provider}:{self.provider_user_id}" diff --git a/apps/users/services/google_oauth.py b/apps/users/services/google_oauth.py new file mode 100644 index 0000000..b391e3a --- /dev/null +++ b/apps/users/services/google_oauth.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +import secrets +from dataclasses import asdict, dataclass, is_dataclass +from typing import Any +from urllib.parse import urlencode + +import requests +from django.conf import settings +from django.core.cache import cache +from rest_framework.exceptions import ValidationError + +from apps.users.models import User, UserSocialAccount +from apps.users.services.auth import generate_and_send_otp, get_tokens_for_user + + +GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" +GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo" + +GOOGLE_STATE_TTL_SECONDS = 300 +GOOGLE_FLOW_TTL_SECONDS = 900 + +GOOGLE_STATE_CACHE_PREFIX = "google_oauth_state" +GOOGLE_FLOW_CACHE_PREFIX = "google_oauth_flow" + + +@dataclass +class GoogleProfile: + provider_user_id: str + email: str + email_verified: bool + first_name: str + last_name: str + avatar_url: str + + +def _google_required_setting(name: str) -> str: + value = getattr(settings, name, "") + if not value: + raise ValidationError({"detail": f"{name} is not configured."}) + return value + + +def _cache_key(prefix: str, token: str) -> str: + return f"{prefix}:{token}" + + +def _create_token() -> str: + return secrets.token_urlsafe(32) + + +def create_google_state() -> str: + state = _create_token() + cache.set(_cache_key(GOOGLE_STATE_CACHE_PREFIX, state), {"valid": True}, GOOGLE_STATE_TTL_SECONDS) + return state + + +def consume_google_state(state: str) -> dict[str, Any]: + if not state: + raise ValidationError({"detail": "Missing OAuth state."}) + + key = _cache_key(GOOGLE_STATE_CACHE_PREFIX, state) + payload = cache.get(key) + cache.delete(key) + if not payload: + raise ValidationError({"detail": "Invalid or expired OAuth state."}) + return payload + + +def create_google_flow(payload: dict[str, Any]) -> str: + flow = _create_token() + cache.set(_cache_key(GOOGLE_FLOW_CACHE_PREFIX, flow), payload, GOOGLE_FLOW_TTL_SECONDS) + return flow + + +def get_google_flow(flow: str) -> dict[str, Any]: + payload = cache.get(_cache_key(GOOGLE_FLOW_CACHE_PREFIX, flow)) + if not payload: + raise ValidationError({"detail": "Google sign-in flow is invalid or expired."}) + return payload + + +def delete_google_flow(flow: str) -> None: + cache.delete(_cache_key(GOOGLE_FLOW_CACHE_PREFIX, flow)) + + +def update_google_flow(flow: str, payload: dict[str, Any]) -> None: + cache.set(_cache_key(GOOGLE_FLOW_CACHE_PREFIX, flow), payload, GOOGLE_FLOW_TTL_SECONDS) + + +def build_google_authorization_url() -> str: + state = create_google_state() + params = { + "client_id": _google_required_setting("GOOGLE_OAUTH_CLIENT_ID"), + "redirect_uri": _google_required_setting("GOOGLE_OAUTH_REDIRECT_URI"), + "response_type": "code", + "scope": "openid email profile", + "state": state, + "access_type": "online", + "prompt": "select_account", + } + return f"{GOOGLE_AUTH_URL}?{urlencode(params)}" + + +def exchange_code_for_google_profile(code: str) -> GoogleProfile: + if not code: + raise ValidationError({"detail": "Missing Google authorization code."}) + + try: + token_response = requests.post( + GOOGLE_TOKEN_URL, + data={ + "code": code, + "client_id": _google_required_setting("GOOGLE_OAUTH_CLIENT_ID"), + "client_secret": _google_required_setting("GOOGLE_OAUTH_CLIENT_SECRET"), + "redirect_uri": _google_required_setting("GOOGLE_OAUTH_REDIRECT_URI"), + "grant_type": "authorization_code", + }, + timeout=10, + ) + token_response.raise_for_status() + token_payload = token_response.json() + except requests.RequestException as exc: + raise ValidationError({"detail": "Google token exchange failed."}) from exc + + access_token = token_payload.get("access_token") + if not access_token: + raise ValidationError({"detail": "Google did not return an access token."}) + + try: + userinfo_response = requests.get( + GOOGLE_USERINFO_URL, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=10, + ) + userinfo_response.raise_for_status() + userinfo = userinfo_response.json() + except requests.RequestException as exc: + raise ValidationError({"detail": "Google user profile lookup failed."}) from exc + + provider_user_id = userinfo.get("sub", "") + email = userinfo.get("email", "") + email_verified = bool(userinfo.get("email_verified")) + + if not provider_user_id or not email or not email_verified: + raise ValidationError({"detail": "Google account must have a verified email address."}) + + return GoogleProfile( + provider_user_id=provider_user_id, + email=email, + email_verified=email_verified, + first_name=userinfo.get("given_name", "") or "", + last_name=userinfo.get("family_name", "") or "", + avatar_url=userinfo.get("picture", "") or "", + ) + + +def get_frontend_google_callback_url() -> str: + return _google_required_setting("GOOGLE_OAUTH_FRONTEND_CALLBACK_URL") + + +def build_google_callback_redirect_url(flow: str) -> str: + return f"{get_frontend_google_callback_url()}?flow={flow}" + + +def find_social_account_for_profile(profile: GoogleProfile) -> UserSocialAccount | None: + return ( + UserSocialAccount.objects.select_related("user") + .filter( + provider=UserSocialAccount.ProviderType.GOOGLE, + provider_user_id=profile.provider_user_id, + ) + .first() + ) + + +def build_authenticated_flow_payload(user: User) -> dict[str, Any]: + tokens = get_tokens_for_user(user) + return { + "status": "authenticated", + "access": tokens["access"], + "refresh": tokens["refresh"], + } + + +def build_pending_google_flow_payload(profile: GoogleProfile) -> dict[str, Any]: + profile_payload = asdict(profile) if is_dataclass(profile) else { + "provider_user_id": profile.provider_user_id, + "email": profile.email, + "email_verified": profile.email_verified, + "first_name": profile.first_name, + "last_name": profile.last_name, + "avatar_url": profile.avatar_url, + } + return { + "status": "collect_mobile", + "google_profile": profile_payload, + "email": profile.email, + "first_name": profile.first_name, + "last_name": profile.last_name, + "avatar_url": profile.avatar_url, + } + + +def _profile_from_flow(flow_payload: dict[str, Any]) -> GoogleProfile: + google_profile = flow_payload.get("google_profile") + if not isinstance(google_profile, dict): + raise ValidationError({"detail": "Google profile is missing from the flow."}) + return GoogleProfile(**google_profile) + + +def _normalize_mobile(mobile: str) -> str: + normalized = "".join(ch for ch in mobile if ch.isdigit()) + if len(normalized) != 11 or not normalized.startswith("09"): + raise ValidationError({"mobile": "Invalid mobile number."}) + return normalized + + +def complete_google_signup(flow: str, mobile: str) -> dict[str, Any]: + flow_payload = get_google_flow(flow) + if flow_payload.get("status") != "collect_mobile": + raise ValidationError({"detail": "Google sign-in flow is not ready for mobile completion."}) + + normalized_mobile = _normalize_mobile(mobile) + profile = _profile_from_flow(flow_payload) + existing_user = User.objects.filter(mobile=normalized_mobile).first() + + if existing_user: + generate_and_send_otp(normalized_mobile, "login") + claim_payload = { + "status": "claim_required", + "google_profile": asdict(profile), + "mobile": normalized_mobile, + "user_id": str(existing_user.id), + } + update_google_flow(flow, claim_payload) + return { + "status": "claim_required", + "mobile": normalized_mobile, + "detail": "Existing account found. Verify ownership to attach Google.", + } + + user = User.objects.create_user( + mobile=normalized_mobile, + password=None, + first_name=profile.first_name, + last_name=profile.last_name, + email=profile.email, + is_verified=False, + is_active=True, + ) + user.set_unusable_password() + user.save(update_fields=["password"]) + + UserSocialAccount.objects.create( + user=user, + provider=UserSocialAccount.ProviderType.GOOGLE, + provider_user_id=profile.provider_user_id, + email=profile.email, + email_verified=profile.email_verified, + avatar_url=profile.avatar_url, + is_active=True, + ) + + authenticated_payload = build_authenticated_flow_payload(user) + update_google_flow(flow, authenticated_payload) + return authenticated_payload + + +def send_google_claim_otp(flow: str) -> dict[str, Any]: + flow_payload = get_google_flow(flow) + if flow_payload.get("status") != "claim_required": + raise ValidationError({"detail": "Google sign-in flow is not waiting for claim verification."}) + + mobile = flow_payload.get("mobile") + if not isinstance(mobile, str) or not mobile: + raise ValidationError({"detail": "Claim mobile number is missing."}) + + generate_and_send_otp(mobile, "login") + return {"detail": "Verification code sent successfully."} + + +def verify_google_claim(flow: str, code: str) -> dict[str, Any]: + from django_redis import get_redis_connection + + flow_payload = get_google_flow(flow) + if flow_payload.get("status") != "claim_required": + raise ValidationError({"detail": "Google sign-in flow is not waiting for claim verification."}) + + mobile = flow_payload.get("mobile") + if not isinstance(mobile, str) or not mobile: + raise ValidationError({"detail": "Claim mobile number is missing."}) + + profile = _profile_from_flow(flow_payload) + user_id = flow_payload.get("user_id") + user = User.objects.filter(id=user_id, mobile=mobile).first() + if not user: + raise ValidationError({"detail": "Target account could not be found."}) + + redis_conn = get_redis_connection("default") + stored_code = redis_conn.get(f"verification_code:{mobile}") + if not stored_code or stored_code.decode("utf-8") != code: + raise ValidationError({"code": "Invalid or expired verification code."}) + + redis_conn.delete(f"verification_code:{mobile}") + + existing_link = find_social_account_for_profile(profile) + if existing_link and existing_link.user_id != user.id: + raise ValidationError({"detail": "This Google account is already attached to another user."}) + + if existing_link: + existing_link.email = profile.email + existing_link.email_verified = profile.email_verified + existing_link.avatar_url = profile.avatar_url + existing_link.is_active = True + existing_link.save( + update_fields=["email", "email_verified", "avatar_url", "is_active", "updated_at"] + ) + else: + UserSocialAccount.objects.create( + user=user, + provider=UserSocialAccount.ProviderType.GOOGLE, + provider_user_id=profile.provider_user_id, + email=profile.email, + email_verified=profile.email_verified, + avatar_url=profile.avatar_url, + is_active=True, + ) + + authenticated_payload = build_authenticated_flow_payload(user) + update_google_flow(flow, authenticated_payload) + return authenticated_payload diff --git a/apps/users/tests/test_api_views.py b/apps/users/tests/test_api_views.py index 1cefe95..caa558a 100644 --- a/apps/users/tests/test_api_views.py +++ b/apps/users/tests/test_api_views.py @@ -8,7 +8,7 @@ from rest_framework import status from rest_framework.test import APITestCase from apps.users.api.views import RegisterWithPasswordView -from apps.users.models import User +from apps.users.models import User, UserSocialAccount class UserApiViewTests(APITestCase): @@ -386,3 +386,207 @@ class UserThrottleTests(APITestCase): self.assertEqual(first.status_code, 400) self.assertEqual(second.status_code, 429) + + +@override_settings( + GOOGLE_OAUTH_CLIENT_ID="google-client-id", + GOOGLE_OAUTH_CLIENT_SECRET="google-client-secret", + GOOGLE_OAUTH_REDIRECT_URI="http://testserver/api/users/oauth/google/callback/", + GOOGLE_OAUTH_FRONTEND_CALLBACK_URL="http://localhost:5173/auth/google/callback", +) +class GoogleOAuthApiTests(APITestCase): + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user( + mobile="09125550001", + password="secret123", + first_name="Google", + last_name="Linked", + ) + + def setUp(self): + cache.clear() + + def tearDown(self): + cache.clear() + + def test_google_start_redirects_to_google_authorization_url(self): + response = self.client.get("/api/users/oauth/google/start/") + + self.assertEqual(response.status_code, 302) + self.assertIn("accounts.google.com", response["Location"]) + self.assertIn("state=", response["Location"]) + + @patch("apps.users.api.views.exchange_code_for_google_profile") + def test_google_callback_redirects_with_authenticated_flow_for_linked_account( + self, + exchange_code_for_google_profile, + ): + exchange_code_for_google_profile.return_value = type( + "Profile", + (), + { + "provider_user_id": "google-sub-1", + "email": "linked@example.com", + "email_verified": True, + "first_name": "Google", + "last_name": "Linked", + "avatar_url": "https://example.com/avatar.png", + }, + )() + UserSocialAccount.objects.create( + user=self.user, + provider=UserSocialAccount.ProviderType.GOOGLE, + provider_user_id="google-sub-1", + email="linked@example.com", + email_verified=True, + avatar_url="https://example.com/avatar.png", + ) + + start_response = self.client.get("/api/users/oauth/google/start/") + state = start_response["Location"].split("state=", 1)[1].split("&", 1)[0] + + response = self.client.get( + f"/api/users/oauth/google/callback/?state={state}&code=google-code", + ) + + self.assertEqual(response.status_code, 302) + self.assertIn("/auth/google/callback?flow=", response["Location"]) + flow = response["Location"].split("flow=", 1)[1] + + flow_response = self.client.get(f"/api/users/oauth/google/flow/?flow={flow}") + + self.assertEqual(flow_response.status_code, 200) + self.assertEqual(flow_response.data["status"], "authenticated") + self.assertIn("access", flow_response.data) + self.assertIn("refresh", flow_response.data) + + @patch("apps.users.api.views.exchange_code_for_google_profile") + def test_google_callback_redirects_with_mobile_collection_flow_for_new_account( + self, + exchange_code_for_google_profile, + ): + exchange_code_for_google_profile.return_value = type( + "Profile", + (), + { + "provider_user_id": "google-sub-2", + "email": "new@example.com", + "email_verified": True, + "first_name": "New", + "last_name": "User", + "avatar_url": "https://example.com/new-avatar.png", + }, + )() + + start_response = self.client.get("/api/users/oauth/google/start/") + state = start_response["Location"].split("state=", 1)[1].split("&", 1)[0] + + response = self.client.get( + f"/api/users/oauth/google/callback/?state={state}&code=google-code", + ) + flow = response["Location"].split("flow=", 1)[1] + + flow_response = self.client.get(f"/api/users/oauth/google/flow/?flow={flow}") + + self.assertEqual(flow_response.status_code, 200) + self.assertEqual(flow_response.data["status"], "collect_mobile") + self.assertEqual(flow_response.data["email"], "new@example.com") + + @patch("apps.users.services.google_oauth.generate_and_send_otp") + def test_google_complete_existing_mobile_moves_flow_to_claim_required(self, generate_and_send_otp): + cache.set( + "google_oauth_flow:test-flow", + { + "status": "collect_mobile", + "google_profile": { + "provider_user_id": "google-sub-3", + "email": "existing@example.com", + "email_verified": True, + "first_name": "Existing", + "last_name": "User", + "avatar_url": "https://example.com/existing.png", + }, + "email": "existing@example.com", + "first_name": "Existing", + "last_name": "User", + "avatar_url": "https://example.com/existing.png", + }, + 900, + ) + + response = self.client.post( + "/api/users/oauth/google/complete/", + {"flow": "test-flow", "mobile": self.user.mobile}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], "claim_required") + generate_and_send_otp.assert_called_once_with(self.user.mobile, "login") + + def test_google_complete_new_mobile_creates_user_and_link(self): + cache.set( + "google_oauth_flow:new-flow", + { + "status": "collect_mobile", + "google_profile": { + "provider_user_id": "google-sub-4", + "email": "created@example.com", + "email_verified": True, + "first_name": "Created", + "last_name": "User", + "avatar_url": "https://example.com/created.png", + }, + "email": "created@example.com", + "first_name": "Created", + "last_name": "User", + "avatar_url": "https://example.com/created.png", + }, + 900, + ) + + response = self.client.post( + "/api/users/oauth/google/complete/", + {"flow": "new-flow", "mobile": "09125550009"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], "authenticated") + created_user = User.objects.get(mobile="09125550009") + self.assertFalse(created_user.has_usable_password()) + self.assertTrue( + UserSocialAccount.objects.filter( + user=created_user, + provider=UserSocialAccount.ProviderType.GOOGLE, + provider_user_id="google-sub-4", + ).exists() + ) + + @patch("apps.users.api.views.send_google_claim_otp") + def test_google_claim_send_otp_endpoint_dispatches(self, send_google_claim_otp): + send_google_claim_otp.return_value = {"detail": "Verification code sent successfully."} + + response = self.client.post( + "/api/users/oauth/google/claim/send-otp/", + {"flow": "claim-flow"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + send_google_claim_otp.assert_called_once_with("claim-flow") + + @patch("apps.users.api.views.verify_google_claim") + def test_google_claim_verify_returns_tokens(self, verify_google_claim): + verify_google_claim.return_value = {"status": "authenticated", "access": "a", "refresh": "r"} + + response = self.client.post( + "/api/users/oauth/google/claim/verify/", + {"flow": "claim-flow", "code": "12345"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["access"], "a") + verify_google_claim.assert_called_once_with(flow="claim-flow", code="12345") diff --git a/config/settings/base.py b/config/settings/base.py index d4c6336..66a0aae 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -256,5 +256,9 @@ STORAGES = { SMS_APIKEY = os.getenv("SMS_APIKEY", "") BASE_URL = os.getenv("BASE_URL", "") +GOOGLE_OAUTH_CLIENT_ID = os.getenv("GOOGLE_OAUTH_CLIENT_ID", "") +GOOGLE_OAUTH_CLIENT_SECRET = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET", "") +GOOGLE_OAUTH_REDIRECT_URI = os.getenv("GOOGLE_OAUTH_REDIRECT_URI", "") +GOOGLE_OAUTH_FRONTEND_CALLBACK_URL = os.getenv("GOOGLE_OAUTH_FRONTEND_CALLBACK_URL", "") from config.services.auditlog import * # noqa: E402,F401,F403