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

@@ -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} "