diff --git a/apps/users/services/google_oauth.py b/apps/users/services/google_oauth.py index b391e3a..eb407d7 100644 --- a/apps/users/services/google_oauth.py +++ b/apps/users/services/google_oauth.py @@ -8,8 +8,9 @@ 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 rest_framework.exceptions import APIException, ValidationError +from apps.users.email_identity import mask_mobile, normalize_email_identity from apps.users.models import User, UserSocialAccount from apps.users.services.auth import generate_and_send_otp, get_tokens_for_user @@ -25,6 +26,25 @@ GOOGLE_STATE_CACHE_PREFIX = "google_oauth_state" GOOGLE_FLOW_CACHE_PREFIX = "google_oauth_flow" +class GoogleOAuthFlowError(APIException): + status_code = 409 + default_detail = "Google sign-in could not be completed." + default_code = "google_flow_error" + + def __init__( + self, + detail: str, + code: str, + *, + status_code: int | None = None, + extra: dict[str, Any] | None = None, + ) -> None: + if status_code is not None: + self.status_code = status_code + self.payload_extra = extra or {} + super().__init__(detail=detail, code=code) + + @dataclass class GoogleProfile: provider_user_id: str @@ -50,6 +70,97 @@ def _create_token() -> str: return secrets.token_urlsafe(32) +def _invalid_flow_error(message: str = "Google sign-in flow is invalid or expired.") -> GoogleOAuthFlowError: + return GoogleOAuthFlowError(message, "google_flow_invalid_state", status_code=400) + + +def _ensure_flow_status(flow_payload: dict[str, Any], expected_status: str) -> None: + if flow_payload.get("status") != expected_status: + raise _invalid_flow_error("Google sign-in flow is in an unexpected state.") + + +def _profile_to_payload(profile: GoogleProfile) -> dict[str, Any]: + return 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, + } + + +def _profile_from_flow(flow_payload: dict[str, Any]) -> GoogleProfile: + google_profile = flow_payload.get("google_profile") + if not isinstance(google_profile, dict): + raise _invalid_flow_error("Google profile data is missing from the flow.") + return GoogleProfile(**google_profile) + + +def _find_user_by_email(email: str | None) -> User | None: + normalized_email = normalize_email_identity(email) + if normalized_email is None: + return None + return User.objects.filter(email=normalized_email).first() + + +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 _build_claim_required_payload( + *, + profile: GoogleProfile, + user: User, + mobile: str, + resolution: str, + detail: str, +) -> dict[str, Any]: + return { + "status": "claim_required", + "google_profile": _profile_to_payload(profile), + "mobile": mobile, + "user_id": str(user.id), + "resolution": resolution, + "email": profile.email, + "mobile_hint": mask_mobile(user.mobile), + "detail": detail, + } + + +def _build_public_google_flow_payload(flow_payload: dict[str, Any]) -> dict[str, Any]: + status = flow_payload.get("status") + if status == "authenticated": + return { + "status": "authenticated", + "access": flow_payload.get("access", ""), + "refresh": flow_payload.get("refresh", ""), + } + if status == "collect_mobile": + return { + "status": "collect_mobile", + "email": flow_payload.get("email", ""), + "first_name": flow_payload.get("first_name", ""), + "last_name": flow_payload.get("last_name", ""), + "avatar_url": flow_payload.get("avatar_url", ""), + "resolution": flow_payload.get("resolution", "new_account"), + "mobile_hint": flow_payload.get("mobile_hint"), + } + if status == "claim_required": + return { + "status": "claim_required", + "mobile": flow_payload.get("mobile", ""), + "detail": flow_payload.get("detail", ""), + "resolution": flow_payload.get("resolution", "existing_mobile_claim"), + "email": flow_payload.get("email", ""), + "mobile_hint": flow_payload.get("mobile_hint"), + } + raise _invalid_flow_error("Google sign-in flow is in an unknown state.") + + def create_google_state() -> str: state = _create_token() cache.set(_cache_key(GOOGLE_STATE_CACHE_PREFIX, state), {"valid": True}, GOOGLE_STATE_TTL_SECONDS) @@ -74,13 +185,17 @@ def create_google_flow(payload: dict[str, Any]) -> str: return flow -def get_google_flow(flow: str) -> dict[str, Any]: +def get_google_flow_payload(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."}) + raise _invalid_flow_error() return payload +def get_google_flow(flow: str) -> dict[str, Any]: + return _build_public_google_flow_payload(get_google_flow_payload(flow)) + + def delete_google_flow(flow: str) -> None: cache.delete(_cache_key(GOOGLE_FLOW_CACHE_PREFIX, flow)) @@ -140,7 +255,7 @@ def exchange_code_for_google_profile(code: str) -> GoogleProfile: raise ValidationError({"detail": "Google user profile lookup failed."}) from exc provider_user_id = userinfo.get("sub", "") - email = userinfo.get("email", "") + email = normalize_email_identity(userinfo.get("email")) email_verified = bool(userinfo.get("email_verified")) if not provider_user_id or not email or not email_verified: @@ -185,61 +300,73 @@ def build_authenticated_flow_payload(user: User) -> dict[str, Any]: 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, - } + existing_email_user = _find_user_by_email(profile.email) return { "status": "collect_mobile", - "google_profile": profile_payload, + "google_profile": _profile_to_payload(profile), "email": profile.email, "first_name": profile.first_name, "last_name": profile.last_name, "avatar_url": profile.avatar_url, + "resolution": "existing_email_claim" if existing_email_user else "new_account", + "target_user_id": str(existing_email_user.id) if existing_email_user else None, + "mobile_hint": mask_mobile(existing_email_user.mobile) if existing_email_user else None, } -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."}) + flow_payload = get_google_flow_payload(flow) + _ensure_flow_status(flow_payload, "collect_mobile") normalized_mobile = _normalize_mobile(mobile) profile = _profile_from_flow(flow_payload) - existing_user = User.objects.filter(mobile=normalized_mobile).first() + email_matched_user = None + + target_user_id = flow_payload.get("target_user_id") + if target_user_id: + email_matched_user = User.objects.filter(id=target_user_id).first() + if email_matched_user is None: + raise _invalid_flow_error("The Google sign-in target account could not be found.") + + existing_mobile_user = User.objects.filter(mobile=normalized_mobile).first() + + if email_matched_user is not None: + if normalized_mobile != email_matched_user.mobile: + raise GoogleOAuthFlowError( + "This Google account must be verified with the mobile number already attached to the matching account.", + "google_email_mobile_conflict", + extra={"mobile_hint": mask_mobile(email_matched_user.mobile)}, + ) - 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), - } + claim_payload = _build_claim_required_payload( + profile=profile, + user=email_matched_user, + mobile=normalized_mobile, + resolution="existing_email_claim", + detail="Existing account found for this email. Verify the linked mobile number to attach Google.", + ) update_google_flow(flow, claim_payload) - return { - "status": "claim_required", - "mobile": normalized_mobile, - "detail": "Existing account found. Verify ownership to attach Google.", - } + return _build_public_google_flow_payload(claim_payload) + + if existing_mobile_user is not None: + existing_mobile_email = normalize_email_identity(existing_mobile_user.email) + if existing_mobile_email: + raise GoogleOAuthFlowError( + "This mobile number already belongs to another account with a different email address.", + "google_mobile_belongs_to_other_email", + ) + + generate_and_send_otp(normalized_mobile, "login") + claim_payload = _build_claim_required_payload( + profile=profile, + user=existing_mobile_user, + mobile=normalized_mobile, + resolution="existing_mobile_claim", + detail="Existing mobile account found. Verify ownership to attach Google and set the verified email address.", + ) + update_google_flow(flow, claim_payload) + return _build_public_google_flow_payload(claim_payload) user = User.objects.create_user( mobile=normalized_mobile, @@ -269,13 +396,12 @@ def complete_google_signup(flow: str, mobile: str) -> dict[str, Any]: 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."}) + flow_payload = get_google_flow_payload(flow) + _ensure_flow_status(flow_payload, "claim_required") mobile = flow_payload.get("mobile") if not isinstance(mobile, str) or not mobile: - raise ValidationError({"detail": "Claim mobile number is missing."}) + raise _invalid_flow_error("Claim mobile number is missing.") generate_and_send_otp(mobile, "login") return {"detail": "Verification code sent successfully."} @@ -284,19 +410,18 @@ def send_google_claim_otp(flow: str) -> dict[str, Any]: 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."}) + flow_payload = get_google_flow_payload(flow) + _ensure_flow_status(flow_payload, "claim_required") mobile = flow_payload.get("mobile") if not isinstance(mobile, str) or not mobile: - raise ValidationError({"detail": "Claim mobile number is missing."}) + raise _invalid_flow_error("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."}) + raise _invalid_flow_error("Target account could not be found.") redis_conn = get_redis_connection("default") stored_code = redis_conn.get(f"verification_code:{mobile}") @@ -305,9 +430,32 @@ def verify_google_claim(flow: str, code: str) -> dict[str, Any]: redis_conn.delete(f"verification_code:{mobile}") + resolution = flow_payload.get("resolution") + user_email = normalize_email_identity(user.email) + + if resolution == "existing_email_claim" and user_email != profile.email: + raise GoogleOAuthFlowError( + "The matching email account could not be verified for this mobile number.", + "google_email_mobile_conflict", + extra={"mobile_hint": mask_mobile(user.mobile)}, + ) + + if resolution == "existing_mobile_claim" and user_email not in (None, profile.email): + raise GoogleOAuthFlowError( + "This mobile number already belongs to another account with a different email address.", + "google_mobile_belongs_to_other_email", + ) + 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."}) + raise GoogleOAuthFlowError( + "This Google account is already attached to another user.", + "google_email_already_claimed", + ) + + if user_email is None: + user.email = profile.email + user.save(update_fields=["email"]) if existing_link: existing_link.email = profile.email diff --git a/core/exceptions/handlers.py b/core/exceptions/handlers.py index e6ad259..a9e7df8 100644 --- a/core/exceptions/handlers.py +++ b/core/exceptions/handlers.py @@ -15,6 +15,26 @@ from rest_framework.views import exception_handler as drf_exception_handler logger = logging.getLogger(__name__) +def _extract_code(value: Any) -> str | None: + if isinstance(value, ErrorDetail): + return str(value.code) if getattr(value, "code", None) else None + if isinstance(value, list | tuple): + for item in value: + code = _extract_code(item) + if code: + return code + return None + if isinstance(value, dict): + for item in value.values(): + code = _extract_code(item) + if code: + return code + return None + if isinstance(value, str): + return None + return getattr(value, "code", None) + + def _flatten_messages(values: Iterable) -> list[str]: items: list[str] = [] for value in values: @@ -107,6 +127,13 @@ def exception_handler(exc, context) -> Response: else None, } ) + else: + code = _extract_code(detail) + if code: + payload["code"] = code + extra_payload = getattr(exc, "payload_extra", None) + if isinstance(extra_payload, dict): + payload.update(extra_payload) formatted_response = Response(payload, status=status_code) for header, value in response.headers.items(): formatted_response[header] = value