fix: customer authentication and shop error page styling

## Customer Authentication Fixes
- Fix get_current_customer_api to properly decode customer tokens (was using User model)
- Add _validate_customer_token() helper for shared customer token validation
- Add vendor validation: token.vendor_id must match request URL vendor
- Block admin/vendor tokens from shop endpoints (type != "customer")
- Update get_current_customer_optional to use proper customer token validation
- Customer auth functions now return Customer object (not User)

## Shop Orders API
- Update orders.py to receive Customer directly from auth dependency
- Remove broken get_customer_from_user() helper
- Use VendorNotFoundException instead of HTTPException

## Shop Error Pages
- Fix all error templates (400, 401, 403, 404, 422, 429, 500, 502, generic)
- Templates were using undefined CSS classes (.btn, .status-code, etc.)
- Now properly extend base.html and override specific blocks
- Use Tailwind utility classes for consistent styling

## Documentation
- Update docs/api/authentication.md with new Customer return types
- Document vendor validation security features
- Update docs/api/authentication-quick-reference.md examples

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-04 22:48:02 +01:00
parent 8a367077e1
commit cbfbbb4654
13 changed files with 302 additions and 394 deletions

View File

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

View File

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