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