Files
Project_Velocity/backend/auth/routes.py
sayan eeb684b46c feat: Ipad app production readiness, Colony orchestration, Social posting (#44)
#38 Ipad app production readiness, Colony orchestration, Social posting

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: sagnik/Project_Velocity#44
2026-05-03 18:30:38 +05:30

271 lines
9.5 KiB
Python

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}