refactor: migrate vendor auth, profile, team, dashboard, settings to modules
Tenancy module (identity & organizational hierarchy): - vendor_auth.py: login, logout, /me endpoints - vendor_profile.py: vendor profile get/update - vendor_team.py: team management, roles, permissions, invitations Core module (foundational non-domain features): - vendor_dashboard.py: dashboard statistics - vendor_settings.py: localization, business info, letzshop settings All routes auto-discovered via is_self_contained=True. Deleted 5 legacy files from app/api/v1/vendor/. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
196
app/modules/tenancy/routes/api/vendor_auth.py
Normal file
196
app/modules/tenancy/routes/api/vendor_auth.py
Normal file
@@ -0,0 +1,196 @@
|
||||
# app/modules/tenancy/routes/api/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.schema.auth import UserContext
|
||||
from models.schema.auth import LogoutResponse, UserLogin, VendorUserResponse
|
||||
|
||||
vendor_auth_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
|
||||
|
||||
|
||||
@vendor_auth_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 = auth_service.get_vendor_by_code(db, vendor_code)
|
||||
|
||||
# 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 has access to this vendor
|
||||
has_access, role = auth_service.get_user_vendor_role(db, user, vendor)
|
||||
|
||||
if has_access:
|
||||
vendor_role = role
|
||||
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
|
||||
vendor, vendor_role = auth_service.find_user_vendor(user)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@vendor_auth_router.post("/logout", response_model=LogoutResponse)
|
||||
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 LogoutResponse(message="Logged out successfully")
|
||||
|
||||
|
||||
@vendor_auth_router.get("/me", response_model=VendorUserResponse)
|
||||
def get_current_vendor_user(
|
||||
user: UserContext = 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 VendorUserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
)
|
||||
Reference in New Issue
Block a user