Files
orion/app/api/v1/vendor/auth.py
Samir Boulahtit 66c967a04e feat: update API endpoints for company-vendor architecture
- Add transfer-ownership endpoint to companies API
- Add user search endpoint for autocomplete (/admin/users/search)
- Update company detail endpoint to include owner info and vendors list
- Update vendor endpoints to use company relationship for ownership
- Update deps.py vendor access check to use company.owner_user_id

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 19:39:31 +01:00

235 lines
7.4 KiB
Python

# 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 (via company ownership)
is_owner = vendor.company and vendor.company.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 (via company ownership)
for company in user.owned_companies:
if company.vendors:
vendor = company.vendors[0]
vendor_role = "Owner"
break
# Check vendor memberships if no owned vendor found
if not vendor and 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}"
)
# Create vendor-scoped access token with vendor information
token_data = auth_service.auth_manager.create_access_token(
user=user,
vendor_id=vendor.id,
vendor_code=vendor.vendor_code,
vendor_role=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=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=token_data["expires_in"], # Match JWT expiry
path="/vendor", # RESTRICTED TO VENDOR ROUTES ONLY
)
logger.debug(
f"Set vendor_token cookie with {token_data['expires_in']}s expiry "
f"(path=/vendor, httponly=True, secure={should_use_secure_cookies()})"
)
# Return full login response with vendor-scoped token
return VendorLoginResponse(
access_token=token_data["access_token"],
token_type=token_data["token_type"],
expires_in=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,
}