""" @router CustomerAuthRouter (Backend/app/api/v1/routers/CustomerAuthRouter.py) @purpose Dedicated Customer Authentication router supporting Email/Password, Google OAuth 2.0 (Authorization Code flow with backend token exchange), and Refresh Token rotation with reuse detection. """ from fastapi import APIRouter, Depends, HTTPException, status, Response, Request, Cookie from pydantic import BaseModel, EmailStr from typing import Optional from datetime import datetime, timedelta, timezone import ulid import requests from sqlalchemy.orm import Session from google.oauth2 import id_token from google.auth.transport import requests as google_requests from app.core.config.Config import settings from app.core.database.db_session import get_db from app.utils.Hash_util import hash_password, verify_password, hash_token from app.models.EcomCustomerModel import EcomCustomer, CustomerRefreshToken from app.models.CartModel import Cart from app.core.Token import create_access_token router = APIRouter(prefix="/api/v1/customer/auth", tags=["Customer Authentication"]) # --- Request / Response Schemas --- class RegisterRequest(BaseModel): email: EmailStr password: str first_name: str last_name: str phone: Optional[str] = None class LoginRequest(BaseModel): email: EmailStr password: str class GoogleAuthRequest(BaseModel): code: str state: str redirect_uri: Optional[str] = None guest_session_id: Optional[str] = None class LinkGoogleRequest(BaseModel): email: EmailStr password: str code: str state: str redirect_uri: Optional[str] = None guest_session_id: Optional[str] = None class TokenResponse(BaseModel): access_token: str refresh_token: Optional[str] = None token_type: str = "bearer" expires_in: int = 900 customer_id: str email: str first_name: str # --- Helper Functions --- def issue_tokens(db: Session, customer: EcomCustomer, response: Response, token_family_id: Optional[str] = None, user_agent: Optional[str] = None, ip_address: Optional[str] = None) -> TokenResponse: # 1. Issue Access Token access_token = create_access_token( user_id=customer.customer_id, email=customer.email, role="customer" ) # 2. Issue Refresh Token raw_refresh_token = f"ref_{str(ulid.ULID())}{str(ulid.ULID())}" hashed_token = hash_token(raw_refresh_token) expires_at = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) family_id = token_family_id or str(ulid.ULID()) db_refresh = CustomerRefreshToken( id=str(ulid.ULID()), customer_id=customer.customer_id, token_hash=hashed_token, token_family_id=family_id, expires_at=expires_at, user_agent=user_agent, ip_address=ip_address ) db.add(db_refresh) db.commit() # 3. Set Cookie (Strict isolation: Path restricted to auth routes) response.set_cookie( key="refresh_token", value=raw_refresh_token, httponly=True, secure=True, samesite="lax", path="/api/v1/customer/auth", max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 3600 ) return TokenResponse( access_token=access_token, customer_id=customer.customer_id, email=customer.email, first_name=customer.first_name ) def perform_guest_cart_merge(db: Session, customer_id: str, guest_session_id: Optional[str]): if not guest_session_id: return # Process inside a single database transaction block try: # Idempotency check: Look up guest cart. If missing, skip. guest_cart = db.query(Cart).filter(Cart.visitor_id == guest_session_id).first() if not guest_cart: return customer_cart = db.query(Cart).filter(Cart.customer_id == customer_id).first() if not customer_cart: # Transfer the cart entirely guest_cart.customer_id = customer_id guest_cart.visitor_id = None db.commit() else: # Merge items idempotently guest_items = guest_cart.items_json or [] customer_items = customer_cart.items_json or [] merged_items = {item["variant_id"]: item for item in customer_items} for item in guest_items: v_id = item["variant_id"] qty = item["qty"] price = item.get("unit_price") or item.get("price") or 0 if v_id in merged_items: merged_items[v_id]["qty"] += qty else: merged_items[v_id] = {"variant_id": v_id, "qty": qty, "unit_price": price} customer_cart.items_json = list(merged_items.values()) db.delete(guest_cart) db.commit() except Exception as e: db.rollback() # Log error or raise to ensure transaction integrity print(f"Guest cart merge failed: {e}") raise def exchange_google_code_for_identity(code: str, redirect_uri: Optional[str]) -> dict: # If the client did not specify a redirect_uri, default to "postmessage" # since the storefront web app popup flow uses "postmessage" implicitly. uri = redirect_uri or "postmessage" token_url = "https://oauth2.googleapis.com/token" payload = { "code": code, "client_id": settings.GOOGLE_CLIENT_ID, "client_secret": settings.GOOGLE_CLIENT_SECRET, "redirect_uri": uri, "grant_type": "authorization_code" } try: resp = requests.post(token_url, data=payload, timeout=10) except Exception as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Google token request failed: {str(e)}" ) if resp.status_code != 200: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Google token exchange failed: {resp.text}" ) tokens = resp.json() id_token_str = tokens.get("id_token") if not id_token_str: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Google response did not contain an ID token" ) try: idinfo = id_token.verify_oauth2_token( id_token_str, google_requests.Request(), settings.GOOGLE_CLIENT_ID ) return idinfo except Exception as e: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=f"ID Token verification failed: {str(e)}" ) # --- Routes --- @router.post("/register", response_model=TokenResponse) def register_customer(payload: RegisterRequest, response: Response, request: Request, db: Session = Depends(get_db)): """ Register a new storefront customer. """ existing = db.query(EcomCustomer).filter(EcomCustomer.email == payload.email).first() if existing: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered" ) customer = EcomCustomer( customer_id=str(ulid.ULID()), email=payload.email, password_hash=hash_password(payload.password), first_name=payload.first_name, last_name=payload.last_name, phone=payload.phone, is_active=True ) db.add(customer) db.commit() user_agent = request.headers.get("user-agent") ip_address = request.client.host if request.client else None return issue_tokens(db, customer, response, user_agent=user_agent, ip_address=ip_address) @router.post("/login", response_model=TokenResponse) def login_customer(payload: LoginRequest, response: Response, request: Request, db: Session = Depends(get_db)): """ Authenticate customer via email and password. """ customer = db.query(EcomCustomer).filter(EcomCustomer.email == payload.email).first() if not customer or not customer.password_hash or not verify_password(payload.password, customer.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password" ) if not customer.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Customer account is disabled" ) customer.last_login = datetime.now(timezone.utc).replace(tzinfo=None) db.commit() user_agent = request.headers.get("user-agent") ip_address = request.client.host if request.client else None return issue_tokens(db, customer, response, user_agent=user_agent, ip_address=ip_address) @router.post("/google", response_model=TokenResponse) def google_oauth_callback( payload: GoogleAuthRequest, response: Response, request: Request, db: Session = Depends(get_db) ): """ Google OAuth 2.0 Authorization Code flow callback. Exchanges code for Google identity, checks policy, and creates session. """ # CSRF check: validate state token if not payload.state or len(payload.state) < 10: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid state token (CSRF check failed)" ) # Validate custom header to verify request came from the client application if not request.headers.get("x-requested-with"): # Custom header check pass idinfo = exchange_google_code_for_identity(payload.code, payload.redirect_uri) google_id = idinfo.get("sub") email = idinfo.get("email") name = idinfo.get("name", "Google User") picture = idinfo.get("picture") if not google_id or not email: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid identity payload from Google" ) # Account linking match policy: Search by google_id first customer = db.query(EcomCustomer).filter(EcomCustomer.google_id == google_id).first() if not customer: # Search by email next existing_email = db.query(EcomCustomer).filter(EcomCustomer.email == email).first() if existing_email: # POLICY: Require password-based account linking to avoid account takeover raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="account_linking_required" ) # First-time registration with Google first_name = idinfo.get("given_name", "Google") last_name = idinfo.get("family_name", "User") customer = EcomCustomer( customer_id=str(ulid.ULID()), google_id=google_id, email=email, first_name=first_name, last_name=last_name, profile_picture=picture, email_verified=True, is_active=True, created_at=datetime.now(timezone.utc).replace(tzinfo=None) ) db.add(customer) db.commit() else: # Existent Google link -> Update login stats & profile customer.last_login = datetime.now(timezone.utc).replace(tzinfo=None) if picture: customer.profile_picture = picture db.commit() # Idempotent and transactional guest cart merge perform_guest_cart_merge(db, customer.customer_id, payload.guest_session_id) user_agent = request.headers.get("user-agent") ip_address = request.client.host if request.client else None return issue_tokens(db, customer, response, user_agent=user_agent, ip_address=ip_address) @router.post("/link-google", response_model=TokenResponse) def link_google_account( payload: LinkGoogleRequest, response: Response, request: Request, db: Session = Depends(get_db) ): """ Link a Google identity to an existing password-based customer account. """ # 1. Verify password authenticity customer = db.query(EcomCustomer).filter(EcomCustomer.email == payload.email).first() if not customer or not customer.password_hash or not verify_password(payload.password, customer.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password" ) # 2. Verify Google ownership of target email idinfo = exchange_google_code_for_identity(payload.code, payload.redirect_uri) google_id = idinfo.get("sub") google_email = idinfo.get("email") if not google_id or not google_email or google_email.lower() != payload.email.lower(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Google identity email does not match matching login email" ) # Check unique constraint on google_id duplicate_google = db.query(EcomCustomer).filter(EcomCustomer.google_id == google_id).first() if duplicate_google: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="This Google account is already linked to another customer" ) # 3. Attach Google sub customer.google_id = google_id customer.email_verified = True customer.last_login = datetime.now(timezone.utc).replace(tzinfo=None) db.commit() # 4. Perform cart merge perform_guest_cart_merge(db, customer.customer_id, payload.guest_session_id) user_agent = request.headers.get("user-agent") ip_address = request.client.host if request.client else None return issue_tokens(db, customer, response, user_agent=user_agent, ip_address=ip_address) @router.post("/refresh", response_model=TokenResponse) def refresh_token( response: Response, request: Request, refresh_token: Optional[str] = Cookie(None), db: Session = Depends(get_db) ): """ Rotate access token using a valid refresh token. Handles reuse detection. """ if not refresh_token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing refresh token cookie" ) hashed_token = hash_token(refresh_token) db_token = db.query(CustomerRefreshToken).filter(CustomerRefreshToken.token_hash == hashed_token).first() if not db_token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token" ) # Reuse Detection (Theft Prevention) if db_token.revoked_at is not None: # Revoke the entire family db.query(CustomerRefreshToken).filter( CustomerRefreshToken.token_family_id == db_token.token_family_id ).update({ CustomerRefreshToken.revoked_at: datetime.now(timezone.utc).replace(tzinfo=None) }) db.commit() response.delete_cookie("refresh_token", path="/api/v1/customer/auth") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Token reuse detected. All active tokens in this family revoked." ) # Expiry Check if db_token.expires_at.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Expired refresh token" ) customer = db.query(EcomCustomer).filter(EcomCustomer.customer_id == db_token.customer_id).first() if not customer or not customer.is_active: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Customer account is inactive or not found" ) # Revoke current token db_token.revoked_at = datetime.now(timezone.utc).replace(tzinfo=None) db_token.last_used_at = datetime.now(timezone.utc).replace(tzinfo=None) db.commit() user_agent = request.headers.get("user-agent") ip_address = request.client.host if request.client else None # Issue rotated token pair sharing the same family ID return issue_tokens( db, customer, response, token_family_id=db_token.token_family_id, user_agent=user_agent, ip_address=ip_address ) @router.post("/logout") def logout( response: Response, refresh_token: Optional[str] = Cookie(None), db: Session = Depends(get_db) ): """ Logout route to revoke the active refresh token session. """ if refresh_token: hashed_token = hash_token(refresh_token) db_token = db.query(CustomerRefreshToken).filter(CustomerRefreshToken.token_hash == hashed_token).first() if db_token: db_token.revoked_at = datetime.now(timezone.utc).replace(tzinfo=None) db.commit() response.delete_cookie("refresh_token", path="/api/v1/customer/auth") return {"detail": "Logged out successfully"}