Files
orion/docs/development/customer-authentication-implementation.md
Samir Boulahtit a6e6d9be8e
Some checks failed
CI / ruff (push) Successful in 11s
CI / pytest (push) Failing after 46m49s
CI / validate (push) Successful in 23s
CI / dependency-scanning (push) Successful in 30s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
refactor: rename shopLayoutData to storefrontLayoutData
Align Alpine.js base component naming with storefront terminology.
Updated across all storefront JS, templates, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:06:45 +01:00

20 KiB

Customer Authentication Implementation

Date: 2025-11-24 Status: Completed

Overview

This document describes the implementation of customer authentication for the shop 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 shop 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): Shop 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:

{
    "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.

Cookies must be set with paths that match how the store is accessed:

Access Method Example URL Cookie Path
Domain orion.lu/shop/account/login /shop
Subdomain orion.localhost/shop/account/login /shop
Path-based localhost/stores/orion/shop/account/login /stores/orion/shop

This ensures cookies are only sent to the correct store's routes.

Implementation Details

Files Created

  1. app/templates/shop/account/login.html

    • Customer login page
    • Extends shop/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/shop/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/shop/account/forgot-password.html

    • Password reset request page
    • Two-state UI (form → success)
    • Email validation
  4. app/templates/shop/account/dashboard.html

    • Customer account dashboard
    • Displays account summary, order statistics
    • Quick links to orders, profile, addresses
    • Logout functionality
    • Follows shop 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:

class UserLogin(BaseModel):
    username: str
    password: str

After:

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/shop/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/shop/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:

# 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 = "/shop"  # Default for domain/subdomain access
if access_method == "path":
    # For path-based access like /stores/orion/shop
    full_prefix = store_context.get('full_prefix', '/store/') if store_context else '/store/'
    cookie_path = f"{full_prefix}{store.subdomain}/shop"

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:

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:

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/shop_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:

# 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/shop/account/login
Cookie Path: /shop
Cookie Sent To: https://orion.lu/shop/*

Subdomain-based Access

URL: https://orion.myplatform.com/shop/account/login
Cookie Path: /shop
Cookie Sent To: https://orion.myplatform.com/shop/*

Path-based Access

URL: https://myplatform.com/stores/orion/shop/account/login
Cookie Path: /stores/orion/shop
Cookie Sent To: https://myplatform.com/stores/orion/shop/*

Authentication Flow

Login Flow

  1. User loads login pageGET /stores/orion/shop/account/login

    • Middleware detects store from path
    • Sets detection_method = "path" in store_context
    • Renders login template
  2. User submits credentialsPOST /api/v1/shop/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 dashboardGET /stores/orion/shop/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 logoutPOST /api/v1/shop/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

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 shop 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 shop template pattern:

{% extends "shop/base.html" %}

{% block title %}Page Title{% endblock %}

{% block alpine_data %}componentName(){% endblock %}

{% block content %}
<!-- Page content -->
{% endblock %}

{% block extra_scripts %}
<script>
function componentName() {
    return {
        ...storefrontLayoutData(),
        // Component-specific data/methods
    }
}
</script>
{% 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:

<!-- Modal trigger -->
<button @click="showLogoutModal = true">Logout</button>

<!-- Modal component -->
<div x-show="showLogoutModal" x-cloak class="fixed inset-0 z-50">
    <!-- Backdrop with fade animation -->
    <div x-show="showLogoutModal"
         x-transition:enter="ease-out duration-300"
         x-transition:enter-start="opacity-0"
         x-transition:enter-end="opacity-100"
         @click="showLogoutModal = false"
         class="fixed inset-0 bg-gray-500 bg-opacity-75">
    </div>

    <!-- Modal panel with slide+scale animation -->
    <div x-show="showLogoutModal"
         x-transition:enter="ease-out duration-300"
         x-transition:enter-start="opacity-0 scale-95"
         x-transition:enter-end="opacity-100 scale-100"
         class="bg-white dark:bg-gray-800 rounded-lg">
        <!-- Modal content -->
    </div>
</div>

Alpine.js Component:

function accountDashboard() {
    return {
        ...storefrontLayoutData(),
        showLogoutModal: false,  // Modal state

        confirmLogout() {
            this.showLogoutModal = false;
            // Perform logout API call
            // Show toast notification
            // Redirect to login
        }
    }
}

Why Custom Modal vs Browser Confirm:

  • Consistent with design system
  • Customizable styling and animations
  • Dark mode support
  • Better mobile experience
  • More professional appearance
  • Accessible (ARIA labels, keyboard navigation)
  • Browser confirm: Cannot be styled, looks dated, poor mobile UX

Testing Checklist

  • Customer can register new account
  • Customer can login with email/password
  • Admin can login with username (using unified schema)
  • Store can login with username (using unified schema)
  • Cookie is set with correct path for path-based access
  • Cookie is sent on subsequent requests to dashboard
  • Customer authentication dependency validates token correctly
  • Dashboard loads with customer data
  • Customer can logout
  • Logout confirmation modal displays correctly
  • Modal has smooth animations and transitions
  • Modal supports dark mode
  • Toast notification shows on logout
  • Cookie is properly deleted on logout
  • Unauthorized access redirects to login
  • Theme styling is applied correctly
  • Dark mode works
  • Mobile responsive layout
  • Admin/store login button spinner aligns correctly

Known Limitations

  1. Password Reset Not Implemented: forgot-password and reset-password endpoints are placeholders (TODO comments in code)

  2. Email Verification Not Implemented: Customers are immediately active after registration

  3. Session Management: No refresh tokens, single JWT with 30-minute expiry

  4. Account Pages Are Placeholders:

    • /account/orders - needs order history implementation
    • /account/profile - needs profile editing implementation
    • /account/addresses - needs address management implementation

Future Enhancements

  1. Password Reset Flow:

    • Generate secure reset tokens
    • Send password reset emails
    • Token expiry and validation
    • Password reset form
  2. Email Verification:

    • Send verification email on registration
    • Verification token validation
    • Resend verification email
  3. Account Management:

    • Edit profile (name, email, phone)
    • Change password
    • Manage addresses (CRUD)
    • View order history with filtering/search
    • Order tracking
  4. Security Enhancements:

    • Refresh tokens for longer sessions
    • Rate limiting on login/registration
    • Account lockout after failed attempts
    • 2FA/MFA support
  5. User Experience:

    • Remember me functionality
    • Social login (OAuth)
    • Progressive disclosure of forms
    • Better error messages

References

  • Customer Model: models/database/customer.py
  • Customer Service: app/services/customer_service.py
  • Auth Endpoints: app/api/v1/shop/auth.py
  • Auth Dependencies: app/api/deps.py
  • Shop Routes: app/routes/shop_pages.py
  • Store Context: middleware/store_context.py
  • Templates: app/templates/shop/account/

Deployment Notes

Environment Variables

No new environment variables required. Uses existing:

  • JWT_SECRET_KEY - for token signing
  • JWT_EXPIRE_MINUTES - token expiry (default: 30)
  • ENVIRONMENT - for secure cookie setting

Database

No migrations required. Uses existing customer table.

Static Files

Ensure these files exist:

  • static/shared/js/log-config.js
  • static/shared/js/icons.js
  • static/shop/js/shop-layout.js
  • static/shared/js/utils.js
  • static/shared/js/api-client.js
  • static/shop/css/shop.css

Troubleshooting

Issue: "No token for path"

Cause: Cookie path doesn't match request path

Solution:

  • Check store context middleware is running
  • Verify detection_method is set correctly
  • Confirm cookie path calculation includes store subdomain for path-based access

Issue: "Invalid token type"

Cause: Trying to use User token for customer route or vice versa

Solution:

  • Ensure customer login creates token with type: "customer"
  • Verify dependency checks type == "customer"

Cause: Cookie path doesn't match or cookie expired

Solution:

  • Check browser DevTools → Application → Cookies
  • Verify cookie path matches request URL
  • Check cookie expiry timestamp

Summary

This implementation establishes a complete customer authentication system that is:

Secure: HTTP-only cookies, CSRF protection, password hashing Scalable: Multi-tenant with store isolation Flexible: Supports domain, subdomain, and path-based access Maintainable: Clear separation of concerns, follows established patterns User-Friendly: Responsive design, theme integration, proper UX flows

The key architectural decision was recognizing that customers and users are fundamentally different entities requiring separate authentication flows, token structures, and database models.