# app/api/deps.py """ Authentication dependencies for FastAPI routes. This module provides authentication dependencies for all three contexts in the multi-tenant application, implementing dual token storage with proper isolation: ADMIN ROUTES (/admin/*): - Cookie: admin_token (path=/admin) OR Authorization header - Role: admin only - Blocks: vendors, customers VENDOR ROUTES (/vendor/*): - Cookie: vendor_token (path=/vendor) OR Authorization header - Role: vendor only - Blocks: admins, customers CUSTOMER/SHOP ROUTES (/shop/account/*): - Cookie: customer_token (path=/shop) OR Authorization header - Role: customer only - Blocks: admins, vendors - Note: Public shop pages (/shop/products, etc.) don't require auth This dual authentication approach supports: - HTML pages: Use cookies (automatic browser behavior) - API calls: Use Authorization headers (explicit JavaScript control) The cookie path restrictions prevent cross-context cookie leakage: - admin_token is NEVER sent to /vendor/* or /shop/* - vendor_token is NEVER sent to /admin/* or /shop/* - customer_token is NEVER sent to /admin/* or /vendor/* """ import logging from datetime import UTC from fastapi import Cookie, Depends, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session from app.core.database import get_db from app.exceptions import ( AdminRequiredException, InsufficientPermissionsException, InsufficientVendorPermissionsException, InvalidTokenException, UnauthorizedVendorAccessException, VendorAccessDeniedException, VendorNotFoundException, VendorOwnerOnlyException, ) from app.services.vendor_service import vendor_service from middleware.auth import AuthManager from middleware.rate_limiter import RateLimiter from models.database.user import User from models.database.vendor import Vendor # Initialize dependencies security = HTTPBearer(auto_error=False) # auto_error=False prevents automatic 403 auth_manager = AuthManager() rate_limiter = RateLimiter() logger = logging.getLogger(__name__) # ============================================================================ # HELPER FUNCTIONS # ============================================================================ def _get_token_from_request( credentials: HTTPAuthorizationCredentials | None, cookie_value: str | None, cookie_name: str, request_path: str, ) -> tuple[str | None, str | None]: """ Extract token from Authorization header or cookie. Priority: 1. Authorization header (for API calls from JavaScript) 2. Cookie (for browser page navigation) Args: credentials: Optional Bearer token from Authorization header cookie_value: Optional token from cookie cookie_name: Name of the cookie (for logging) request_path: Request URL path (for logging) Returns: Tuple of (token, source) where source is "header" or "cookie" """ if credentials: logger.debug(f"Token found in Authorization header for {request_path}") return credentials.credentials, "header" if cookie_value: logger.debug(f"Token found in {cookie_name} cookie for {request_path}") return cookie_value, "cookie" return None, None def _validate_user_token(token: str, db: Session) -> User: """ Validate JWT token and return user. Args: token: JWT token string db: Database session Returns: User: Authenticated user object Raises: InvalidTokenException: If token is invalid """ mock_credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token) return auth_manager.get_current_user(db, mock_credentials) # ============================================================================ # ADMIN AUTHENTICATION # ============================================================================ def get_current_admin_from_cookie_or_header( request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(security), admin_token: str | None = Cookie(None), db: Session = Depends(get_db), ) -> User: """ Get current admin user from admin_token cookie or Authorization header. Used for admin HTML pages (/admin/*) that need cookie-based auth. Priority: 1. Authorization header (API calls) 2. admin_token cookie (page navigation) Args: request: FastAPI request credentials: Optional Bearer token from header admin_token: Optional token from admin_token cookie db: Database session Returns: User: Authenticated admin user Raises: InvalidTokenException: If no token or invalid token AdminRequiredException: If user is not admin """ token, source = _get_token_from_request( credentials, admin_token, "admin_token", str(request.url.path) ) if not token: logger.warning(f"Admin auth failed: No token for {request.url.path}") raise InvalidTokenException("Admin authentication required") # Validate token and get user user = _validate_user_token(token, db) # Verify user is admin if user.role != "admin": logger.warning( f"Non-admin user {user.username} attempted admin route: {request.url.path}" ) raise AdminRequiredException("Admin privileges required") return user def get_current_admin_api( credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db), ) -> User: """ Get current admin user from Authorization header ONLY. Used for admin API endpoints that should not accept cookies. This prevents CSRF attacks on API endpoints. Args: credentials: Bearer token from Authorization header db: Database session Returns: User: Authenticated admin user Raises: InvalidTokenException: If no token or invalid token AdminRequiredException: If user is not admin """ if not credentials: raise InvalidTokenException("Authorization header required for API calls") user = _validate_user_token(credentials.credentials, db) if user.role != "admin": logger.warning(f"Non-admin user {user.username} attempted admin API") raise AdminRequiredException("Admin privileges required") return user # ============================================================================ # VENDOR AUTHENTICATION # ============================================================================ def get_current_vendor_from_cookie_or_header( request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(security), vendor_token: str | None = Cookie(None), db: Session = Depends(get_db), ) -> User: """ Get current vendor user from vendor_token cookie or Authorization header. Used for vendor HTML pages (/vendor/*) that need cookie-based auth. Priority: 1. Authorization header (API calls) 2. vendor_token cookie (page navigation) Args: request: FastAPI request credentials: Optional Bearer token from header vendor_token: Optional token from vendor_token cookie db: Database session Returns: User: Authenticated vendor user Raises: InvalidTokenException: If no token or invalid token InsufficientPermissionsException: If user is not vendor or is admin """ token, source = _get_token_from_request( credentials, vendor_token, "vendor_token", str(request.url.path) ) if not token: logger.warning(f"Vendor auth failed: No token for {request.url.path}") raise InvalidTokenException("Vendor authentication required") # Validate token and get user user = _validate_user_token(token, db) # CRITICAL: Block admins from vendor routes if user.role == "admin": logger.warning( f"Admin user {user.username} attempted vendor route: {request.url.path}" ) raise InsufficientPermissionsException( "Vendor access only - admins cannot use vendor portal" ) # Verify user is vendor if user.role != "vendor": logger.warning( f"Non-vendor user {user.username} attempted vendor route: {request.url.path}" ) raise InsufficientPermissionsException("Vendor privileges required") return user def get_current_vendor_api( credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db), ) -> User: """ Get current vendor user from Authorization header ONLY. Used for vendor API endpoints that should not accept cookies. Validates that: 1. Token contains vendor context (token_vendor_id) 2. User still has access to the vendor specified in the token Args: credentials: Bearer token from Authorization header db: Database session Returns: User: Authenticated vendor user (with token_vendor_id, token_vendor_code, token_vendor_role) Raises: InvalidTokenException: If no token, invalid token, or missing vendor context InsufficientPermissionsException: If user is not vendor or lost access to vendor """ if not credentials: raise InvalidTokenException("Authorization header required for API calls") user = _validate_user_token(credentials.credentials, db) # Block admins from vendor API if user.role == "admin": logger.warning(f"Admin user {user.username} attempted vendor API") raise InsufficientPermissionsException("Vendor access only") if user.role != "vendor": logger.warning(f"Non-vendor user {user.username} attempted vendor API") raise InsufficientPermissionsException("Vendor privileges required") # Require vendor context in token if not hasattr(user, "token_vendor_id"): raise InvalidTokenException("Token missing vendor information. Please login again.") vendor_id = user.token_vendor_id # Verify user still has access to this vendor if not user.is_member_of(vendor_id): logger.warning( f"User {user.username} lost access to vendor_id={vendor_id}" ) raise InsufficientPermissionsException( "Access to vendor has been revoked. Please login again." ) logger.debug( f"Vendor API access: user={user.username}, vendor_id={vendor_id}, " f"vendor_code={getattr(user, 'token_vendor_code', 'N/A')}" ) return user # ============================================================================ # CUSTOMER AUTHENTICATION (SHOP) # ============================================================================ def _validate_customer_token(token: str, request: Request, db: Session): """ Validate customer JWT token and return Customer object. Validates: 1. Token signature and expiration 2. Token type is "customer" 3. Customer exists and is active 4. Token vendor_id matches request vendor (URL-based) Args: token: JWT token string request: FastAPI request (for vendor context) db: Database session Returns: Customer: Authenticated customer object Raises: InvalidTokenException: If token is invalid or expired UnauthorizedVendorAccessException: If vendor mismatch """ from datetime import datetime from jose import JWTError, jwt from models.database.customer import Customer # Decode and validate customer JWT token try: payload = jwt.decode( token, auth_manager.secret_key, algorithms=[auth_manager.algorithm] ) # 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") # 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") # Verify token hasn't expired exp = payload.get("exp") if exp and datetime.fromtimestamp(exp, tz=UTC) < datetime.now(UTC): logger.warning(f"Expired customer token for customer_id={customer_id}") raise InvalidTokenException("Token has expired") # Get vendor_id from token for validation token_vendor_id = payload.get("vendor_id") 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") # Validate vendor context matches token # This prevents using a customer token from vendor A on vendor B's shop request_vendor = getattr(request.state, "vendor", None) if request_vendor and token_vendor_id: if request_vendor.id != token_vendor_id: logger.warning( f"Customer {customer.email} token vendor mismatch: " f"token={token_vendor_id}, request={request_vendor.id}" ) raise UnauthorizedVendorAccessException( vendor_code=request_vendor.vendor_code, user_id=customer.id, ) logger.debug(f"Customer authenticated: {customer.email} (ID: {customer.id})") return customer def get_current_customer_from_cookie_or_header( request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(security), customer_token: str | None = Cookie(None), db: Session = Depends(get_db), ): """ 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. Validates that token vendor_id matches request vendor (URL-based detection). Priority: 1. Authorization header (API calls) 2. customer_token cookie (page navigation) Args: request: FastAPI request credentials: Optional Bearer token from header customer_token: Optional token from customer_token cookie db: Database session Returns: Customer: Authenticated customer object Raises: InvalidTokenException: If no token or invalid token UnauthorizedVendorAccessException: If vendor mismatch """ token, source = _get_token_from_request( credentials, customer_token, "customer_token", str(request.url.path) ) if not token: logger.warning(f"Customer auth failed: No token for {request.url.path}") raise InvalidTokenException("Customer authentication required") return _validate_customer_token(token, request, db) def get_current_customer_api( request: Request, credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db), ): """ Get current customer from Authorization header ONLY. Used for shop API endpoints that should not accept cookies. Validates that token vendor_id matches request vendor (URL-based detection). Args: request: FastAPI request (for vendor context) credentials: Bearer token from Authorization header db: Database session Returns: Customer: Authenticated customer object Raises: InvalidTokenException: If no token or invalid token UnauthorizedVendorAccessException: If vendor mismatch """ if not credentials: raise InvalidTokenException("Authorization header required for API calls") return _validate_customer_token(credentials.credentials, request, db) # ============================================================================ # GENERIC AUTHENTICATION (for mixed-use endpoints) # ============================================================================ def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db), ) -> User: """ Get current authenticated user from Authorization header only. Generic authentication without role checking. Used for endpoints accessible to any authenticated user. Args: credentials: Bearer token from Authorization header db: Database session Returns: User: Authenticated user (any role) Raises: InvalidTokenException: If no token or invalid token """ if not credentials: raise InvalidTokenException("Authorization header required") return _validate_user_token(credentials.credentials, db) # ============================================================================ # VENDOR OWNERSHIP VERIFICATION # ============================================================================ def get_user_vendor( vendor_code: str, current_user: User = Depends(get_current_vendor_from_cookie_or_header), db: Session = Depends(get_db), ) -> Vendor: """ Get vendor and verify user ownership/membership. Ensures the current user has access to the specified vendor. - Vendor owners can access their own vendor - Team members can access their vendor - Admins are BLOCKED (use admin routes instead) Args: vendor_code: Vendor code to look up current_user: Current authenticated vendor user db: Database session Returns: Vendor: Vendor object if user has access Raises: VendorNotFoundException: If vendor doesn't exist UnauthorizedVendorAccessException: If user doesn't have access """ from sqlalchemy.orm import joinedload vendor = ( db.query(Vendor) .options(joinedload(Vendor.company)) .filter(Vendor.vendor_code == vendor_code.upper()) .first() ) if not vendor: raise VendorNotFoundException(vendor_code) # Check if user owns this vendor (via company ownership) if vendor.company and vendor.company.owner_user_id == current_user.id: return vendor # Check if user is team member # TODO: Add team member check when VendorUser relationship is set up # User doesn't have access to this vendor raise UnauthorizedVendorAccessException(vendor_code, current_user.id) # ============================================================================ # PERMISSIONS CHECKING # ============================================================================ def require_vendor_permission(permission: str): """ Dependency factory to require a specific vendor permission. Uses token_vendor_id from JWT token (authenticated vendor API pattern). The vendor object is loaded and stored in request.state.vendor for endpoint use. Usage: @router.get("/products") def list_products( request: Request, user: User = Depends(require_vendor_permission(VendorPermissions.PRODUCTS_VIEW.value)) ): vendor = request.state.vendor # Vendor is set by this dependency ... """ def permission_checker( request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_vendor_from_cookie_or_header), ) -> User: # Get vendor ID from JWT token if not hasattr(current_user, "token_vendor_id"): raise InvalidTokenException("Token missing vendor information. Please login again.") vendor_id = current_user.token_vendor_id # Load vendor from database (raises VendorNotFoundException if not found) vendor = vendor_service.get_vendor_by_id(db, vendor_id) # Store vendor in request state for endpoint use request.state.vendor = vendor # Check if user has permission if not current_user.has_vendor_permission(vendor.id, permission): raise InsufficientVendorPermissionsException( required_permission=permission, vendor_code=vendor.vendor_code, ) return current_user return permission_checker def require_vendor_owner( request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_vendor_from_cookie_or_header), ) -> User: """ Dependency to require vendor owner role. Uses token_vendor_id from JWT token (authenticated vendor API pattern). The vendor object is loaded and stored in request.state.vendor for endpoint use. Usage: @router.delete("/team/{user_id}") def remove_team_member( request: Request, user: User = Depends(require_vendor_owner) ): vendor = request.state.vendor # Vendor is set by this dependency ... """ # Get vendor ID from JWT token if not hasattr(current_user, "token_vendor_id"): raise InvalidTokenException("Token missing vendor information. Please login again.") vendor_id = current_user.token_vendor_id # Load vendor from database (raises VendorNotFoundException if not found) vendor = vendor_service.get_vendor_by_id(db, vendor_id) # Store vendor in request state for endpoint use request.state.vendor = vendor if not current_user.is_owner_of(vendor.id): raise VendorOwnerOnlyException( operation="team management", vendor_code=vendor.vendor_code, ) return current_user def require_any_vendor_permission(*permissions: str): """ Dependency factory to require ANY of the specified permissions. Uses token_vendor_id from JWT token (authenticated vendor API pattern). The vendor object is loaded and stored in request.state.vendor for endpoint use. Usage: @router.get("/dashboard") def dashboard( request: Request, user: User = Depends(require_any_vendor_permission( VendorPermissions.DASHBOARD_VIEW.value, VendorPermissions.REPORTS_VIEW.value )) ): vendor = request.state.vendor # Vendor is set by this dependency ... """ def permission_checker( request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_vendor_from_cookie_or_header), ) -> User: # Get vendor ID from JWT token if not hasattr(current_user, "token_vendor_id"): raise InvalidTokenException("Token missing vendor information. Please login again.") vendor_id = current_user.token_vendor_id # Load vendor from database (raises VendorNotFoundException if not found) vendor = vendor_service.get_vendor_by_id(db, vendor_id) # Store vendor in request state for endpoint use request.state.vendor = vendor # Check if user has ANY of the required permissions has_permission = any( current_user.has_vendor_permission(vendor.id, perm) for perm in permissions ) if not has_permission: raise InsufficientVendorPermissionsException( required_permission=f"Any of: {', '.join(permissions)}", vendor_code=vendor.vendor_code, ) return current_user return permission_checker def require_all_vendor_permissions(*permissions: str): """ Dependency factory to require ALL of the specified permissions. Uses token_vendor_id from JWT token (authenticated vendor API pattern). The vendor object is loaded and stored in request.state.vendor for endpoint use. Usage: @router.post("/products/bulk-delete") def bulk_delete_products( request: Request, user: User = Depends(require_all_vendor_permissions( VendorPermissions.PRODUCTS_VIEW.value, VendorPermissions.PRODUCTS_DELETE.value )) ): vendor = request.state.vendor # Vendor is set by this dependency ... """ def permission_checker( request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_vendor_from_cookie_or_header), ) -> User: # Get vendor ID from JWT token if not hasattr(current_user, "token_vendor_id"): raise InvalidTokenException("Token missing vendor information. Please login again.") vendor_id = current_user.token_vendor_id # Load vendor from database (raises VendorNotFoundException if not found) vendor = vendor_service.get_vendor_by_id(db, vendor_id) # Store vendor in request state for endpoint use request.state.vendor = vendor # Check if user has ALL required permissions missing_permissions = [ perm for perm in permissions if not current_user.has_vendor_permission(vendor.id, perm) ] if missing_permissions: raise InsufficientVendorPermissionsException( required_permission=f"All of: {', '.join(permissions)}", vendor_code=vendor.vendor_code, ) return current_user return permission_checker def get_user_permissions( request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_vendor_from_cookie_or_header), ) -> list: """ Get all permissions for current user in current vendor. Uses token_vendor_id from JWT token (authenticated vendor API pattern). Also sets request.state.vendor for endpoint use. Returns empty list if no vendor context in token. """ # Get vendor ID from JWT token if not hasattr(current_user, "token_vendor_id"): return [] vendor_id = current_user.token_vendor_id # Load vendor from database vendor = vendor_service.get_vendor_by_id(db, vendor_id) # Store vendor in request state for endpoint use request.state.vendor = vendor # If owner, return all permissions if current_user.is_owner_of(vendor.id): from app.core.permissions import VendorPermissions return [p.value for p in VendorPermissions] # Get permissions from vendor membership for vm in current_user.vendor_memberships: if vm.vendor_id == vendor.id and vm.is_active: return vm.get_all_permissions() return [] # ============================================================================ # OPTIONAL AUTHENTICATION (For Login Page Redirects) # ============================================================================ def get_current_admin_optional( request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(security), admin_token: str | None = Cookie(None), db: Session = Depends(get_db), ) -> User | None: """ Get current admin user from admin_token cookie or Authorization header. Returns None instead of raising exceptions if not authenticated. Used for login pages to check if user is already authenticated. Priority: 1. Authorization header (API calls) 2. admin_token cookie (page navigation) Args: request: FastAPI request credentials: Optional Bearer token from header admin_token: Optional token from admin_token cookie db: Database session Returns: User: Authenticated admin user if valid token exists None: If no token, invalid token, or user is not admin """ token, source = _get_token_from_request( credentials, admin_token, "admin_token", str(request.url.path) ) if not token: return None try: # Validate token and get user user = _validate_user_token(token, db) # Verify user is admin if user.role == "admin": return user except Exception: # Invalid token or other error pass return None def get_current_vendor_optional( request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(security), vendor_token: str | None = Cookie(None), db: Session = Depends(get_db), ) -> User | None: """ Get current vendor user from vendor_token cookie or Authorization header. Returns None instead of raising exceptions if not authenticated. Used for login pages to check if user is already authenticated. Priority: 1. Authorization header (API calls) 2. vendor_token cookie (page navigation) Args: request: FastAPI request credentials: Optional Bearer token from header vendor_token: Optional token from vendor_token cookie db: Database session Returns: User: Authenticated vendor user if valid token exists None: If no token, invalid token, or user is not vendor """ token, source = _get_token_from_request( credentials, vendor_token, "vendor_token", str(request.url.path) ) if not token: return None try: # Validate token and get user user = _validate_user_token(token, db) # Verify user is vendor if user.role == "vendor": return user except Exception: # Invalid token or other error pass return None def get_current_customer_optional( request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(security), customer_token: str | None = Cookie(None), db: Session = Depends(get_db), ): """ Get current customer from customer_token cookie or Authorization header. Returns None instead of raising exceptions if not authenticated. Used for login pages to check if user is already authenticated. Priority: 1. Authorization header (API calls) 2. customer_token cookie (page navigation) Args: request: FastAPI request credentials: Optional Bearer token from header customer_token: Optional token from customer_token cookie db: Database session Returns: Customer: Authenticated customer if valid token exists None: If no token, invalid token, or vendor mismatch """ token, source = _get_token_from_request( credentials, customer_token, "customer_token", str(request.url.path) ) if not token: return None try: # Validate customer token (includes vendor validation) return _validate_customer_token(token, request, db) except Exception: # Invalid token, vendor mismatch, or other error return None