Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,11 @@ APP_PORT=8000
CACHE_TTL_SECONDS=300
LOG_LEVEL=INFO
CORS_ORIGINS=http://localhost:5173,http://localhost:3000

# === Auth (OPTIONAL) ===
# Login credentials are read from an OBS object (JSON) so they live next to
# the demo data, not in source. See scripts/seed_auth_users.py to seed it.
AUTH_ENABLED=true
AUTH_USERS_PATH=Auth/users.json
AUTH_SECRET=<any_random_string_used_to_sign_session_tokens>
AUTH_TOKEN_TTL_SECONDS=28800
152 changes: 152 additions & 0 deletions backend/app/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Authentication helpers.

Credentials are stored as a JSON object in OBS (path configurable via
``auth_users_path``) so they live next to the demo data instead of in source
code or environment variables. Tokens are HMAC-SHA256 signed (stdlib only) to
avoid pulling in extra dependencies for a demo.
"""

import base64
import hashlib
import hmac
import json
import logging
import time
from typing import Any

from app.config import settings
from app.obs_client import obs_reader

logger = logging.getLogger(__name__)

# Cache the user table for a short window so a login does not hit OBS every
# time. Negative lookups (file missing) are cached too.
_USERS_CACHE_KEY = "auth:users"
_users_cache_expires: float = 0.0
_users_cache_value: list[dict[str, Any]] | None = None


def _b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")


def _b64url_decode(text: str) -> bytes:
padding = "=" * (-len(text) % 4)
return base64.urlsafe_b64decode(text + padding)


def create_token(username: str, ttl_seconds: int | None = None) -> str:
"""Issue a signed token for ``username``."""
ttl = ttl_seconds if ttl_seconds is not None else settings.auth_token_ttl_seconds
payload = {"sub": username, "exp": int(time.time()) + ttl}
payload_b64 = _b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
signature = hmac.new(
settings.auth_secret.encode("utf-8"),
payload_b64.encode("ascii"),
hashlib.sha256,
).digest()
return f"{payload_b64}.{_b64url(signature)}"


def verify_token(token: str) -> str | None:
"""Return the username if the token is valid and unexpired, else None."""
if not token or "." not in token:
return None
payload_b64, signature_b64 = token.split(".", 1)
expected = hmac.new(
settings.auth_secret.encode("utf-8"),
payload_b64.encode("ascii"),
hashlib.sha256,
).digest()
try:
provided = _b64url_decode(signature_b64)
except (ValueError, base64.binascii.Error):
return None
if not hmac.compare_digest(expected, provided):
return None
try:
payload = json.loads(_b64url_decode(payload_b64).decode("utf-8"))
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
return None
if not isinstance(payload, dict):
return None
exp = payload.get("exp")
if not isinstance(exp, (int, float)) or time.time() > exp:
return None
sub = payload.get("sub")
return sub if isinstance(sub, str) and sub else None


def _normalize_users(raw: Any) -> list[dict[str, Any]]:
"""Accept either {"users": [...]} or a bare list [...] from the OBS file."""
if isinstance(raw, list):
users = raw
elif isinstance(raw, dict) and isinstance(raw.get("users"), list):
users = raw["users"]
else:
return []
return [u for u in users if isinstance(u, dict)]


def load_users() -> list[dict[str, Any]]:
"""Load the user table from OBS with a short in-process TTL cache."""
global _users_cache_value, _users_cache_expires
now = time.time()
if _users_cache_value is not None and now < _users_cache_expires:
return _users_cache_value

try:
raw = obs_reader.get_object_as_json(settings.auth_users_path)
except Exception as exc: # pragma: no cover - network/OBS failures
logger.error("Failed to load auth users from OBS: %s", exc)
raw = None

users = _normalize_users(raw) if raw is not None else []
# Cache both success and miss for a short window.
_users_cache_value = users
_users_cache_expires = now + min(settings.cache_ttl_seconds, 60)
if not users:
logger.warning(
"No users loaded from OBS path %s — login will be rejected until the "
"file is available.",
settings.auth_users_path,
)
return users


def authenticate(username: str, password: str) -> dict[str, Any] | None:
"""Validate credentials against the OBS user table.

