478 lines
16 KiB
Python
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"}
|