fix(users): harden google oauth account resolution

This commit is contained in:
2026-05-14 21:17:37 +03:30
parent 09d2015351
commit cacf6114d1
2 changed files with 228 additions and 53 deletions

View File

@@ -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