From 4a6f6a08fbf99f6972f2514476553f45bb561cf7 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Thu, 14 May 2026 23:24:09 +0330 Subject: [PATCH] fix(users): require otp verification before google signup --- apps/users/services/google_oauth.py | 127 ++++++++++++++++++++-------- 1 file changed, 92 insertions(+), 35 deletions(-) diff --git a/apps/users/services/google_oauth.py b/apps/users/services/google_oauth.py index 76c0bd9..84d9615 100644 --- a/apps/users/services/google_oauth.py +++ b/apps/users/services/google_oauth.py @@ -173,6 +173,32 @@ def _build_claim_required_payload( } +def _create_user_and_social_account_from_google_profile(*, mobile: str, profile: GoogleProfile) -> User: + user = User.objects.create_user( + mobile=mobile, + password=None, + first_name=profile.first_name, + last_name=profile.last_name, + email=profile.email, + is_verified=True, + is_active=True, + ) + user.set_unusable_password() + user.save(update_fields=["password"]) + sync_user_from_google_profile(user, profile) + + 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, + ) + return user + + def _build_public_google_flow_payload(flow_payload: dict[str, Any]) -> dict[str, Any]: status = flow_payload.get("status") if status == "authenticated": @@ -410,32 +436,62 @@ def complete_google_signup(flow: str, mobile: str) -> dict[str, Any]: update_google_flow(flow, claim_payload) return _build_public_google_flow_payload(claim_payload) - 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"]) - sync_user_from_google_profile(user, profile) + generate_and_send_otp(normalized_mobile, "register") + signup_payload = { + "status": "claim_required", + "google_profile": _profile_to_payload(profile), + "mobile": normalized_mobile, + "user_id": None, + "resolution": "new_account", + "email": profile.email, + "mobile_hint": None, + "detail": "Verify this mobile number to finish creating your account with Google.", + } + update_google_flow(flow, signup_payload) + return _build_public_google_flow_payload(signup_payload) - 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 _verify_otp_code(*, mobile: str, code: str) -> None: + from django_redis import get_redis_connection + + 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}") + + +def _create_new_google_user_after_otp(*, mobile: str, profile: GoogleProfile) -> User: + existing_email_user = _find_user_by_email(profile.email) + if existing_email_user is not None: + raise GoogleOAuthFlowError( + "This Google email is already attached to an existing account.", + "google_email_already_claimed", + extra={"mobile_hint": mask_mobile(existing_email_user.mobile)}, + ) + + existing_mobile_user = User.objects.filter(mobile=mobile).first() + 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", + ) + raise GoogleOAuthFlowError( + "This mobile number is no longer available for creating a new Google account.", + "google_flow_invalid_state", + status_code=409, + ) + + existing_link = find_social_account_for_profile(profile) + if existing_link is not None: + raise GoogleOAuthFlowError( + "This Google account is already attached to another user.", + "google_email_already_claimed", + ) + + return _create_user_and_social_account_from_google_profile(mobile=mobile, profile=profile) def send_google_claim_otp(flow: str) -> dict[str, Any]: @@ -446,13 +502,13 @@ def send_google_claim_otp(flow: str) -> dict[str, Any]: if not isinstance(mobile, str) or not mobile: raise _invalid_flow_error("Claim mobile number is missing.") - generate_and_send_otp(mobile, "login") + resolution = flow_payload.get("resolution") + otp_mode = "register" if resolution == "new_account" else "login" + generate_and_send_otp(mobile, otp_mode) 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_payload(flow) _ensure_flow_status(flow_payload, "claim_required") @@ -462,18 +518,19 @@ def verify_google_claim(flow: str, code: str) -> dict[str, Any]: profile = _profile_from_flow(flow_payload) user_id = flow_payload.get("user_id") + resolution = flow_payload.get("resolution") + _verify_otp_code(mobile=mobile, code=code) + + if resolution == "new_account": + user = _create_new_google_user_after_otp(mobile=mobile, profile=profile) + authenticated_payload = build_authenticated_flow_payload(user) + update_google_flow(flow, authenticated_payload) + return authenticated_payload + user = User.objects.filter(id=user_id, mobile=mobile).first() if not user: 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}") - 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}") - - resolution = flow_payload.get("resolution") user_email = normalize_email_identity(user.email) if resolution == "existing_email_claim" and user_email != profile.email: