from __future__ import annotations import os import re import secrets import hashlib from datetime import datetime, timedelta, timezone from pathlib import Path from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status from pydantic import BaseModel from backend.auth.dependencies import UserPrincipal, create_access_token, get_current_user from backend.auth.service import ( list_tenant_users, login_with_directory, read_authenticated_user_profile, ) from backend.auth.user_directory import ensure_user_directory_schema router = APIRouter() ASSET_DIR = os.getenv("VELOCITY_ASSET_DIR", "/opt/dlami/nvme/assets") def _sanitize_filename(value: str) -> str: cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._") return cleaned or "upload" class LoginRequest(BaseModel): email: str password: str class PasswordRecoveryRequest(BaseModel): email: str class SessionSwitchRequest(BaseModel): userId: str def _role_level(role: str) -> int: levels = {"JUNIOR_BROKER": 0, "SENIOR_BROKER": 1, "SALES_DIRECTOR": 2, "ADMIN": 3, "SUPERADMIN": 4} return levels.get(role.upper(), -1) def _sso_provider_descriptor(provider: str, tenant_id: str) -> dict[str, str | bool]: normalized = provider.strip().lower() env_prefix = f"VELOCITY_SSO_{normalized.upper().replace('-', '_')}" kind = os.getenv(f"{env_prefix}_TYPE", "oauth").strip().lower() return { "id": normalized, "name": os.getenv(f"{env_prefix}_NAME", normalized.replace("_", " ").title()), "type": kind, "tenantId": tenant_id, "authorizationUrl": os.getenv(f"{env_prefix}_AUTH_URL", ""), "tokenUrl": os.getenv(f"{env_prefix}_TOKEN_URL", ""), "issuer": os.getenv(f"{env_prefix}_ISSUER", ""), "clientId": os.getenv(f"{env_prefix}_CLIENT_ID", ""), "metadataUrl": os.getenv(f"{env_prefix}_METADATA_URL", ""), "enabled": bool(os.getenv(f"{env_prefix}_AUTH_URL") or os.getenv(f"{env_prefix}_METADATA_URL")), } @router.post("/api/auth/login", tags=["Auth"]) async def login(body: LoginRequest, request: Request): """ Authenticate a user and return a JWT. Credentials are verified against the users_and_roles table. """ return await login_with_directory( app=request.app, email=body.email, password=body.password, ) @router.post("/api/auth/password-recovery", tags=["Auth"]) async def request_password_recovery(body: PasswordRecoveryRequest, request: Request): await ensure_user_directory_schema(request.app) pool = getattr(request.app.state, "db_pool", None) if pool is None: raise HTTPException(status_code=503, detail="Database unavailable.") normalized_email = body.email.strip().lower() if not normalized_email or "@" not in normalized_email: raise HTTPException(status_code=422, detail="email is required.") raw_token = secrets.token_urlsafe(32) token_hash = hashlib.sha256(raw_token.encode("utf-8")).hexdigest() expires_at = datetime.now(timezone.utc) + timedelta(minutes=int(os.getenv("VELOCITY_PASSWORD_RECOVERY_MINUTES", "30"))) async with pool.acquire() as conn: await conn.execute( """ CREATE TABLE IF NOT EXISTS auth_password_recovery_requests ( request_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL, token_hash TEXT, expires_at TIMESTAMPTZ, requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), status TEXT NOT NULL DEFAULT 'requested' ) """ ) await conn.execute( """ INSERT INTO auth_password_recovery_requests (email, token_hash, expires_at) VALUES ($1, $2, $3) """, normalized_email, token_hash, expires_at, ) response = { "status": "ok", "message": "Password recovery request recorded.", "expiresAt": expires_at.isoformat(), } if os.getenv("VELOCITY_AUTH_RETURN_RECOVERY_TOKEN", "").lower() in {"1", "true", "yes"}: response["recoveryToken"] = raw_token return response @router.get("/api/auth/sso/providers", tags=["Auth"]) async def list_sso_providers(user: UserPrincipal = Depends(get_current_user)): raw = os.getenv("VELOCITY_SSO_PROVIDERS", "") providers = [ _sso_provider_descriptor(provider, user.tenant_id) for provider in raw.split(",") if provider.strip() ] return { "status": "ok", "data": { "enabled": bool(providers), "providers": providers, "tenantId": user.tenant_id, }, } @router.get("/api/auth/sso/{provider_id}/start", tags=["Auth"]) async def start_sso(provider_id: str, user: UserPrincipal = Depends(get_current_user)): provider = _sso_provider_descriptor(provider_id, user.tenant_id) if not provider["enabled"]: raise HTTPException(status_code=404, detail="SSO provider is not configured.") state = secrets.token_urlsafe(24) auth_url = str(provider["authorizationUrl"]) separator = "&" if "?" in auth_url else "?" redirect_url = ( f"{auth_url}{separator}" f"client_id={provider['clientId']}&" f"response_type=code&" f"scope=openid%20email%20profile&" f"state={state}" ) return {"status": "ok", "data": {"provider": provider, "redirectUrl": redirect_url, "state": state}} @router.get("/api/auth/mdm/config", tags=["Auth"]) async def get_mdm_config(user: UserPrincipal = Depends(get_current_user)): required = os.getenv("VELOCITY_MDM_REQUIRED", "").lower() in {"1", "true", "yes"} payload = { "VelocityBackendURL": os.getenv("VELOCITY_PUBLIC_BACKEND_URL", ""), "VelocityDreamWeaverURL": os.getenv("VELOCITY_DREAM_WEAVER_URL", ""), "VelocityTenantID": user.tenant_id, "VelocitySSOProvider": os.getenv("VELOCITY_DEFAULT_SSO_PROVIDER", ""), } return { "status": "ok", "data": { "tenantId": user.tenant_id, "managedConfigurationRequired": required, "configurationKeys": list(payload.keys()), "payload": payload, }, } @router.post("/api/auth/session-switch", tags=["Auth"]) async def request_session_switch( body: SessionSwitchRequest, request: Request, user: UserPrincipal = Depends(get_current_user), ): if user.role.upper() not in {"ADMIN", "SUPERADMIN"}: raise HTTPException(status_code=403, detail="Admin access required for session switching.") users = await list_tenant_users(app=request.app, user=user) target = next((item for item in users if str(item.get("user_id") or item.get("id")) == body.userId), None) if not target: raise HTTPException(status_code=404, detail="Target user not found in tenant.") if _role_level(str(target.get("role") or "")) > _role_level(user.role): raise HTTPException(status_code=403, detail="Cannot switch into a higher-privilege account.") switched_token = create_access_token( user_id=target["user_id"], role=target["role"], tenant_id=target["tenant_id"], ) return { "status": "ok", "data": { "switchAllowed": True, "targetUser": target, "requiresReauthentication": False, "accessToken": switched_token, "tokenType": "bearer", "expiresIn": 28800, }, } @router.get("/api/auth/me", tags=["Auth"]) async def me(request: Request, user: UserPrincipal = Depends(get_current_user)): return await read_authenticated_user_profile(app=request.app, user=user) @router.get("/api/auth/users", tags=["Auth"]) async def list_auth_users(request: Request, user: UserPrincipal = Depends(get_current_user)): return {"status": "ok", "data": await list_tenant_users(app=request.app, user=user)} @router.post("/api/auth/profile/avatar", tags=["Auth"]) async def upload_profile_avatar( request: Request, file: UploadFile = File(...), user: UserPrincipal = Depends(get_current_user), ): await ensure_user_directory_schema(request.app) pool = getattr(request.app.state, "db_pool", None) if pool is None: raise HTTPException(status_code=503, detail="Database unavailable.") allowed = {"image/png", "image/jpeg", "image/jpg", "image/webp"} if file.content_type not in allowed: raise HTTPException(status_code=400, detail="Unsupported avatar format.") extension = Path(file.filename or "avatar.png").suffix.lower() or ".png" if extension not in {".png", ".jpg", ".jpeg", ".webp"}: extension = ".png" avatar_dir = Path(ASSET_DIR) / "profile_avatars" avatar_dir.mkdir(parents=True, exist_ok=True) filename = ( f"{user.user_id}_{_sanitize_filename(Path(file.filename or 'avatar').stem)}_" f"{int(datetime.now(timezone.utc).timestamp())}{extension}" ) destination = avatar_dir / filename contents = await file.read() destination.write_bytes(contents) avatar_url = f"/assets/profile_avatars/{filename}" async with pool.acquire() as conn: result = await conn.execute( """ UPDATE users_and_roles SET avatar_url = $2 WHERE id = $1::uuid AND tenant_id = $3 """, user.user_id, avatar_url, user.tenant_id, ) if result == "UPDATE 0": raise HTTPException(status_code=404, detail="Authenticated user profile was not found.") return {"avatar_url": avatar_url}