Files
orion/app/modules/tenancy/routes/api/admin_auth.py
Samir Boulahtit a77a8a3a98
All checks were successful
CI / ruff (push) Successful in 12s
CI / pytest (push) Successful in 50m57s
CI / validate (push) Successful in 24s
CI / dependency-scanning (push) Successful in 29s
CI / docs (push) Successful in 40s
CI / deploy (push) Successful in 51s
feat: multi-module improvements across merchant, store, i18n, and customer systems
- Fix platform-grouped merchant sidebar menu with core items at root level
- Add merchant store management (detail page, create store, team page)
- Fix store settings 500 error by removing dead stripe/API tab
- Move onboarding translations to module-owned locale files
- Fix onboarding banner i18n with server-side rendering + context inheritance
- Refactor login language selectors to use languageSelector() function (LANG-002)
- Move HTTPException handling to global exception handler in merchant routes (API-003)
- Add language selector to all login pages and portal headers
- Fix customer module: drop order stats from customer model, add to orders module
- Fix admin menu config visibility for super admin platform context
- Fix storefront auth and layout issues
- Add missing i18n translations for onboarding steps (en/fr/de/lb)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:48:25 +01:00

269 lines
8.5 KiB
Python

# app/modules/tenancy/routes/api/admin_auth.py
"""
Admin authentication endpoints.
Implements dual token storage with path restriction:
- Sets HTTP-only cookie with path=/admin (restricted to admin routes only)
- Returns token in response for localStorage (API calls)
This prevents admin cookies from being sent to store routes.
"""
import logging
from fastapi import APIRouter, Depends, Response
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, get_current_admin_from_cookie_or_header
from app.core.database import get_db
from app.core.environment import should_use_secure_cookies
from app.modules.core.services.auth_service import auth_service
from app.modules.tenancy.exceptions import (
InvalidCredentialsException,
)
from app.modules.tenancy.schemas.auth import (
LoginResponse,
LogoutResponse,
PlatformSelectResponse,
UserContext,
UserLogin,
UserResponse,
)
from app.modules.tenancy.services.admin_platform_service import admin_platform_service
from middleware.auth import AuthManager
admin_auth_router = APIRouter(prefix="/auth")
logger = logging.getLogger(__name__)
@admin_auth_router.post("/login", response_model=LoginResponse)
def admin_login(
user_credentials: UserLogin, response: Response, db: Session = Depends(get_db)
):
"""
Admin login endpoint.
Only allows users with 'admin' role to login.
Returns JWT token for authenticated admin users.
Sets token in two places:
1. HTTP-only cookie with path=/admin (for browser page navigation)
2. Response body (for localStorage and API calls)
The cookie is restricted to /admin/* routes only to prevent
it from being sent to store or other routes.
"""
# Authenticate user
login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
# Verify user is admin
if not login_result["user"].is_admin:
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}")
# Set HTTP-only cookie for browser navigation
# CRITICAL: path=/admin restricts cookie to admin routes only
response.set_cookie(
key="admin_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="/admin", # RESTRICTED TO ADMIN ROUTES ONLY
)
logger.debug(
f"Set admin_token cookie with {login_result['token_data']['expires_in']}s expiry "
f"(path=/admin, httponly=True, secure={should_use_secure_cookies()})"
)
# Also return token in response for localStorage (API calls)
return LoginResponse(
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["user"],
)
@admin_auth_router.get("/me", response_model=UserResponse)
def get_current_admin(current_user: UserContext = Depends(get_current_admin_api)):
"""
Get current authenticated admin user.
This endpoint validates the token and ensures the user has admin privileges.
Returns the current user's information.
Token can come from:
- Authorization header (API calls)
- admin_token cookie (browser navigation, path=/admin only)
"""
logger.info(f"Admin user info requested: {current_user.username}")
return current_user
@admin_auth_router.post("/logout", response_model=LogoutResponse)
def admin_logout(response: Response):
"""
Admin logout endpoint.
Clears the admin_token cookie.
Client should also remove token from localStorage.
"""
logger.info("Admin logout")
# Clear the cookie (must match path used when setting)
response.delete_cookie(
key="admin_token",
path="/admin",
)
# Also clear legacy cookie with path=/ (from before path isolation was added)
# This handles users who logged in before the path=/admin change
response.delete_cookie(
key="admin_token",
path="/",
)
logger.debug("Deleted admin_token cookies (both /admin and / paths)")
return LogoutResponse(message="Logged out successfully")
@admin_auth_router.get("/accessible-platforms")
def get_accessible_platforms(
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_admin_from_cookie_or_header),
):
"""
Get list of platforms this admin can access.
Returns:
- For super admins: All active platforms
- For platform admins: Only assigned platforms
"""
if current_user.is_super_admin:
platforms = admin_platform_service.get_all_active_platforms(db)
else:
platforms = admin_platform_service.get_platforms_for_admin(db, current_user.id)
return {
"platforms": [
{
"id": p.id,
"code": p.code,
"name": p.name,
"logo": p.logo,
}
for p in platforms
],
"is_super_admin": current_user.is_super_admin,
"requires_platform_selection": not current_user.is_super_admin and len(platforms) > 0,
"current_platform_id": current_user.token_platform_id,
}
@admin_auth_router.post("/select-platform", response_model=PlatformSelectResponse)
def select_platform(
platform_id: int,
response: Response,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_admin_from_cookie_or_header),
):
"""
Select platform context for an admin.
Issues a new JWT token with platform context.
Available to both platform admins and super admins.
Args:
platform_id: Platform ID to select
Returns:
PlatformSelectResponse with new token and platform info
"""
# Platform admins must have access; super admins can access any platform
if not current_user.is_super_admin:
admin_platform_service.validate_admin_platform_access(current_user, platform_id)
# Load platform
platform = admin_platform_service.get_platform_by_id(db, platform_id)
if not platform:
raise InvalidCredentialsException("Platform not found")
# Issue new token with platform context
auth_manager = AuthManager()
token_data = auth_manager.create_access_token(
user=current_user,
platform_id=platform.id,
platform_code=platform.code,
)
# Set cookie with new token
response.set_cookie(
key="admin_token",
value=token_data["access_token"],
httponly=True,
secure=should_use_secure_cookies(),
samesite="lax",
max_age=token_data["expires_in"],
path="/admin",
)
logger.info(f"Admin {current_user.username} selected platform {platform.code}")
return PlatformSelectResponse(
access_token=token_data["access_token"],
token_type=token_data["token_type"],
expires_in=token_data["expires_in"],
platform_id=platform.id,
platform_code=platform.code,
)
@admin_auth_router.post("/deselect-platform")
def deselect_platform(
response: Response,
current_user: UserContext = Depends(get_current_admin_from_cookie_or_header),
):
"""
Deselect platform context (return to global mode).
Only available to super admins. Issues a new JWT without platform context.
Returns:
New token without platform context
"""
if not current_user.is_super_admin:
raise InvalidCredentialsException(
"Only super admins can deselect platform (platform admins must always have a platform)"
)
# Issue new token without platform context
auth_manager = AuthManager()
token_data = auth_manager.create_access_token(user=current_user)
# Set cookie with new token
response.set_cookie(
key="admin_token",
value=token_data["access_token"],
httponly=True,
secure=should_use_secure_cookies(),
samesite="lax",
max_age=token_data["expires_in"],
path="/admin",
)
logger.info(f"Super admin {current_user.username} deselected platform (global mode)")
return {
"access_token": token_data["access_token"],
"token_type": token_data["token_type"],
"expires_in": token_data["expires_in"],
}