From cbfbbb4654ee9a1db444bba4bf904ecd56d1145d Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Thu, 4 Dec 2025 22:48:02 +0100 Subject: [PATCH] fix: customer authentication and shop error page styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- app/api/deps.py | 152 ++++++++++++--------- app/api/v1/shop/orders.py | 88 +++--------- app/templates/shop/errors/400.html | 41 ++---- app/templates/shop/errors/401.html | 41 ++---- app/templates/shop/errors/403.html | 41 ++---- app/templates/shop/errors/404.html | 39 ++---- app/templates/shop/errors/422.html | 53 +++---- app/templates/shop/errors/429.html | 47 +++---- app/templates/shop/errors/500.html | 41 ++---- app/templates/shop/errors/502.html | 41 ++---- app/templates/shop/errors/generic.html | 39 ++---- docs/api/authentication-quick-reference.md | 18 +-- docs/api/authentication.md | 55 ++++++-- 13 files changed, 302 insertions(+), 394 deletions(-) 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 %} -
-
400
-
Invalid Request
-
- The request couldn't be processed. This might be due to invalid information or a technical issue. -
- - - - - -{% if vendor %} -
- {{ vendor.name }} -
-{% endif %} -{% endblock %} \ No newline at end of file +{% block support_link %} +Need help? Contact us +{% endblock %} diff --git a/app/templates/shop/errors/401.html b/app/templates/shop/errors/401.html index 2b138273..a47c3634 100644 --- a/app/templates/shop/errors/401.html +++ b/app/templates/shop/errors/401.html @@ -1,33 +1,22 @@ +{# app/templates/shop/errors/401.html #} +{# 401 Unauthorized error page - prompts login #} {% extends "shop/errors/base.html" %} {% block icon %}🔐{% endblock %} {% block title %}401 - Authentication Required{% endblock %} -{% block content %} -{% if vendor and theme and theme.branding and theme.branding.logo %} - -{% endif %} +{% block action_buttons %} + + Log In + + + Create Account + +{% endblock %} -
🔐
-
401
-
Please Log In
-
- You need to be logged in to access this page. Please sign in to continue shopping. -
- - - - - -{% if vendor %} -
- {{ vendor.name }} -
-{% endif %} -{% endblock %} \ No newline at end of file +{% block support_link %} +Don't have an account? Sign up now +{% endblock %} diff --git a/app/templates/shop/errors/403.html b/app/templates/shop/errors/403.html index d2b9ac20..1c4fb43d 100644 --- a/app/templates/shop/errors/403.html +++ b/app/templates/shop/errors/403.html @@ -1,33 +1,22 @@ +{# app/templates/shop/errors/403.html #} +{# 403 Forbidden error page - access restricted #} {% extends "shop/errors/base.html" %} {% block icon %}🔒{% endblock %} {% block title %}403 - Access Restricted{% endblock %} -{% block content %} -{% if vendor and theme and theme.branding and theme.branding.logo %} - -{% endif %} +{% block action_buttons %} + + Log In + + + Go to Home + +{% endblock %} -
🔒
-
403
-
Access Restricted
-
- This page requires authentication or special permissions to access. Please log in to continue. -
- - - - - -{% if vendor %} -
- {{ vendor.name }} -
-{% endif %} -{% endblock %} \ No newline at end of file +{% block support_link %} +Need help accessing your account? Contact support +{% endblock %} diff --git a/app/templates/shop/errors/404.html b/app/templates/shop/errors/404.html index 993f1cd2..16c59372 100644 --- a/app/templates/shop/errors/404.html +++ b/app/templates/shop/errors/404.html @@ -1,33 +1,22 @@ +{# app/templates/shop/errors/404.html #} +{# 404 Not Found error page - uses base template with custom icon and message #} {% extends "shop/errors/base.html" %} {% block icon %}🔍{% endblock %} {% block title %}404 - Page Not Found{% endblock %} -{% block content %} -{% if vendor and theme and theme.branding and theme.branding.logo %} - -{% endif %} +{% block action_buttons %} + + Continue Shopping + + + View All Products + +{% endblock %} -
🔍
-
404
-
Page Not Found
-
- Sorry, we couldn't find the page you're looking for. The product or page may have been moved or is no longer available. -
- - - - - -{% if vendor %} -
- {{ vendor.name }} -
-{% endif %} +{% block support_link %} +Can't find what you're looking for? Contact us and we'll help you find it. {% endblock %} \ No newline at end of file diff --git a/app/templates/shop/errors/422.html b/app/templates/shop/errors/422.html index 95579166..481e4ceb 100644 --- a/app/templates/shop/errors/422.html +++ b/app/templates/shop/errors/422.html @@ -1,46 +1,35 @@ +{# app/templates/shop/errors/422.html #} +{# 422 Unprocessable Entity error page - validation errors #} {% extends "shop/errors/base.html" %} {% block icon %}📝{% endblock %} {% block title %}422 - Invalid Information{% endblock %} -{% block content %} -{% if vendor and theme and theme.branding and theme.branding.logo %} - -{% endif %} - -
📝
-
422
-
Please Check Your Information
-
- Some of the information you provided isn't valid. Please review the form and try again. -
- +{% block extra_content %} {% if details and details.validation_errors %} -
-

Please correct:

-
    +
    +

    Please correct:

    +
      {% for error in details.validation_errors %} -
    • - • {{ error.msg }} -
    • +
    • • {{ error.msg }}
    • {% endfor %}
    {% endif %} +{% endblock %} - +{% block action_buttons %} + + Go Back and Fix + + + Go to Home + +{% endblock %} - - -{% if vendor %} -
    - {{ vendor.name }} -
    -{% endif %} -{% endblock %} \ No newline at end of file +{% block support_link %} +Having trouble? We're here to help +{% endblock %} diff --git a/app/templates/shop/errors/429.html b/app/templates/shop/errors/429.html index fa847eeb..7ac2a67f 100644 --- a/app/templates/shop/errors/429.html +++ b/app/templates/shop/errors/429.html @@ -1,41 +1,32 @@ +{# app/templates/shop/errors/429.html #} +{# 429 Too Many Requests error page - rate limiting #} {% extends "shop/errors/base.html" %} {% block icon %}⏱️{% endblock %} {% block title %}429 - Please Slow Down{% endblock %} -{% block content %} -{% if vendor and theme and theme.branding and theme.branding.logo %} - -{% endif %} - -
    ⏱️
    -
    429
    -
    Please Slow Down
    -
    - You're browsing a bit too fast! Please wait a moment before continuing. -
    - +{% block extra_content %} {% if details and details.retry_after %} -
    -

    +

    +

    Please wait {{ details.retry_after }} seconds

    {% endif %} +{% endblock %} - +{% block action_buttons %} + + Try Again + + + Go to Home + +{% endblock %} - - -{% if vendor %} -
    - {{ vendor.name }} -
    -{% endif %} -{% endblock %} \ No newline at end of file +{% block support_link %} +Questions? Contact us +{% endblock %} diff --git a/app/templates/shop/errors/500.html b/app/templates/shop/errors/500.html index 12f190ed..892eb90a 100644 --- a/app/templates/shop/errors/500.html +++ b/app/templates/shop/errors/500.html @@ -1,33 +1,22 @@ +{# app/templates/shop/errors/500.html #} +{# 500 Internal Server Error page #} {% extends "shop/errors/base.html" %} {% block icon %}😔{% endblock %} {% block title %}500 - Something Went Wrong{% endblock %} -{% block content %} -{% if vendor and theme and theme.branding and theme.branding.logo %} - -{% endif %} +{% block action_buttons %} + + Go to Home + + + Try Again + +{% endblock %} -
    😔
    -
    500
    -
    Oops! Something Went Wrong
    -
    - We're experiencing technical difficulties. Our team has been notified and is working to fix the issue. Please try again in a few moments. -
    - - - - - -{% if vendor %} -
    - {{ vendor.name }} -
    -{% endif %} -{% endblock %} \ No newline at end of file +{% block support_link %} +Issue persisting? Let us know and we'll help you out. +{% endblock %} diff --git a/app/templates/shop/errors/502.html b/app/templates/shop/errors/502.html index 09ada9e6..88a89f25 100644 --- a/app/templates/shop/errors/502.html +++ b/app/templates/shop/errors/502.html @@ -1,33 +1,22 @@ +{# app/templates/shop/errors/502.html #} +{# 502 Bad Gateway error page - upstream service unavailable #} {% extends "shop/errors/base.html" %} {% block icon %}🔧{% endblock %} {% block title %}502 - Service Temporarily Unavailable{% endblock %} -{% block content %} -{% if vendor and theme and theme.branding and theme.branding.logo %} - -{% endif %} +{% block action_buttons %} + + Try Again + + + Go to Home + +{% endblock %} -
    🔧
    -
    502
    -
    Temporarily Unavailable
    -
    - We're having trouble connecting to our systems. This is usually temporary. Please try again in a few moments. -
    - - - - - -{% if vendor %} -
    - {{ vendor.name }} -
    -{% endif %} -{% endblock %} \ No newline at end of file +{% block support_link %} +If this continues, let us know +{% endblock %} diff --git a/app/templates/shop/errors/generic.html b/app/templates/shop/errors/generic.html index 250b0dbc..19be08e0 100644 --- a/app/templates/shop/errors/generic.html +++ b/app/templates/shop/errors/generic.html @@ -1,31 +1,22 @@ +{# app/templates/shop/errors/generic.html #} +{# Generic error page - fallback for any error code #} {% extends "shop/errors/base.html" %} {% block icon %}⚠️{% endblock %} {% block title %}{{ status_code }} - {{ status_name }}{% endblock %} -{% block content %} -{% if vendor and theme and theme.branding and theme.branding.logo %} - -{% endif %} +{% block action_buttons %} + + Continue Shopping + + + Go Back + +{% endblock %} -
    ⚠️
    -
    {{ status_code }}
    -
    {{ status_name }}
    -
    {{ message }}
    - - - - - -{% if vendor %} -
    - {{ vendor.name }} -
    -{% endif %} -{% endblock %} \ No newline at end of file +{% block support_link %} +Need assistance? Contact us +{% endblock %} diff --git a/docs/api/authentication-quick-reference.md b/docs/api/authentication-quick-reference.md index 46b03cf5..50bd8399 100644 --- a/docs/api/authentication-quick-reference.md +++ b/docs/api/authentication-quick-reference.md @@ -14,6 +14,7 @@ from app.api.deps import ( get_current_vendor_from_cookie_or_header, get_current_customer_from_cookie_or_header ) +from models.database.customer import Customer # Admin page @router.get("/admin/dashboard") @@ -25,10 +26,10 @@ def admin_page(user: User = Depends(get_current_admin_from_cookie_or_header)): def vendor_page(user: User = Depends(get_current_vendor_from_cookie_or_header)): pass -# Customer page +# Customer page - NOTE: Returns Customer, not User! @router.get("/shop/account/dashboard") -def customer_page(user: User = Depends(get_current_customer_from_cookie_or_header)): - pass +def customer_page(customer: Customer = Depends(get_current_customer_from_cookie_or_header)): + pass # customer.id, customer.email, customer.vendor_id ``` ### For API Endpoints (header only - better security) @@ -39,6 +40,7 @@ from app.api.deps import ( get_current_vendor_api, get_current_customer_api ) +from models.database.customer import Customer # Admin API @router.post("/api/v1/admin/vendors") @@ -46,14 +48,14 @@ def admin_api(user: User = Depends(get_current_admin_api)): pass # Vendor API -@router.post("/api/v1/vendor/{code}/products") +@router.post("/api/v1/vendor/products") def vendor_api(user: User = Depends(get_current_vendor_api)): - pass + pass # user.token_vendor_id for vendor context -# Customer API +# Customer API - NOTE: Returns Customer, not User! @router.post("/api/v1/shop/orders") -def customer_api(user: User = Depends(get_current_customer_api)): - pass +def customer_api(request: Request, customer: Customer = Depends(get_current_customer_api)): + pass # customer.id, request.state.vendor validated to match ``` --- diff --git a/docs/api/authentication.md b/docs/api/authentication.md index a51a4e9b..044ca0e0 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -467,30 +467,51 @@ current_user: User = Depends(get_current_vendor_api) **Purpose:** Authenticate customer users for HTML pages **Accepts:** Cookie (`customer_token`) OR Authorization header -**Returns:** `Customer` object +**Returns:** `Customer` object (from `models.database.customer.Customer`) **Raises:** -- `InvalidTokenException` - No token or invalid token -- `InsufficientPermissionsException` - User is not customer (admin/vendor blocked) +- `InvalidTokenException` - No token, invalid token, or not a customer token +- `UnauthorizedVendorAccessException` - Token vendor_id doesn't match URL vendor -**Note:** The `InsufficientPermissionsException` raised here is from `app.exceptions.auth`, which provides general authentication permission checking. This is distinct from `InsufficientTeamPermissionsException` used for team-specific permissions. +**Security Features:** +- **Token type validation:** Only accepts tokens with `type: "customer"` - admin and vendor tokens are rejected +- **Vendor validation:** Validates that `token.vendor_id` matches `request.state.vendor.id` (URL-based vendor) +- Prevents cross-vendor token reuse (customer from Vendor A cannot use token on Vendor B's shop) **Usage:** ```python -current_customer: Customer = Depends(get_current_customer_from_cookie_or_header) +from models.database.customer import Customer + +customer: Customer = Depends(get_current_customer_from_cookie_or_header) +# Access customer.id, customer.email, customer.vendor_id, etc. ``` #### `get_current_customer_api()` **Purpose:** Authenticate customer users for API endpoints **Accepts:** Authorization header ONLY -**Returns:** `Customer` object +**Returns:** `Customer` object (from `models.database.customer.Customer`) **Raises:** -- `InvalidTokenException` - No token or invalid token -- `InsufficientPermissionsException` - User is not customer (admin/vendor blocked) +- `InvalidTokenException` - No token, invalid token, or not a customer token +- `UnauthorizedVendorAccessException` - Token vendor_id doesn't match URL vendor + +**Security Features:** +- **Token type validation:** Only accepts tokens with `type: "customer"` - admin and vendor tokens are rejected +- **Vendor validation:** Validates that `token.vendor_id` matches `request.state.vendor.id` (URL-based vendor) +- Prevents cross-vendor token reuse **Usage:** ```python -current_customer: Customer = Depends(get_current_customer_api) +from models.database.customer import Customer + +@router.post("/orders") +def place_order( + request: Request, + customer: Customer = Depends(get_current_customer_api), + db: Session = Depends(get_db), +): + # customer is a Customer object, not User + vendor = request.state.vendor # Already validated to match token + order = order_service.create_order(db, vendor.id, customer.id, ...) ``` #### `get_current_user()` @@ -581,20 +602,26 @@ async def vendor_login_page( **Purpose:** Check if customer user is authenticated (without enforcing) **Accepts:** Cookie (`customer_token`) OR Authorization header **Returns:** -- `User` object with `role="customer"` if authenticated -- `None` if no token, invalid token, or user is not customer +- `Customer` object if authenticated with valid customer token +- `None` if no token, invalid token, vendor mismatch, or not a customer token **Raises:** Never raises exceptions +**Security Features:** +- Only accepts tokens with `type: "customer"` - admin and vendor tokens return `None` +- Validates vendor_id in token matches URL vendor - mismatch returns `None` + **Usage:** ```python +from models.database.customer import Customer + # Shop login page redirect @router.get("/shop/account/login") async def customer_login_page( request: Request, - current_user: Optional[User] = Depends(get_current_customer_optional) + customer: Customer | None = Depends(get_current_customer_optional) ): - if current_user: - # User already logged in, redirect to account page + if customer: + # Customer already logged in, redirect to account page return RedirectResponse(url="/shop/account", status_code=302) # Not logged in, show login form