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:
@@ -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(
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user