fix(users): harden google oauth account resolution
This commit is contained in:
@@ -8,8 +8,9 @@ from urllib.parse import urlencode
|
|||||||
import requests
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
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.models import User, UserSocialAccount
|
||||||
from apps.users.services.auth import generate_and_send_otp, get_tokens_for_user
|
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"
|
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
|
@dataclass
|
||||||
class GoogleProfile:
|
class GoogleProfile:
|
||||||
provider_user_id: str
|
provider_user_id: str
|
||||||
@@ -50,6 +70,97 @@ def _create_token() -> str:
|
|||||||
return secrets.token_urlsafe(32)
|
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:
|
def create_google_state() -> str:
|
||||||
state = _create_token()
|
state = _create_token()
|
||||||
cache.set(_cache_key(GOOGLE_STATE_CACHE_PREFIX, state), {"valid": True}, GOOGLE_STATE_TTL_SECONDS)
|
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
|
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))
|
payload = cache.get(_cache_key(GOOGLE_FLOW_CACHE_PREFIX, flow))
|
||||||
if not payload:
|
if not payload:
|
||||||
raise ValidationError({"detail": "Google sign-in flow is invalid or expired."})
|
raise _invalid_flow_error()
|
||||||
return payload
|
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:
|
def delete_google_flow(flow: str) -> None:
|
||||||
cache.delete(_cache_key(GOOGLE_FLOW_CACHE_PREFIX, flow))
|
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
|
raise ValidationError({"detail": "Google user profile lookup failed."}) from exc
|
||||||
|
|
||||||
provider_user_id = userinfo.get("sub", "")
|
provider_user_id = userinfo.get("sub", "")
|
||||||
email = userinfo.get("email", "")
|
email = normalize_email_identity(userinfo.get("email"))
|
||||||
email_verified = bool(userinfo.get("email_verified"))
|
email_verified = bool(userinfo.get("email_verified"))
|
||||||
|
|
||||||
if not provider_user_id or not email or not 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]:
|
def build_pending_google_flow_payload(profile: GoogleProfile) -> dict[str, Any]:
|
||||||
profile_payload = asdict(profile) if is_dataclass(profile) else {
|
existing_email_user = _find_user_by_email(profile.email)
|
||||||
"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 {
|
return {
|
||||||
"status": "collect_mobile",
|
"status": "collect_mobile",
|
||||||
"google_profile": profile_payload,
|
"google_profile": _profile_to_payload(profile),
|
||||||
"email": profile.email,
|
"email": profile.email,
|
||||||
"first_name": profile.first_name,
|
"first_name": profile.first_name,
|
||||||
"last_name": profile.last_name,
|
"last_name": profile.last_name,
|
||||||
"avatar_url": profile.avatar_url,
|
"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]:
|
def complete_google_signup(flow: str, mobile: str) -> dict[str, Any]:
|
||||||
flow_payload = get_google_flow(flow)
|
flow_payload = get_google_flow_payload(flow)
|
||||||
if flow_payload.get("status") != "collect_mobile":
|
_ensure_flow_status(flow_payload, "collect_mobile")
|
||||||
raise ValidationError({"detail": "Google sign-in flow is not ready for mobile completion."})
|
|
||||||
|
|
||||||
normalized_mobile = _normalize_mobile(mobile)
|
normalized_mobile = _normalize_mobile(mobile)
|
||||||
profile = _profile_from_flow(flow_payload)
|
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")
|
generate_and_send_otp(normalized_mobile, "login")
|
||||||
claim_payload = {
|
claim_payload = _build_claim_required_payload(
|
||||||
"status": "claim_required",
|
profile=profile,
|
||||||
"google_profile": asdict(profile),
|
user=email_matched_user,
|
||||||
"mobile": normalized_mobile,
|
mobile=normalized_mobile,
|
||||||
"user_id": str(existing_user.id),
|
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)
|
update_google_flow(flow, claim_payload)
|
||||||
return {
|
return _build_public_google_flow_payload(claim_payload)
|
||||||
"status": "claim_required",
|
|
||||||
"mobile": normalized_mobile,
|
if existing_mobile_user is not None:
|
||||||
"detail": "Existing account found. Verify ownership to attach Google.",
|
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(
|
user = User.objects.create_user(
|
||||||
mobile=normalized_mobile,
|
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]:
|
def send_google_claim_otp(flow: str) -> dict[str, Any]:
|
||||||
flow_payload = get_google_flow(flow)
|
flow_payload = get_google_flow_payload(flow)
|
||||||
if flow_payload.get("status") != "claim_required":
|
_ensure_flow_status(flow_payload, "claim_required")
|
||||||
raise ValidationError({"detail": "Google sign-in flow is not waiting for claim verification."})
|
|
||||||
|
|
||||||
mobile = flow_payload.get("mobile")
|
mobile = flow_payload.get("mobile")
|
||||||
if not isinstance(mobile, str) or not 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")
|
generate_and_send_otp(mobile, "login")
|
||||||
return {"detail": "Verification code sent successfully."}
|
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]:
|
def verify_google_claim(flow: str, code: str) -> dict[str, Any]:
|
||||||
from django_redis import get_redis_connection
|
from django_redis import get_redis_connection
|
||||||
|
|
||||||
flow_payload = get_google_flow(flow)
|
flow_payload = get_google_flow_payload(flow)
|
||||||
if flow_payload.get("status") != "claim_required":
|
_ensure_flow_status(flow_payload, "claim_required")
|
||||||
raise ValidationError({"detail": "Google sign-in flow is not waiting for claim verification."})
|
|
||||||
|
|
||||||
mobile = flow_payload.get("mobile")
|
mobile = flow_payload.get("mobile")
|
||||||
if not isinstance(mobile, str) or not 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)
|
profile = _profile_from_flow(flow_payload)
|
||||||
user_id = flow_payload.get("user_id")
|
user_id = flow_payload.get("user_id")
|
||||||
user = User.objects.filter(id=user_id, mobile=mobile).first()
|
user = User.objects.filter(id=user_id, mobile=mobile).first()
|
||||||
if not user:
|
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")
|
redis_conn = get_redis_connection("default")
|
||||||
stored_code = redis_conn.get(f"verification_code:{mobile}")
|
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}")
|
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)
|
existing_link = find_social_account_for_profile(profile)
|
||||||
if existing_link and existing_link.user_id != user.id:
|
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:
|
if existing_link:
|
||||||
existing_link.email = profile.email
|
existing_link.email = profile.email
|
||||||
|
|||||||
@@ -15,6 +15,26 @@ from rest_framework.views import exception_handler as drf_exception_handler
|
|||||||
logger = logging.getLogger(__name__)
|
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]:
|
def _flatten_messages(values: Iterable) -> list[str]:
|
||||||
items: list[str] = []
|
items: list[str] = []
|
||||||
for value in values:
|
for value in values:
|
||||||
@@ -107,6 +127,13 @@ def exception_handler(exc, context) -> Response:
|
|||||||
else None,
|
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)
|
formatted_response = Response(payload, status=status_code)
|
||||||
for header, value in response.headers.items():
|
for header, value in response.headers.items():
|
||||||
formatted_response[header] = value
|
formatted_response[header] = value
|
||||||
|
|||||||
Reference in New Issue
Block a user