# app/api/v1/vendor/auth.py """ Vendor team authentication endpoints. Implements dual token storage with path restriction: - Sets HTTP-only cookie with path=/vendor (restricted to vendor routes only) - Returns token in response for localStorage (API calls) This prevents: - Vendor cookies from being sent to admin routes - Admin cookies from being sent to vendor routes - Cross-context authentication confusion """ import logging from fastapi import APIRouter, Depends, Request, Response from pydantic import BaseModel from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db from app.core.environment import should_use_secure_cookies from app.exceptions import InvalidCredentialsException from app.services.auth_service import auth_service from middleware.vendor_context import get_current_vendor from models.database.user import User from models.database.vendor import Role, Vendor, VendorUser from models.schema.auth import UserLogin router = APIRouter(prefix="/auth") logger = logging.getLogger(__name__) # Response model for vendor login class VendorLoginResponse(BaseModel): access_token: str token_type: str expires_in: int user: dict vendor: dict vendor_role: str @router.post("/login", response_model=VendorLoginResponse) def vendor_login( user_credentials: UserLogin, request: Request, response: Response, db: Session = Depends(get_db), ): """ Vendor team member login. Authenticates users who are part of a vendor team. Validates against vendor context if available. Sets token in two places: 1. HTTP-only cookie with path=/vendor (for browser page navigation) 2. Response body (for localStorage and API calls) Prevents admin users from logging into vendor portal. """ # Try to get vendor from middleware first vendor = get_current_vendor(request) # If no vendor from middleware, try to get from request body if not vendor and hasattr(user_credentials, "vendor_code"): vendor_code = getattr(user_credentials, "vendor_code", None) if vendor_code: vendor = ( db.query(Vendor) .filter( Vendor.vendor_code == vendor_code.upper(), Vendor.is_active == True ) .first() ) # Authenticate user login_result = auth_service.login_user(db=db, user_credentials=user_credentials) user = login_result["user"] # CRITICAL: Prevent admin users from using vendor login if user.role == "admin": logger.warning(f"Admin user attempted vendor login: {user.username}") raise InvalidCredentialsException( "Admins cannot access vendor portal. Please use admin portal." ) # Determine vendor and role vendor_role = "Member" if vendor: # Check if user is vendor owner is_owner = vendor.owner_user_id == user.id if is_owner: vendor_role = "Owner" else: # Check if user is team member vendor_user = ( db.query(VendorUser) .join(Role) .filter( VendorUser.user_id == user.id, VendorUser.vendor_id == vendor.id, VendorUser.is_active == True, ) .first() ) if vendor_user: vendor_role = vendor_user.role.name else: logger.warning( f"User {user.username} attempted login to vendor {vendor.vendor_code} " f"but is not authorized" ) raise InvalidCredentialsException( "You do not have access to this vendor" ) else: # No vendor context - find which vendor this user belongs to # Check owned vendors first if user.owned_vendors: vendor = user.owned_vendors[0] vendor_role = "Owner" # Check vendor memberships elif user.vendor_memberships: active_membership = next( (vm for vm in user.vendor_memberships if vm.is_active), None ) if active_membership: vendor = active_membership.vendor vendor_role = active_membership.role.name if not vendor: raise InvalidCredentialsException("User is not associated with any vendor") logger.info( f"Vendor team login successful: {user.username} " f"for vendor {vendor.vendor_code} as {vendor_role}" ) # Set HTTP-only cookie for browser navigation # CRITICAL: path=/vendor restricts cookie to vendor routes only response.set_cookie( key="vendor_token", value=login_result["token_data"]["access_token"], httponly=True, # JavaScript cannot access (XSS protection) 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="/vendor", # RESTRICTED TO VENDOR ROUTES ONLY ) logger.debug( f"Set vendor_token cookie with {login_result['token_data']['expires_in']}s expiry " f"(path=/vendor, httponly=True, secure={should_use_secure_cookies()})" ) # Return full login response return VendorLoginResponse( 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={ "id": user.id, "username": user.username, "email": user.email, "role": user.role, "is_active": user.is_active, }, vendor={ "id": vendor.id, "vendor_code": vendor.vendor_code, "subdomain": vendor.subdomain, "name": vendor.name, "is_active": vendor.is_active, "is_verified": vendor.is_verified, }, vendor_role=vendor_role, ) @router.post("/logout") def vendor_logout(response: Response): """ Vendor team member logout. Clears the vendor_token cookie. Client should also remove token from localStorage. """ logger.info("Vendor logout") # Clear the cookie (must match path used when setting) response.delete_cookie( key="vendor_token", path="/vendor", ) logger.debug("Deleted vendor_token cookie") return {"message": "Logged out successfully"} @router.get("/me") def get_current_vendor_user( user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db) ): """ Get current authenticated vendor user. This endpoint can be called to verify authentication and get user info. Requires Authorization header (header-only authentication for API endpoints). """ return { "id": user.id, "username": user.username, "email": user.email, "role": user.role, "is_active": user.is_active, }