forked from sagnik/Project_Velocity
#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
271 lines
9.5 KiB
Python
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}
|