feat: implement customer authentication with JWT tokens

Implement secure customer authentication system with dedicated JWT tokens,
separate from admin/vendor authentication.

Backend Changes:
- Add customer JWT token support in deps.py
  - New get_current_customer_from_cookie_or_header dependency
  - Validates customer-specific tokens with type checking
  - Returns Customer object instead of User for shop routes
- Extend AuthService with customer token support
  - Add verify_password() method
  - Add create_access_token_with_data() for custom token payloads
- Update CustomerService authentication
  - Generate customer-specific JWT tokens with type="customer"
  - Use vendor-scoped customer lookup
- Enhance exception handler
  - Sanitize validation errors to prevent password leaks in logs
  - Fix shop login redirect to support multi-access routing
- Improve vendor context detection from Referer header
  - Consistent "path" detection method for cookie path logic

Schema Changes:
- Rename UserLogin.username to email_or_username for flexibility
- Update field validators accordingly

API Changes:
- Update admin/vendor auth endpoints to use email_or_username
- Customer auth already uses email field correctly

Route Changes:
- Update shop account routes to use Customer dependency
- Add /account redirect (without trailing slash)
- Change parameter names from current_user to current_customer

Frontend Changes:
- Update login forms to use email_or_username in API calls
- Change button text from "Log in" to "Sign in" for consistency
- Improve loading spinner layout with flexbox

Security Improvements:
- Customer tokens scoped to vendor_id
- Token type validation prevents cross-context token usage
- Password inputs redacted from validation error logs

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-25 21:08:49 +01:00
parent 1f2ccb4668
commit 6735d99df2
13 changed files with 219 additions and 81 deletions

View File

