diff --git a/app/api/deps.py b/app/api/deps.py index 88d19721..d219a60a 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -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( diff --git a/app/api/v1/admin/auth.py b/app/api/v1/admin/auth.py index 3b6d67e0..5de07e00 100644 --- a/app/api/v1/admin/auth.py +++ b/app/api/v1/admin/auth.py @@ -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}") diff --git a/app/api/v1/shop/auth.py b/app/api/v1/shop/auth.py index 0fdb5a47..f9d50b43 100644 --- a/app/api/v1/shop/auth.py +++ b/app/api/v1/shop/auth.py @@ -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"} diff --git a/app/exceptions/handler.py b/app/exceptions/handler.py index f364078b..2ea2690e 100644 --- a/app/exceptions/handler.py +++ b/app/exceptions/handler.py @@ -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'] = '' + 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 /") diff --git a/app/routes/shop_pages.py b/app/routes/shop_pages.py index 513d3fa2..569113d9 100644 --- a/app/routes/shop_pages.py +++ b/app/routes/shop_pages.py @@ -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) ) diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 6a1db187..c2c6c400 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -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.""" diff --git a/app/services/customer_service.py b/app/services/customer_service.py index 61e4aa96..53e70ee6 100644 --- a/app/services/customer_service.py +++ b/app/services/customer_service.py @@ -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} " diff --git a/app/templates/admin/login.html b/app/templates/admin/login.html index 621d4742..f5aad69e 100644 --- a/app/templates/admin/login.html +++ b/app/templates/admin/login.html @@ -69,8 +69,8 @@