"""Authentication and authorisation. Collectors (machines) authenticate with API keys, checked here. Dashboard *users* sign in with Microsoft 365 in nginx (via oauth2-proxy), which then tells the app who they are in X-Forwarded-* headers it sets itself (a browser can't supply them). The app only decides whether that user is an admin. """ import hashlib import secrets import sqlite3 from fastapi import Depends, Header, HTTPException, Request from .db import get_db from .timeutil import to_db, utcnow KEY_PREFIX = "bdk_" def generate_key() -> str: return KEY_PREFIX + secrets.token_urlsafe(32) def hash_key(key: str) -> str: # Keys are 256-bit random, so a fast unsalted hash is fine (no dictionary to attack). return hashlib.sha256(key.encode()).hexdigest() def current_user(request: Request) -> str | None: """The signed-in user's sign-in name, lowercase; None without Microsoft sign-in (e.g. dev).""" user = request.headers.get("x-forwarded-email") or request.headers.get("x-forwarded-preferred-username") return user.strip().lower() if user and user.strip() else None def is_admin(request: Request) -> bool: user = current_user(request) return user is not None and user in request.app.state.settings.admins def require_admin(request: Request) -> str: if not is_admin(request): raise HTTPException(403, "Admins only. Admins are listed in BACKUP_DASH_ADMINS on the server.") return current_user(request) # Changes made from the admin page must carry this header. A page on another site can't add a # custom header to a request to the dashboard (the browser would first ask the API, which allows # no other origins), so a signed-in admin's browser can't be used to forge them. PAGE_HEADER = "X-Requested-By" PAGE_HEADER_VALUE = "backup-dashboard" def require_page_request(x_requested_by: str | None = Header(None)) -> None: if x_requested_by != PAGE_HEADER_VALUE: raise HTTPException(403, f"Missing {PAGE_HEADER} header") def require_ingest_key( authorization: str | None = Header(None), conn: sqlite3.Connection = Depends(get_db), ) -> str: scheme, _, token = (authorization or "").partition(" ") token = token.strip() if scheme.lower() != "bearer" or not token: raise HTTPException(401, "Missing bearer token", headers={"WWW-Authenticate": "Bearer"}) row = conn.execute( "SELECT id, name FROM api_keys WHERE key_hash = ? AND revoked_at IS NULL", (hash_key(token),) ).fetchone() if row is None: raise HTTPException(401, "Invalid or revoked API key", headers={"WWW-Authenticate": "Bearer"}) conn.execute("UPDATE api_keys SET last_used_at = ? WHERE id = ?", (to_db(utcnow()), row["id"])) return row["name"]