Supports either a plaintext ``password`` field (demo convenience) or a
``password_sha256`` hex digest for slightly stronger storage. Returns the
user record (without the password fields) on success.
"""
if not username or not password:
return None
for user in load_users():
if user.get("username") != username:
continue
stored_plain = user.get("password")
stored_hash = user.get("password_sha256")
ok = False
if isinstance(stored_plain, str) and stored_plain:
ok = hmac.compare_digest(stored_plain, password)
elif isinstance(stored_hash, str) and stored_hash:
ok = hmac.compare_digest(stored_hash.lower(), hashlib.sha256(password.encode("utf-8")).hexdigest())
if not ok:
return None
return {
"username": user.get("username"),
"display_name": user.get("display_name") or user.get("username"),
"role": user.get("role", "viewer"),
}
return None


def public_user(user: dict[str, Any]) -> dict[str, Any]:
"""Strip any secret-adjacent fields before returning a user to the client."""
return {
"username": user.get("username"),
"display_name": user.get("display_name") or user.get("username"),
"role": user.get("role", "viewer"),
}
7 changes: 7 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ class Settings(BaseSettings):
log_level: str = "INFO"
cors_origins: str = "http://localhost:5173,http://localhost:3000"

# Auth — credentials are read from an OBS object (JSON) so they live next to
# the demo data, never in source. AUTH_ENABLED=false disables the gate.
auth_enabled: bool = True
auth_users_path: str = "Auth/users.json"
auth_secret: str = "cloud-ops-demo-auth-secret-change-me"
auth_token_ttl_seconds: int = 8 * 3600

model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

@property
Expand Down
39 changes: 36 additions & 3 deletions backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import logging

from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette import status

from app.auth import verify_token
from app.config import settings
from app.routers import cts, costs, metrics, inventory, insights, health
from app.routers import cts, costs, metrics, inventory, insights, health, auth

logging.basicConfig(level=getattr(logging, settings.log_level, logging.INFO))

logger = logging.getLogger(__name__)

app = FastAPI(
title="Cloud Ops Dashboard API",
description="Reads Huawei Cloud OBS data (CTS, Cost Center, Cloud Eye) and generates interactive reports with AI insights",
version="1.0.0",
version="1.1.0",
)

app.add_middleware(
Expand All @@ -22,7 +27,35 @@
allow_headers=["*"],
)

# Paths that remain reachable without authentication. Health lets the
# docker healthcheck and the login screen probe connectivity; login is the
# bootstrap endpoint.
PUBLIC_PATHS = {"/api/health", "/api/auth/login", "/api/auth/logout"}


@app.middleware("http")
async def enforce_auth(request: Request, call_next):
path = request.url.path
if (
settings.auth_enabled
and path.startswith("/api/")
and path not in PUBLIC_PATHS
and request.method != "OPTIONS"
):
header = request.headers.get("authorization", "")
token = header.split(" ", 1)[1] if header.lower().startswith("bearer ") else ""
username = verify_token(token) if token else None
if username is None:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={"detail": "Not authenticated"},
)
request.state.username = username
return await call_next(request)


app.include_router(health.router, prefix="/api")
app.include_router(auth.router, prefix="/api")
app.include_router(inventory.router, prefix="/api/inventory", tags=["inventory"])
app.include_router(cts.router, prefix="/api/cts", tags=["cts"])
app.include_router(costs.router, prefix="/api/costs", tags=["costs"])
Expand Down
68 changes: 68 additions & 0 deletions backend/app/routers/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import logging

from fastapi import APIRouter, Header
from pydantic import BaseModel

from app.auth import authenticate, create_token, public_user, verify_token
from app.config import settings

router = APIRouter()
logger = logging.getLogger(__name__)


class LoginRequest(BaseModel):
username: str
password: str


class LoginResponse(BaseModel):
token: str
user: dict


@router.post("/auth/login", response_model=LoginResponse)
async def login(body: LoginRequest):
user = authenticate(body.username, body.password)
if user is None:
logger.info("Failed login attempt for username=%r", body.username)
# FastAPI will turn this into a 401 with a generic message.
from fastapi import HTTPException
from starlette import status

raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
)
token = create_token(user["username"])
logger.info("Successful login for username=%r", user["username"])
return LoginResponse(token=token, user=public_user(user))


@router.get("/auth/me")
async def me(authorization: str | None = Header(default=None)):
from fastapi import HTTPException
from starlette import status

if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
username = verify_token(authorization.split(" ", 1)[1])
if username is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
# Resolve back to the full record for display info.
from app.auth import load_users

record = next((u for u in load_users() if u.get("username") == username), None)
if record is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User no longer exists")
return {"user": public_user(record)}


@router.post("/auth/logout")
async def logout():
# Tokens are stateless; the client simply discards them. Endpoint exists so
# the frontend has a clean place to call and we can wire revocation later.
return {"ok": True}


def is_auth_enabled() -> bool:
return settings.auth_enabled
23 changes: 21 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,43 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AppShell } from './components/layout/AppShell'
import { DashboardPage } from './pages/DashboardPage'
import { InventoryPage } from './pages/InventoryPage'
import { CtsPage } from './pages/CtsPage'
import { CostPage } from './pages/CostPage'
import { MetricsPage } from './pages/MetricsPage'
import { InsightsPage } from './pages/InsightsPage'
import { LoginPage } from './pages/LoginPage'
import { useAuth } from './auth/useAuth'
import { Loader2 } from 'lucide-react'

export function App() {
const { initialized, isAuthenticated } = useAuth()

if (!initialized) {
return (
<div className="flex h-screen items-center justify-center bg-gray-50 text-gray-400">
<Loader2 className="animate-spin" size={24} />
</div>
)
}

return (
<BrowserRouter>
<Routes>
<Route element={<AppShell />}>
<Route path="/login" element={isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />} />
<Route
element={
isAuthenticated ? <AppShell /> : <Navigate to="/login" replace />
}
>
<Route path="/" element={<DashboardPage />} />
<Route path="/inventory" element={<InventoryPage />} />
<Route path="/cts" element={<CtsPage />} />
<Route path="/costs" element={<CostPage />} />
<Route path="/metrics" element={<MetricsPage />} />
<Route path="/insights" element={<InsightsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
)
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/api/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { api } from './client'

export interface AuthUser {
username: string
display_name: string
role: string
}

interface LoginResponse {
token: string
user: AuthUser
}

export async function login(username: string, password: string): Promise<LoginResponse> {
const { data } = await api.post<LoginResponse>('/auth/login', { username, password })
return data
}

export async function fetchMe(): Promise<AuthUser> {
const { data } = await api.get<{ user: AuthUser }>('/auth/me')
return data.user
}

export async function logout(): Promise<void> {
try {
await api.post('/auth/logout')
} catch {
// Stateless logout — ignore network failures, the client clears the token.
}
}
Loading