ifixkart-backend/app/api/v1/routers/CustomerAuthRouter.py

478 lines
16 KiB
Python

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