diff --git a/app/api/deps.py b/app/api/deps.py
index ae6026b6..6ddae7fd 100644
--- a/app/api/deps.py
+++ b/app/api/deps.py
@@ -328,33 +328,27 @@ def get_current_vendor_api(
# ============================================================================
-def get_current_customer_from_cookie_or_header(
- request: Request,
- credentials: HTTPAuthorizationCredentials | None = Depends(security),
- customer_token: str | None = Cookie(None),
- db: Session = Depends(get_db),
-):
+def _validate_customer_token(token: str, request: Request, db: Session):
"""
- Get current customer from customer_token cookie or Authorization header.
+ Validate customer JWT token and return Customer object.
- Used for shop account HTML pages (/shop/account/*) that need cookie-based auth.
- Note: Public shop pages (/shop/products, etc.) don't use this dependency.
-
- Priority:
- 1. Authorization header (API calls)
- 2. customer_token cookie (page navigation)
+ Validates:
+ 1. Token signature and expiration
+ 2. Token type is "customer"
+ 3. Customer exists and is active
+ 4. Token vendor_id matches request vendor (URL-based)
Args:
- request: FastAPI request
- credentials: Optional Bearer token from header
- customer_token: Optional token from customer_token cookie
+ token: JWT token string
+ request: FastAPI request (for vendor context)
db: Database session
Returns:
Customer: Authenticated customer object
Raises:
- InvalidTokenException: If no token or invalid token
+ InvalidTokenException: If token is invalid or expired
+ UnauthorizedVendorAccessException: If vendor mismatch
"""
from datetime import datetime
@@ -362,14 +356,6 @@ def get_current_customer_from_cookie_or_header(
from models.database.customer import Customer
- token, source = _get_token_from_request(
- credentials, customer_token, "customer_token", str(request.url.path)
- )
-
- if not token:
- logger.warning(f"Customer auth failed: No token for {request.url.path}")
- raise InvalidTokenException("Customer authentication required")
-
# Decode and validate customer JWT token
try:
payload = jwt.decode(
@@ -394,6 +380,9 @@ def get_current_customer_from_cookie_or_header(
logger.warning(f"Expired customer token for customer_id={customer_id}")
raise InvalidTokenException("Token has expired")
+ # Get vendor_id from token for validation
+ token_vendor_id = payload.get("vendor_id")
+
except JWTError as e:
logger.warning(f"JWT decode error: {str(e)}")
raise InvalidTokenException("Could not validate credentials")
@@ -409,51 +398,94 @@ def get_current_customer_from_cookie_or_header(
logger.warning(f"Inactive customer attempted access: {customer.email}")
raise InvalidTokenException("Customer account is inactive")
+ # Validate vendor context matches token
+ # This prevents using a customer token from vendor A on vendor B's shop
+ request_vendor = getattr(request.state, "vendor", None)
+ if request_vendor and token_vendor_id:
+ if request_vendor.id != token_vendor_id:
+ logger.warning(
+ f"Customer {customer.email} token vendor mismatch: "
+ f"token={token_vendor_id}, request={request_vendor.id}"
+ )
+ raise UnauthorizedVendorAccessException(
+ vendor_code=request_vendor.vendor_code,
+ user_id=customer.id,
+ )
+
logger.debug(f"Customer authenticated: {customer.email} (ID: {customer.id})")
return customer
-def get_current_customer_api(
- credentials: HTTPAuthorizationCredentials = Depends(security),
+def get_current_customer_from_cookie_or_header(
+ request: Request,
+ credentials: HTTPAuthorizationCredentials | None = Depends(security),
+ customer_token: str | None = Cookie(None),
db: Session = Depends(get_db),
-) -> User:
+):
"""
- Get current customer user from Authorization header ONLY.
+ Get current customer from customer_token cookie or Authorization header.
- Used for shop API endpoints that should not accept cookies.
+ Used for shop account HTML pages (/shop/account/*) that need cookie-based auth.
+ Note: Public shop pages (/shop/products, etc.) don't use this dependency.
+
+ Validates that token vendor_id matches request vendor (URL-based detection).
+
+ Priority:
+ 1. Authorization header (API calls)
+ 2. customer_token cookie (page navigation)
Args:
+ request: FastAPI request
+ credentials: Optional Bearer token from header
+ customer_token: Optional token from customer_token cookie
+ db: Database session
+
+ Returns:
+ Customer: Authenticated customer object
+
+ Raises:
+ InvalidTokenException: If no token or invalid token
+ UnauthorizedVendorAccessException: If vendor mismatch
+ """
+ token, source = _get_token_from_request(
+ credentials, customer_token, "customer_token", str(request.url.path)
+ )
+
+ if not token:
+ logger.warning(f"Customer auth failed: No token for {request.url.path}")
+ raise InvalidTokenException("Customer authentication required")
+
+ return _validate_customer_token(token, request, db)
+
+
+def get_current_customer_api(
+ request: Request,
+ credentials: HTTPAuthorizationCredentials = Depends(security),
+ db: Session = Depends(get_db),
+):
+ """
+ Get current customer from Authorization header ONLY.
+
+ Used for shop API endpoints that should not accept cookies.
+ Validates that token vendor_id matches request vendor (URL-based detection).
+
+ Args:
+ request: FastAPI request (for vendor context)
credentials: Bearer token from Authorization header
db: Database session
Returns:
- User: Authenticated customer user
+ Customer: Authenticated customer object
Raises:
InvalidTokenException: If no token or invalid token
- InsufficientPermissionsException: If user is not customer (admin/vendor blocked)
+ UnauthorizedVendorAccessException: If vendor mismatch
"""
if not credentials:
raise InvalidTokenException("Authorization header required for API calls")
- user = _validate_user_token(credentials.credentials, db)
-
- # Block admins from customer API
- if user.role == "admin":
- logger.warning(f"Admin user {user.username} attempted customer API")
- raise InsufficientPermissionsException("Customer access only")
-
- # Block vendors from customer API
- if user.role == "vendor":
- logger.warning(f"Vendor user {user.username} attempted customer API")
- raise InsufficientPermissionsException("Customer access only")
-
- if user.role != "customer":
- logger.warning(f"Non-customer user {user.username} attempted customer API")
- raise InsufficientPermissionsException("Customer privileges required")
-
- return user
+ return _validate_customer_token(credentials.credentials, request, db)
# ============================================================================
@@ -883,9 +915,9 @@ def get_current_customer_optional(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
customer_token: str | None = Cookie(None),
db: Session = Depends(get_db),
-) -> User | None:
+):
"""
- Get current customer user from customer_token cookie or Authorization header.
+ Get current customer from customer_token cookie or Authorization header.
Returns None instead of raising exceptions if not authenticated.
Used for login pages to check if user is already authenticated.
@@ -901,8 +933,8 @@ def get_current_customer_optional(
db: Database session
Returns:
- User: Authenticated customer user if valid token exists
- None: If no token, invalid token, or user is not customer
+ Customer: Authenticated customer if valid token exists
+ None: If no token, invalid token, or vendor mismatch
"""
token, source = _get_token_from_request(
credentials, customer_token, "customer_token", str(request.url.path)
@@ -912,14 +944,8 @@ def get_current_customer_optional(
return None
try:
- # Validate token and get user
- user = _validate_user_token(token, db)
-
- # Verify user is customer
- if user.role == "customer":
- return user
+ # Validate customer token (includes vendor validation)
+ return _validate_customer_token(token, request, db)
except Exception:
- # Invalid token or other error
- pass
-
- return None
+ # Invalid token, vendor mismatch, or other error
+ return None
diff --git a/app/api/v1/shop/orders.py b/app/api/v1/shop/orders.py
index ea7dd648..23044d8c 100644
--- a/app/api/v1/shop/orders.py
+++ b/app/api/v1/shop/orders.py
@@ -1,23 +1,26 @@
# app/api/v1/shop/orders.py
"""
-Shop Orders API (Public)
+Shop Orders API (Customer authenticated)
-Public endpoints for managing customer orders in shop frontend.
+Endpoints for managing customer orders in shop frontend.
Uses vendor from request.state (injected by VendorContextMiddleware).
-Requires customer authentication for most operations.
+Requires customer authentication - get_current_customer_api validates
+that customer token vendor_id matches the URL vendor.
+
+Customer Context: get_current_customer_api returns Customer directly
+(not User), with vendor validation already performed.
"""
import logging
-from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
+from fastapi import APIRouter, Depends, Path, Query, Request
from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_api
from app.core.database import get_db
-from app.services.customer_service import customer_service
+from app.exceptions import VendorNotFoundException
from app.services.order_service import order_service
from models.database.customer import Customer
-from models.database.user import User
from models.schema.order import (
OrderCreate,
OrderDetailResponse,
@@ -29,47 +32,11 @@ router = APIRouter()
logger = logging.getLogger(__name__)
-def get_customer_from_user(request: Request, user: User, db: Session) -> Customer:
- """
- Helper to get Customer record from authenticated User.
-
- Args:
- request: FastAPI request (to get vendor)
- user: Authenticated user
- db: Database session
-
- Returns:
- Customer record
-
- Raises:
- HTTPException: If customer not found or vendor mismatch
- """
- vendor = getattr(request.state, "vendor", None)
-
- if not vendor:
- raise HTTPException(
- status_code=404,
- detail="Vendor not found. Please access via vendor domain/subdomain/path.",
- )
-
- # Find customer record for this user and vendor
- customer = customer_service.get_customer_by_user_id(
- db=db, vendor_id=vendor.id, user_id=user.id
- )
-
- if not customer:
- raise HTTPException(
- status_code=404, detail="Customer account not found for current vendor"
- )
-
- return customer
-
-
@router.post("/orders", response_model=OrderResponse)
def place_order(
request: Request,
order_data: OrderCreate,
- current_user: User = Depends(get_current_customer_api),
+ customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
@@ -82,17 +49,11 @@ def place_order(
Request Body:
- Order data including shipping address, payment method, etc.
"""
- # Get vendor from middleware
+ # Get vendor from middleware (already validated by get_current_customer_api)
vendor = getattr(request.state, "vendor", None)
if not vendor:
- raise HTTPException(
- status_code=404,
- detail="Vendor not found. Please access via vendor domain/subdomain/path.",
- )
-
- # Get customer record
- customer = get_customer_from_user(request, current_user, db)
+ raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] place_order for customer {customer.id}",
@@ -100,7 +61,6 @@ def place_order(
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"customer_id": customer.id,
- "user_id": current_user.id,
},
)
@@ -132,7 +92,7 @@ def get_my_orders(
request: Request,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
- current_user: User = Depends(get_current_customer_api),
+ customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
@@ -145,17 +105,11 @@ def get_my_orders(
- skip: Number of orders to skip (pagination)
- limit: Maximum number of orders to return
"""
- # Get vendor from middleware
+ # Get vendor from middleware (already validated by get_current_customer_api)
vendor = getattr(request.state, "vendor", None)
if not vendor:
- raise HTTPException(
- status_code=404,
- detail="Vendor not found. Please access via vendor domain/subdomain/path.",
- )
-
- # Get customer record
- customer = get_customer_from_user(request, current_user, db)
+ raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] get_my_orders for customer {customer.id}",
@@ -185,7 +139,7 @@ def get_my_orders(
def get_order_details(
request: Request,
order_id: int = Path(..., description="Order ID", gt=0),
- current_user: User = Depends(get_current_customer_api),
+ customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
@@ -197,17 +151,11 @@ def get_order_details(
Path Parameters:
- order_id: ID of the order to retrieve
"""
- # Get vendor from middleware
+ # Get vendor from middleware (already validated by get_current_customer_api)
vendor = getattr(request.state, "vendor", None)
if not vendor:
- raise HTTPException(
- status_code=404,
- detail="Vendor not found. Please access via vendor domain/subdomain/path.",
- )
-
- # Get customer record
- customer = get_customer_from_user(request, current_user, db)
+ raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] get_order_details: order {order_id}",
diff --git a/app/templates/shop/errors/400.html b/app/templates/shop/errors/400.html
index cba0c209..fc16ad74 100644
--- a/app/templates/shop/errors/400.html
+++ b/app/templates/shop/errors/400.html
@@ -1,33 +1,22 @@
+{# app/templates/shop/errors/400.html #}
+{# 400 Bad Request error page #}
{% extends "shop/errors/base.html" %}
{% block icon %}❌{% endblock %}
{% block title %}400 - Invalid Request{% endblock %}
-{% block content %}
-{% if vendor and theme and theme.branding and theme.branding.logo %}
-
-{% endif %}
+{% block action_buttons %}
+
+ Go Back
+
+
+ Go to Home
+
+{% endblock %}
-
+
Please wait {{ details.retry_after }} seconds