# Customer Authentication Implementation **Date**: 2025-11-24 **Status**: Completed ## Overview This document describes the implementation of customer authentication for the storefront frontend, including login, registration, and account management pages. This work creates a complete separation between customer authentication and admin/store authentication systems. ## Problem Statement The storefront frontend needed proper authentication pages (login, registration, forgot password) and a working customer authentication system. The initial implementation had several issues: 1. No styled authentication pages for customers 2. Customer authentication was incorrectly trying to use the User model (admins/stores) 3. Cookie paths were hardcoded and didn't work with multi-access routing (domain, subdomain, path-based) 4. Store detection method was inconsistent between direct path access and API calls via referer ## Solution Architecture ### 1. Customer vs User Separation **Key Insight**: Customers are NOT users. They are a separate entity in the system. - **Users** (`models/database/user.py`): Admin and store accounts - Have `role` field (admin/store) - Have `username` field - Managed via `app/services/auth_service.py` - **Customers** (`models/database/customer.py`): Storefront customers - Store-scoped (each store has independent customers) - No `role` or `username` fields - Have `customer_number`, `total_orders`, store relationship - Managed via `app/services/customer_service.py` ### 2. JWT Token Structure Customer tokens have a distinct structure: ```python { "sub": str(customer.id), # Customer ID "email": customer.email, "store_id": store_id, # Important: Store isolation "type": "customer", # CRITICAL: Distinguishes from User tokens "exp": expire_timestamp, "iat": issued_at_timestamp, } ``` User tokens have `type` implicitly set to user role (admin/store) and different payload structure. ### 3. Cookie Path Management Cookies must be set with paths that match how the store is accessed: | Access Method | Example URL | Cookie Path | |--------------|-------------|-------------| | Domain | `orion.lu/storefront/account/login` | `/storefront` | | Subdomain | `orion.localhost/storefront/account/login` | `/storefront` | | Path-based | `localhost/stores/orion/storefront/account/login` | `/storefront/orion` | This ensures cookies are only sent to the correct store's routes. ## Implementation Details ### Files Created 1. **`app/templates/storefront/account/login.html`** - Customer login page - Extends `storefront/base.html` (follows design pattern) - Uses Tailwind CSS and Alpine.js - Theme-aware styling with CSS variables - Two-column layout (branding + form) - Form validation and error handling 2. **`app/templates/storefront/account/register.html`** - Customer registration page - Fields: first_name, last_name, email, phone (optional), password - Client-side validation - Marketing consent checkbox - Theme integration 3. **`app/templates/storefront/account/forgot-password.html`** - Password reset request page - Two-state UI (form → success) - Email validation 4. **`app/templates/storefront/account/dashboard.html`** - Customer account dashboard - Displays account summary, order statistics - Quick links to orders, profile, addresses - Logout functionality - Follows storefront design pattern (extends base.html) ### Important Schema Change #### `models/schema/auth.py` - Unified Login Schema Changed the `UserLogin` schema to use `email_or_username` instead of `username` to support both username and email login across all contexts (admin, store, and customer). **Before**: ```python class UserLogin(BaseModel): username: str password: str ``` **After**: ```python class UserLogin(BaseModel): email_or_username: str = Field(..., description="Username or email address") password: str store_code: Optional[str] = Field(None, description="Optional store code for context") ``` **Impact**: This change affects all login endpoints: - Admin login: `/api/v1/admin/auth/login` - Store login: `/api/v1/store/auth/login` - Customer login: `/api/v1/storefront/auth/login` **Updated Files**: - `app/services/auth_service.py` - Changed `user_credentials.username` to `user_credentials.email_or_username` - `app/api/v1/admin/auth.py` - Updated logging to use `email_or_username` - `static/admin/js/login.js` - Send `email_or_username` in payload - `static/store/js/login.js` - Send `email_or_username` in payload ### Files Modified #### 1. `app/api/v1/storefront/auth.py` **Changes**: - Added `CustomerLoginResponse` model (uses `CustomerResponse` instead of `UserResponse`) - Updated `customer_login` endpoint to: - Calculate cookie path dynamically based on store access method - Set cookie with correct path for multi-access support - Return `CustomerLoginResponse` with proper customer data - Updated `customer_logout` endpoint to calculate cookie path dynamically **Key Code**: ```python # Calculate cookie path based on store access method store_context = getattr(request.state, 'store_context', None) access_method = store_context.get('detection_method', 'unknown') if store_context else 'unknown' cookie_path = "/storefront" # Default for domain/subdomain access if access_method == "path": # For path-based access like /storefront/orion full_prefix = store_context.get('full_prefix', '/store/') if store_context else '/store/' cookie_path = f"/storefront/{store.subdomain}" response.set_cookie( key="customer_token", value=token, httponly=True, secure=should_use_secure_cookies(), samesite="lax", max_age=expires_in, path=cookie_path, # Dynamic path ) ``` #### 2. `app/services/customer_service.py` **Changes**: - Updated `login_customer` to create JWT tokens directly using `auth_manager` - No longer tries to use `auth_service.create_access_token()` (that's for Users only) - Directly uses `jose.jwt.encode()` with custom customer payload **Key Code**: ```python from jose import jwt from datetime import datetime, timedelta, timezone auth_manager = self.auth_service.auth_manager expires_delta = timedelta(minutes=auth_manager.token_expire_minutes) expire = datetime.now(timezone.utc) + expires_delta payload = { "sub": str(customer.id), "email": customer.email, "store_id": store_id, "type": "customer", # Critical distinction "exp": expire, "iat": datetime.now(timezone.utc), } token = jwt.encode(payload, auth_manager.secret_key, algorithm=auth_manager.algorithm) ``` #### 3. `app/api/deps.py` **Major Rewrite**: `get_current_customer_from_cookie_or_header` **Before**: Tried to validate customer tokens as User tokens, expected `role` field **After**: - Decodes JWT manually - Validates `type == "customer"` in payload - Loads Customer from database (not User) - Returns Customer object **Key Code**: ```python def get_current_customer_from_cookie_or_header( request: Request, credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), customer_token: Optional[str] = Cookie(None), db: Session = Depends(get_db), ): from models.database.customer import Customer from jose import jwt, JWTError token, source = _get_token_from_request(...) if not token: raise InvalidTokenException("Customer authentication required") # Decode and validate customer JWT token payload = jwt.decode(token, auth_manager.secret_key, algorithms=[auth_manager.algorithm]) # Verify this is a customer token if payload.get("type") != "customer": raise InvalidTokenException("Customer authentication required") customer_id = payload.get("sub") customer = db.query(Customer).filter(Customer.id == int(customer_id)).first() if not customer or not customer.is_active: raise InvalidTokenException("Customer not found or inactive") return customer # Returns Customer, not User ``` #### 4. `app/routes/storefront_pages.py` **Changes**: - Changed import from `User` to `Customer` - Updated all protected route handlers: - Changed parameter type from `current_user: User` to `current_customer: Customer` - Updated function calls from `user=current_user` to `user=current_customer` **Affected Routes**: - `/account/dashboard` - `/account/orders` - `/account/orders/{order_id}` - `/account/profile` - `/account/addresses` - `/account/wishlist` - `/account/reviews` #### 5. `middleware/store_context.py` **Critical Fix**: Harmonized store detection methods **Problem**: - Direct page access: `detection_method = "path"` - API call via referer: `detection_method = "referer_path"` - This inconsistency broke cookie path calculation **Solution**: When detecting store from referer path, use the same `detection_method = "path"` and include the same fields (`full_prefix`, `path_prefix`) as direct path detection. **Key Code**: ```python # Method 1: Path-based detection from referer path if referer_path.startswith("/stores/") or referer_path.startswith("/store/"): prefix = "/stores/" if referer_path.startswith("/stores/") else "/store/" path_parts = referer_path[len(prefix):].split("/") if len(path_parts) >= 1 and path_parts[0]: store_code = path_parts[0] prefix_len = len(prefix) # Use "path" as detection_method to be consistent with direct path detection return { "subdomain": store_code, "detection_method": "path", # Consistent! "path_prefix": referer_path[:prefix_len + len(store_code)], "full_prefix": prefix, "host": referer_host, "referer": referer, } ``` #### 6. `app/exceptions/handler.py` **Changes**: - Password sanitization in validation error logging - Already had proper redirect logic for customer login (no changes needed) #### 7. `models/schema/auth.py` **Changes**: - Updated `UserLogin` schema to accept `email_or_username` instead of `username` - This allows customers to login with email (they don't have usernames) ### Multi-Access Routing Support The implementation properly supports all three store access methods: #### Domain-based Access ``` URL: https://orion.lu/storefront/account/login Cookie Path: /storefront Cookie Sent To: https://orion.lu/storefront/* ``` #### Subdomain-based Access ``` URL: https://orion.myplatform.com/storefront/account/login Cookie Path: /storefront Cookie Sent To: https://orion.myplatform.com/storefront/* ``` #### Path-based Access ``` URL: https://myplatform.com/storefront/orion/account/login Cookie Path: /storefront/orion Cookie Sent To: https://myplatform.com/storefront/orion/* ``` ## Authentication Flow ### Login Flow 1. **User loads login page** → `GET /storefront/orion/account/login` - Middleware detects store from path - Sets `detection_method = "path"` in store_context - Renders login template 2. **User submits credentials** → `POST /api/v1/storefront/auth/login` - Middleware detects store from Referer header - Sets `detection_method = "path"` (harmonized!) - Validates credentials via `customer_service.login_customer()` - Creates JWT token with `type: "customer"` - Calculates cookie path based on access method - Sets `customer_token` cookie with correct path - Returns token + customer data 3. **Browser redirects to dashboard** → `GET /storefront/orion/account/dashboard` - Browser sends `customer_token` cookie (path matches!) - Dependency `get_current_customer_from_cookie_or_header` extracts token - Decodes JWT, validates `type == "customer"` - Loads Customer from database - Renders dashboard with customer data ### Logout Flow 1. **User clicks logout button** → Shows Tailwind modal confirmation - Custom modal (not browser confirm dialog) - Alpine.js state management - Smooth animations with transitions - Dark mode support 2. **User confirms logout** → `POST /api/v1/storefront/auth/logout` - Calculates cookie path (same logic as login) - Deletes cookie with matching path - Returns success message 3. **Frontend redirects to login page** - Shows success toast notification - Clears localStorage token - Redirects after 500ms delay ## Security Features ### Cookie Security ```python response.set_cookie( key="customer_token", value=token, httponly=True, # JavaScript cannot access (XSS protection) secure=True, # HTTPS only (production/staging) samesite="lax", # CSRF protection max_age=1800, # 30 minutes (matches JWT expiry) path=cookie_path, # Restricted to store's storefront routes ) ``` ### Token Validation - JWT expiration checked - Customer active status verified - Token type validated (`type == "customer"`) - Store isolation enforced (customer must belong to store) ### Password Security - Bcrypt hashing via `auth_manager.hash_password()` - Validation errors sanitized (passwords never logged) - Minimum password length enforced ## Design Patterns Followed ### Frontend Templates All authentication pages follow the storefront template pattern: ```jinja2 {% extends "storefront/base.html" %} {% block title %}Page Title{% endblock %} {% block alpine_data %}componentName(){% endblock %} {% block content %} {% endblock %} {% block extra_scripts %} {% endblock %} ``` **Benefits**: - Consistent header/footer/navigation - Theme CSS variables automatically injected - Dark mode support - Mobile responsive - Alpine.js component pattern ### Service Layer - Customer operations in `customer_service.py` - Auth operations in `auth_service.py` - Clear separation of concerns - Database operations via SQLAlchemy ORM ### Exception Handling - Custom exceptions for customer-specific errors - Consistent error responses (JSON for API, HTML for pages) - Automatic redirect to login on 401 for HTML page requests ### UI Components #### Logout Confirmation Modal Custom Tailwind CSS modal for logout confirmation instead of browser's native `confirm()` dialog. **Features**: - Beautiful animated modal with backdrop overlay - Warning icon (red triangle with exclamation mark) - Clear confirmation message - Two action buttons: "Logout" (red) and "Cancel" (gray) - Dark mode support - Mobile responsive - Keyboard accessible (ARIA attributes) - Click backdrop to dismiss **Implementation**: ```html