diff --git a/.env.example b/.env.example index 2cbc66d..f979c1d 100644 --- a/.env.example +++ b/.env.example @@ -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= +AUTH_TOKEN_TTL_SECONDS=28800 diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..29c7956 --- /dev/null +++ b/backend/app/auth.py @@ -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"), + } diff --git a/backend/app/config.py b/backend/app/config.py index 2506e6d..45dab3b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 09335b5..6aaa703 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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( @@ -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"]) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..ee68343 --- /dev/null +++ b/backend/app/routers/auth.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 647d5a5..f8f99dd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -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' @@ -6,12 +6,30 @@ 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 ( +
+ +
+ ) + } + return ( - }> + : } /> + : + } + > } /> } /> } /> @@ -19,6 +37,7 @@ export function App() { } /> } /> + } /> ) diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..c26715a --- /dev/null +++ b/frontend/src/api/auth.ts @@ -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 { + const { data } = await api.post('/auth/login', { username, password }) + return data +} + +export async function fetchMe(): Promise { + const { data } = await api.get<{ user: AuthUser }>('/auth/me') + return data.user +} + +export async function logout(): Promise { + try { + await api.post('/auth/logout') + } catch { + // Stateless logout — ignore network failures, the client clears the token. + } +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 55fc84c..99b5021 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,6 +1,46 @@ import axios from 'axios' +const TOKEN_KEY = 'cloudops.auth.token' +const USER_KEY = 'cloudops.auth.user' + +export const authStorage = { + getToken: (): string | null => localStorage.getItem(TOKEN_KEY), + setToken: (token: string) => localStorage.setItem(TOKEN_KEY, token), + clearToken: () => localStorage.removeItem(TOKEN_KEY), + getUser: (): unknown | null => { + const raw = localStorage.getItem(USER_KEY) + return raw ? JSON.parse(raw) : null + }, + setUser: (user: unknown) => localStorage.setItem(USER_KEY, JSON.stringify(user)), + clearUser: () => localStorage.removeItem(USER_KEY), +} + export const api = axios.create({ baseURL: '/api', timeout: 300000, }) + +// Attach the bearer token to every request when present. +api.interceptors.request.use((config) => { + const token = authStorage.getToken() + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +// Centralized 401 handling: clear the cached session and bounce to /login so +// the rest of the app does not have to guard every request. +api.interceptors.response.use( + (response) => response, + (error) => { + if (error?.response?.status === 401) { + authStorage.clearToken() + authStorage.clearUser() + if (window.location.pathname !== '/login') { + window.location.assign('/login') + } + } + return Promise.reject(error) + }, +) diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx new file mode 100644 index 0000000..c56b9b7 --- /dev/null +++ b/frontend/src/auth/AuthContext.tsx @@ -0,0 +1,64 @@ +import { createContext, useCallback, useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import { authStorage } from '../api/client' +import { fetchMe, login as apiLogin, logout as apiLogout } from '../api/auth' +import type { AuthUser } from '../api/auth' + +interface AuthState { + user: AuthUser | null + initialized: boolean + isAuthenticated: boolean + login: (username: string, password: string) => Promise + logout: () => Promise +} + +export const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null) + const [initialized, setInitialized] = useState(false) + + // On boot, if a token is cached, validate it via /auth/me so a stale token + // is dropped instead of producing a confusing 401 storm on the dashboard. + useEffect(() => { + const token = authStorage.getToken() + if (!token) { + setInitialized(true) + return + } + fetchMe() + .then((u) => setUser(u)) + .catch(() => { + authStorage.clearToken() + authStorage.clearUser() + }) + .finally(() => setInitialized(true)) + }, []) + + const login = useCallback(async (username: string, password: string) => { + const { token, user: u } = await apiLogin(username, password) + authStorage.setToken(token) + authStorage.setUser(u) + setUser(u) + }, []) + + const logout = useCallback(async () => { + await apiLogout() + authStorage.clearToken() + authStorage.clearUser() + setUser(null) + }, []) + + const value = useMemo( + () => ({ + user, + initialized, + isAuthenticated: user !== null, + login, + logout, + }), + [user, initialized, login, logout], + ) + + return {children} +} diff --git a/frontend/src/auth/useAuth.ts b/frontend/src/auth/useAuth.ts new file mode 100644 index 0000000..e00f580 --- /dev/null +++ b/frontend/src/auth/useAuth.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react' +import { AuthContext } from './AuthContext' + +export function useAuth() { + const ctx = useContext(AuthContext) + if (ctx === null) { + throw new Error('useAuth must be used within an AuthProvider') + } + return ctx +} diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index ac35d99..ed53424 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -1,4 +1,7 @@ -import { useLocation } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' +import { LogOut, ChevronDown } from 'lucide-react' +import { useAuth } from '../../auth/useAuth' +import { useState } from 'react' const titles: Record = { '/': 'Dashboard Overview', @@ -11,11 +14,59 @@ const titles: Record = { export function Header() { const location = useLocation() + const navigate = useNavigate() + const { user, logout } = useAuth() + const [menuOpen, setMenuOpen] = useState(false) const title = titles[location.pathname] || 'Cloud Ops Dashboard' + async function handleLogout() { + setMenuOpen(false) + await logout() + navigate('/login', { replace: true }) + } + return ( -
+

{title}

+ +
+ + + {menuOpen && ( + <> +
setMenuOpen(false)} /> +
+
+

+ {user?.display_name || user?.username || 'User'} +

+

@{user?.username}

+

+ {user?.role || 'viewer'} +

+
+ +
+ + )} +
) } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 12ac296..0371874 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { App } from './App' +import { AuthProvider } from './auth/AuthContext' import './index.css' const queryClient = new QueryClient({ @@ -16,7 +17,9 @@ const queryClient = new QueryClient({ createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..c83dfd1 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,207 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Cloud, Lock, User, Eye, EyeOff, AlertCircle, Loader2, ShieldCheck } from 'lucide-react' +import { useAuth } from '../auth/useAuth' + +export function LoginPage() { + const { login } = useAuth() + const navigate = useNavigate() + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + setError(null) + if (!username.trim() || !password) { + setError('Enter your username and password.') + return + } + setSubmitting(true) + try { + await login(username.trim(), password) + navigate('/', { replace: true }) + } catch (err: unknown) { + const status = (err as { response?: { status?: number } })?.response?.status + setError(status === 401 ? 'Invalid username or password.' : 'Unable to reach the server. Try again.') + } finally { + setSubmitting(false) + } + } + + return ( +
+ {/* Brand panel — atmosphere, not a flat color. */} + + + {/* Form panel. */} +
+
+
+
+ +
+
+

Cloud Ops

+

Huawei Cloud Dashboard

+
+
+ +

Sign in

+

+ Use the credentials stored in your OBS bucket. +

+ +
+ } + value={username} + onChange={setUsername} + placeholder="admin" + autoComplete="username" + disabled={submitting} + /> + } + value={password} + onChange={setPassword} + placeholder="••••••••" + type={showPassword ? 'text' : 'password'} + autoComplete="current-password" + disabled={submitting} + trailing={ + + } + /> + + {error && ( +
+ + {error} +
+ )} + + + + +

+ Access is controlled by Auth/users.json in + the configured OBS bucket. Contact your cloud ops owner if you need an account. +

+
+
+
+ ) +} + +interface FieldProps { + id: string + label: string + icon: React.ReactNode + value: string + onChange: (value: string) => void + placeholder?: string + type?: string + autoComplete?: string + disabled?: boolean + trailing?: React.ReactNode +} + +function Field({ id, label, icon, value, onChange, placeholder, type = 'text', autoComplete, disabled, trailing }: FieldProps) { + return ( +
+ +
+ + {icon} + + onChange(e.target.value)} + placeholder={placeholder} + autoComplete={autoComplete} + disabled={disabled} + className="w-full rounded-lg border border-gray-200 bg-white py-2.5 pl-9 pr-9 text-sm text-gray-900 shadow-sm transition-colors placeholder:text-gray-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 disabled:opacity-60" + /> + {trailing && ( + {trailing} + )} +
+
+ ) +} + +const brandGradient: React.CSSProperties = { + backgroundImage: + 'radial-gradient(120% 80% at 0% 0%, #2563eb 0%, transparent 55%), radial-gradient(100% 100% at 100% 100%, #1e3a8a 0%, transparent 60%), linear-gradient(135deg, #1e3a8a 0%, #172554 100%)', +} + +const gridTexture: React.CSSProperties = { + backgroundImage: + 'linear-gradient(to right, #ffffff 1px, transparent 1px), linear-gradient(to bottom, #ffffff 1px, transparent 1px)', + backgroundSize: '32px 32px', +} diff --git a/scripts/seed_auth_users.py b/scripts/seed_auth_users.py new file mode 100644 index 0000000..482a21b --- /dev/null +++ b/scripts/seed_auth_users.py @@ -0,0 +1,90 @@ +"""One-off helper to upload the demo auth users file to OBS. + +The credentials themselves are NOT hardcoded here — read them from environment +variables so nothing secret lands in the repo. The backend authenticates +against the JSON object this script uploads to OBS. + +Run inside the backend container where OBS credentials are available: + docker exec -e ADMIN_PASSWORD=... -e VIEWER_PASSWORD=... \ + cloud-ops-backend python /app/scripts/seed_auth_users.py + +If a password env var is omitted the user is skipped, so you can seed only the +accounts you need. The file format written to OBS is: + + { + "users": [ + {"username": "admin", "password": "...", "display_name": "...", "role": "admin"}, + ... + ] + } + +The backend also accepts ``password_sha256`` (hex digest) instead of ``password`` +if you prefer to avoid plaintext at rest. Idempotent: re-running overwrites +Auth/users.json. +""" + +import json +import os +import sys + +from obs import ObsClient + +BUCKET = os.environ["OBS_BUCKET"] +PREFIX = os.environ["OBS_PREFIX"] +REGION = os.environ.get("OBS_REGION", "la-south-2") +ENDPOINT = f"obs.{REGION}.myhuaweicloud.com" + + +def build_users() -> list[dict[str, str]]: + users: list[dict[str, str]] = [] + admin_pw = os.environ.get("ADMIN_PASSWORD") + if admin_pw: + users.append( + { + "username": "admin", + "password": admin_pw, + "display_name": os.environ.get("ADMIN_DISPLAY_NAME", "Cloud Ops Admin"), + "role": "admin", + } + ) + viewer_pw = os.environ.get("VIEWER_PASSWORD") + if viewer_pw: + users.append( + { + "username": "viewer", + "password": viewer_pw, + "display_name": os.environ.get("VIEWER_DISPLAY_NAME", "Read-Only Viewer"), + "role": "viewer", + } + ) + return users + + +def main() -> int: + users = build_users() + if not users: + print( + "No users to seed. Set ADMIN_PASSWORD and/or VIEWER_PASSWORD env vars.", + file=sys.stderr, + ) + return 2 + + client = ObsClient( + access_key_id=os.environ["OBS_AK"], + secret_access_key=os.environ["OBS_SK"], + server=ENDPOINT, + ) + key = f"{PREFIX}/Auth/users.json" + body = json.dumps({"users": users}, indent=2) + resp = client.putObject(BUCKET, key, content=body) + if resp.status >= 300: + print(f"FAILED: {resp.status} {resp.reason}", file=sys.stderr) + return 1 + print(f"Uploaded {len(body)} bytes to obs://{BUCKET}/{key}") + print(f"Seeded {len(users)} user(s): {', '.join(u['username'] for u in users)}") + client.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())