@@ -315,9 +315,9 @@ def get_current_customer_from_cookie_or_header(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
customer_token: Optional[str] = Cookie(None),
db: Session = Depends(get_db),
) -> User:
):
"""
Get current customer user from customer_token cookie or Authorization header.
Get current customer from customer_token cookie or Authorization header.
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.
@@ -333,12 +333,15 @@ def get_current_customer_from_cookie_or_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)
"""
from models.database.customer import Customer
from jose import jwt, JWTError
from datetime import datetime, timezone
token, source = _get_token_from_request(
credentials,
customer_token,
@@ -350,35 +353,50 @@ def get_current_customer_from_cookie_or_header(
logger.warning(f"Customer auth failed: No token for {request.url.path}")
raise InvalidTokenException("Customer authentication required")
# Validate token and get user
user = _validate_user_token(token, db)
# CRITICAL: Block admins from customer routes
if user.role == "admin":
logger.warning(
f"Admin user {user.username} attempted shop account: {request.url.path}"
)
raise InsufficientPermissionsException(
"Customer access only - admins cannot use shop"
# Decode and validate customer JWT token
try:
payload = jwt.decode(
token,
auth_manager.secret_key,
algorithms=[auth_manager.algorithm]
)
# CRITICAL: Block vendors from customer routes
if user.role == "vendor":
logger.warning(
f"Vendor user {user.username} attempted shop account: {request.url.path}"
)
raise InsufficientPermissionsException(
"Customer access only - vendors cannot use shop"
)
# Verify this is a customer token
token_type = payload.get("type")
if token_type != "customer":
logger.warning(f"Invalid token type for customer route: {token_type}")
raise InvalidTokenException("Customer authentication required")
# Verify user is customer
if user.role != "customer":
logger.warning(
f"Non-customer user {user.username} attempted shop account: {request.url.path}"
)
raise InsufficientPermissionsException("Customer privileges required")
# Get customer ID from token
customer_id: str = payload.get("sub")
if customer_id is None:
logger.warning("Token missing 'sub' (customer_id)")
raise InvalidTokenException("Invalid token")
return user
# Verify token hasn't expired
exp = payload.get("exp")
if exp and datetime.fromtimestamp(exp, tz=timezone.utc) < datetime.now(timezone.utc):
logger.warning(f"Expired customer token for customer_id={customer_id}")
raise InvalidTokenException("Token has expired")
except JWTError as e:
logger.warning(f"JWT decode error: {str(e)}")
raise InvalidTokenException("Could not validate credentials")
# Load customer from database
customer = db.query(Customer).filter(Customer.id == int(customer_id)).first()
if not customer:
logger.warning(f"Customer not found: {customer_id}")
raise InvalidTokenException("Customer not found")
if not customer.is_active:
logger.warning(f"Inactive customer attempted access: {customer.email}")
raise InvalidTokenException("Customer account is inactive")
logger.debug(f"Customer authenticated: {customer.email} (ID: {customer.id})")
return customer
def get_current_customer_api(

View File

@@ -49,7 +49,7 @@ def admin_login(
# Verify user is admin
if login_result["user"].role != "admin":
logger.warning(f"Non-admin user attempted admin login: {user_credentials.username}")
logger.warning(f"Non-admin user attempted admin login: {user_credentials.email_or_username}")
raise InvalidCredentialsException("Admin access required")
logger.info(f"Admin login successful: {login_result['user'].username}")

View File

@@ -20,14 +20,24 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.services.customer_service import customer_service
from models.schema.auth import LoginResponse, UserLogin
from models.schema.auth import UserLogin
from models.schema.customer import CustomerRegister, CustomerResponse
from app.core.environment import should_use_secure_cookies
from pydantic import BaseModel
router = APIRouter()
logger = logging.getLogger(__name__)
# Response model for customer login
class CustomerLoginResponse(BaseModel):
"""Customer login response with token and customer data."""
access_token: str
token_type: str
expires_in: int
user: CustomerResponse # Use CustomerResponse instead of UserResponse
@router.post("/auth/register", response_model=CustomerResponse)
def register_customer(
request: Request,
@@ -85,7 +95,7 @@ def register_customer(
return CustomerResponse.model_validate(customer)
@router.post("/auth/login", response_model=LoginResponse)
@router.post("/auth/login", response_model=CustomerLoginResponse)
def customer_login(
request: Request,
user_credentials: UserLogin,
@@ -144,8 +154,18 @@ def customer_login(
}
)
# Calculate cookie path based on vendor access method
vendor_context = getattr(request.state, 'vendor_context', None)
access_method = vendor_context.get('detection_method', 'unknown') if vendor_context else 'unknown'
cookie_path = "/shop" # Default for domain/subdomain access
if access_method == "path":
# For path-based access like /vendors/wizamart/shop
full_prefix = vendor_context.get('full_prefix', '/vendor/') if vendor_context else '/vendor/'
cookie_path = f"{full_prefix}{vendor.subdomain}/shop"
# Set HTTP-only cookie for browser navigation
# CRITICAL: path=/shop restricts cookie to shop routes only
# Cookie path matches the vendor's shop routes
response.set_cookie(
key="customer_token",
value=login_result["token_data"]["access_token"],
@@ -153,24 +173,25 @@ def customer_login(
secure=should_use_secure_cookies(), # HTTPS only in production/staging
samesite="lax", # CSRF protection
max_age=login_result["token_data"]["expires_in"], # Match JWT expiry
path="/shop", # RESTRICTED TO SHOP ROUTES ONLY
path=cookie_path, # Matches vendor's shop routes
)
logger.debug(
f"Set customer_token cookie with {login_result['token_data']['expires_in']}s expiry "
f"(path=/shop, httponly=True, secure={should_use_secure_cookies()})",
f"(path={cookie_path}, httponly=True, secure={should_use_secure_cookies()})",
extra={
"expires_in": login_result['token_data']['expires_in'],
"secure": should_use_secure_cookies(),
"cookie_path": cookie_path,
}
)
# Return full login response
return LoginResponse(
return CustomerLoginResponse(
access_token=login_result["token_data"]["access_token"],
token_type=login_result["token_data"]["token_type"],
expires_in=login_result["token_data"]["expires_in"],
user=login_result["customer"], # Return customer as user
user=CustomerResponse.model_validate(login_result["customer"]),
)
@@ -197,13 +218,23 @@ def customer_logout(
}
)
# Calculate cookie path based on vendor access method (must match login)
vendor_context = getattr(request.state, 'vendor_context', None)
access_method = vendor_context.get('detection_method', 'unknown') if vendor_context else 'unknown'
cookie_path = "/shop" # Default for domain/subdomain access
if access_method == "path" and vendor:
# For path-based access like /vendors/wizamart/shop
full_prefix = vendor_context.get('full_prefix', '/vendor/') if vendor_context else '/vendor/'
cookie_path = f"{full_prefix}{vendor.subdomain}/shop"
# Clear the cookie (must match path used when setting)
response.delete_cookie(
key="customer_token",
path="/shop",
path=cookie_path,
)
logger.debug("Deleted customer_token cookie")
logger.debug(f"Deleted customer_token cookie (path={cookie_path})")
return {"message": "Logged out successfully"}

View File

@@ -135,10 +135,19 @@ def setup_exception_handlers(app):
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""Handle Pydantic validation errors with consistent format."""
# Sanitize errors to remove sensitive data from logs
sanitized_errors = []
for error in exc.errors():
sanitized_error = error.copy()
# Remove 'input' field which may contain passwords
if 'input' in sanitized_error:
sanitized_error['input'] = '<redacted>'
sanitized_errors.append(sanitized_error)
logger.error(
f"Validation error in {request.method} {request.url}: {exc.errors()}",
f"Validation error in {request.method} {request.url}: {len(sanitized_errors)} validation error(s)",
extra={
"validation_errors": exc.errors(),
"validation_errors": sanitized_errors,
"url": str(request.url),
"method": request.method,
"exception_type": "RequestValidationError",
@@ -357,6 +366,7 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
Redirect to appropriate login page based on request context.
Uses context detection to determine admin vs vendor vs shop login.
Properly handles multi-access routing (domain, subdomain, path-based).
"""
context_type = get_request_context(request)
@@ -368,8 +378,19 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
return RedirectResponse(url="/vendor/login", status_code=302)
elif context_type == RequestContext.SHOP:
# For shop context, redirect to shop login (customer login)
logger.debug("Redirecting to /shop/login")
return RedirectResponse(url="/shop/login", status_code=302)
# Calculate base_url for proper routing (supports domain, subdomain, and path-based access)
vendor = getattr(request.state, 'vendor', None)
vendor_context = getattr(request.state, 'vendor_context', None)
access_method = vendor_context.get('detection_method', 'unknown') if vendor_context else 'unknown'
base_url = "/"
if access_method == "path" and vendor:
full_prefix = vendor_context.get('full_prefix', '/vendor/') if vendor_context else '/vendor/'
base_url = f"{full_prefix}{vendor.subdomain}/"
login_url = f"{base_url}shop/account/login"
logger.debug(f"Redirecting to {login_url}")
return RedirectResponse(url=login_url, status_code=302)
else:
# Fallback to root for unknown contexts
logger.debug("Unknown context, redirecting to /")

View File

@@ -38,7 +38,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_from_cookie_or_header, get_db
from app.services.content_page_service import content_page_service
from models.database.user import User
from models.database.customer import Customer
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@@ -370,10 +370,11 @@ async def shop_forgot_password_page(request: Request):
# CUSTOMER ACCOUNT - AUTHENTICATED ROUTES
# ============================================================================
@router.get("/account", response_class=RedirectResponse, include_in_schema=False)
@router.get("/account/", response_class=RedirectResponse, include_in_schema=False)
async def shop_account_root(request: Request):
"""
Redirect /shop/account/ to dashboard.
Redirect /shop/account or /shop/account/ to dashboard.
"""
logger.debug(
f"[SHOP_HANDLER] shop_products_page REACHED",
@@ -400,7 +401,7 @@ async def shop_account_root(request: Request):
@router.get("/account/dashboard", response_class=HTMLResponse, include_in_schema=False)
async def shop_account_dashboard_page(
request: Request,
current_user: User = Depends(get_current_customer_from_cookie_or_header),
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
db: Session = Depends(get_db)
):
"""
@@ -419,14 +420,14 @@ async def shop_account_dashboard_page(
return templates.TemplateResponse(
"shop/account/dashboard.html",
get_shop_context(request, user=current_user)
get_shop_context(request, user=current_customer)
)
@router.get("/account/orders", response_class=HTMLResponse, include_in_schema=False)
async def shop_orders_page(
request: Request,
current_user: User = Depends(get_current_customer_from_cookie_or_header),
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
db: Session = Depends(get_db)
):
"""
@@ -445,7 +446,7 @@ async def shop_orders_page(
return templates.TemplateResponse(
"shop/account/orders.html",
get_shop_context(request, user=current_user)
get_shop_context(request, user=current_customer)
)
@@ -453,7 +454,7 @@ async def shop_orders_page(
async def shop_order_detail_page(
request: Request,
order_id: int = Path(..., description="Order ID"),
current_user: User = Depends(get_current_customer_from_cookie_or_header),
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
db: Session = Depends(get_db)
):
"""
@@ -472,14 +473,14 @@ async def shop_order_detail_page(
return templates.TemplateResponse(
"shop/account/order-detail.html",
get_shop_context(request, user=current_user, order_id=order_id)
get_shop_context(request, user=current_customer, order_id=order_id)
)
@router.get("/account/profile", response_class=HTMLResponse, include_in_schema=False)
async def shop_profile_page(
request: Request,
current_user: User = Depends(get_current_customer_from_cookie_or_header),
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
db: Session = Depends(get_db)
):
"""
@@ -498,14 +499,14 @@ async def shop_profile_page(
return templates.TemplateResponse(
"shop/account/profile.html",
get_shop_context(request, user=current_user)
get_shop_context(request, user=current_customer)
)
@router.get("/account/addresses", response_class=HTMLResponse, include_in_schema=False)
async def shop_addresses_page(
request: Request,
current_user: User = Depends(get_current_customer_from_cookie_or_header),
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
db: Session = Depends(get_db)
):
"""
@@ -524,14 +525,14 @@ async def shop_addresses_page(
return templates.TemplateResponse(
"shop/account/addresses.html",
get_shop_context(request, user=current_user)
get_shop_context(request, user=current_customer)
)
@router.get("/account/wishlist", response_class=HTMLResponse, include_in_schema=False)
async def shop_wishlist_page(
request: Request,
current_user: User = Depends(get_current_customer_from_cookie_or_header),
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
db: Session = Depends(get_db)
):
"""
@@ -550,14 +551,14 @@ async def shop_wishlist_page(
return templates.TemplateResponse(
"shop/account/wishlist.html",
get_shop_context(request, user=current_user)
get_shop_context(request, user=current_customer)
)
@router.get("/account/settings", response_class=HTMLResponse, include_in_schema=False)
async def shop_settings_page(
request: Request,
current_user: User = Depends(get_current_customer_from_cookie_or_header),
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
db: Session = Depends(get_db)
):
"""
@@ -576,7 +577,7 @@ async def shop_settings_page(
return templates.TemplateResponse(
"shop/account/settings.html",
get_shop_context(request, user=current_user)
get_shop_context(request, user=current_customer)
)

View File

@@ -98,7 +98,7 @@ class AuthService:
"""
try:
user = self.auth_manager.authenticate_user(
db, user_credentials.username, user_credentials.password
db, user_credentials.email_or_username, user_credentials.password
)
if not user:
raise InvalidCredentialsException("Incorrect username or password")
@@ -161,6 +161,52 @@ class AuthService:
logger.error(f"Error hashing password: {str(e)}")
raise ValidationException("Failed to hash password")
def verify_password(self, plain_password: str, hashed_password: str) -> bool:
"""Verify password against hash."""
try:
return self.auth_manager.verify_password(plain_password, hashed_password)
except Exception as e:
logger.error(f"Error verifying password: {str(e)}")
return False
def create_access_token_with_data(self, data: dict) -> dict:
"""
Create JWT token with custom data payload.
Useful for non-User entities like customers that need tokens.
Args:
data: Dictionary containing token payload data (must include 'sub')
Returns:
Dictionary with access_token, token_type, and expires_in
"""
from datetime import datetime, timedelta, timezone
from jose import jwt
from app.core.config import settings
try:
expires_delta = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
expire = datetime.now(timezone.utc) + expires_delta
# Build payload with provided data
payload = {
**data,
"exp": expire,
"iat": datetime.now(timezone.utc),
}
token = jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
return {
"access_token": token,
"token_type": "bearer",
"expires_in": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
}
except Exception as e:
logger.error(f"Error creating access token with data: {str(e)}")
raise ValidationException("Failed to create access token")
# Private helper methods
def _email_exists(self, db: Session, email: str) -> bool:
"""Check if email already exists."""

View File

@@ -149,15 +149,15 @@ class CustomerService:
customer = db.query(Customer).filter(
and_(
Customer.vendor_id == vendor_id,
Customer.email == credentials.username.lower()
Customer.email == credentials.email_or_username.lower()
)
).first()
if not customer:
raise InvalidCustomerCredentialsException()
# Verify password
if not self.auth_service.verify_password(
# Verify password using auth_manager directly
if not self.auth_service.auth_manager.verify_password(
credentials.password,
customer.hashed_password
):
@@ -168,14 +168,30 @@ class CustomerService:
raise CustomerNotActiveException(customer.email)
# Generate JWT token with customer context
token_data = self.auth_service.create_access_token(
data={
"sub": str(customer.id),
"email": customer.email,
"vendor_id": vendor_id,
"type": "customer"
}
)
# Use auth_manager directly since Customer is not a User model
from datetime import datetime, timedelta, timezone
from jose import jwt
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,
"vendor_id": vendor_id,
"type": "customer",
"exp": expire,
"iat": datetime.now(timezone.utc),
}
token = jwt.encode(payload, auth_manager.secret_key, algorithm=auth_manager.algorithm)
token_data = {
"access_token": token,
"token_type": "bearer",
"expires_in": auth_manager.token_expire_minutes * 60,
}
logger.info(
f"Customer login successful: {customer.email} "

View File

@@ -69,8 +69,8 @@
<button type="submit" :disabled="loading"
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!loading">Log in</span>
<span x-show="loading">
<span x-show="!loading">Sign in</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>

View File

@@ -84,8 +84,8 @@
<button type="submit" :disabled="loading"
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!loading">Log in</span>
<span x-show="loading">
<span x-show="!loading">Sign in</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>

View File

@@ -256,13 +256,18 @@ class VendorContextManager:
path_parts = referer_path[len(prefix):].split("/")
if len(path_parts) >= 1 and path_parts[0]:
vendor_code = path_parts[0]
prefix_len = len(prefix)
logger.debug(
f"[VENDOR] Extracted vendor from Referer path: {vendor_code}",
extra={"vendor_code": vendor_code, "method": "referer_path"}
)
# Use "path" as detection_method to be consistent with direct path detection
# This allows cookie path logic to work the same way
return {
"subdomain": vendor_code,
"detection_method": "referer_path",
"detection_method": "path", # Consistent with direct path detection
"path_prefix": referer_path[:prefix_len + len(vendor_code)], # /vendor/vendor1
"full_prefix": prefix, # /vendor/ or /vendors/
"host": referer_host,
"referer": referer,
}

View File

@@ -29,13 +29,13 @@ class UserRegister(BaseModel):
class UserLogin(BaseModel):
username: str = Field(..., description="Username")
email_or_username: str = Field(..., description="Username or email address")
password: str = Field(..., description="Password")
vendor_code: Optional[str] = Field(None, description="Optional vendor code for context")
@field_validator("username")
@field_validator("email_or_username")
@classmethod
def validate_username(cls, v):
def validate_email_or_username(cls, v):
return v.strip()

View File

@@ -111,7 +111,7 @@ function adminLogin() {
const url = '/admin/auth/login';
const payload = {
username: this.credentials.username.trim(),
email_or_username: this.credentials.username.trim(),
password: this.credentials.password
};

View File

@@ -90,7 +90,7 @@ function vendorLogin() {
const startTime = performance.now();
const response = await apiClient.post('/vendor/auth/login', {
username: this.credentials.username,
email_or_username: this.credentials.username,
password: this.credentials.password,
vendor_code: this.vendorCode
});