diff --git a/.env.example b/.env.example index b0448ff2..825bf6cf 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,10 @@ SECUSCAN_ALLOW_LOOPBACK_SCANS=true # The server refuses to start the vault if this is unset. SECUSCAN_VAULT_KEY=replace-with-output-of-secrets.token_hex-32 +# API key for authenticating all API requests (auto-generated on first startup if not set) +# Generate with: python -c "import secrets; print(secrets.token_hex(32))" +SECUSCAN_API_KEY= + # Plugin Security # SECUSCAN_PLUGIN_SIGNATURE_KEY=replace-with-your-signing-key # SECUSCAN_ENFORCE_PLUGIN_SIGNATURES=false diff --git a/backend/secuscan/auth.py b/backend/secuscan/auth.py new file mode 100644 index 00000000..78acc2d5 --- /dev/null +++ b/backend/secuscan/auth.py @@ -0,0 +1,60 @@ +""" +API key authentication dependency for SecuScan. + +On first startup, if SECUSCAN_API_KEY is not set, a random key is generated, +written to data/api_key.txt, and logged once so the user can copy it. +Every non-health-check request must supply the key as: + - Authorization: Bearer OR + - X-Api-Key: +""" +from __future__ import annotations + +import logging +import secrets +from functools import lru_cache +from pathlib import Path + +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +logger = logging.getLogger(__name__) + +_bearer = HTTPBearer(auto_error=False) + +_API_KEY_FILE = Path(__file__).resolve().parent.parent / "data" / "api_key.txt" + + +@lru_cache(maxsize=1) +def _get_active_key() -> str: + from .config import settings + if settings.api_key: + return settings.api_key + if _API_KEY_FILE.exists(): + key = _API_KEY_FILE.read_text().strip() + if key: + return key + key = secrets.token_hex(32) + _API_KEY_FILE.parent.mkdir(parents=True, exist_ok=True) + _API_KEY_FILE.write_text(key) + logger.warning( + "No SECUSCAN_API_KEY set. Generated key saved to %s -- API KEY: %s", + _API_KEY_FILE, key, + ) + return key + + +async def require_api_key( + request: Request, + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> None: + active_key = _get_active_key() + if credentials and secrets.compare_digest(credentials.credentials, active_key): + return + x_api_key = request.headers.get("X-Api-Key", "") + if x_api_key and secrets.compare_digest(x_api_key, active_key): + return + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing API key.", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/backend/secuscan/config.py b/backend/secuscan/config.py index 3b67d255..30c954cf 100644 --- a/backend/secuscan/config.py +++ b/backend/secuscan/config.py @@ -58,6 +58,7 @@ class Settings(BaseSettings): plugin_signature_key: Optional[str] = None enforce_plugin_signatures: bool = False vault_key: Optional[str] = None + api_key: Optional[str] = None # Rate Limiting max_concurrent_tasks: int = 3 diff --git a/backend/secuscan/main.py b/backend/secuscan/main.py index ebf57f60..a9a5f533 100644 --- a/backend/secuscan/main.py +++ b/backend/secuscan/main.py @@ -9,7 +9,7 @@ from contextlib import asynccontextmanager from .request_middleware import RequestIDMiddleware -from fastapi import FastAPI +from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles @@ -18,6 +18,7 @@ from .database import init_db, db as global_db from .plugins import init_plugins from .routes import router +from .auth import require_api_key from .workflows import scheduler @@ -124,7 +125,7 @@ async def redirect_api_openapi(): app.add_middleware(RequestIDMiddleware) # Include API routes -app.include_router(router) +app.include_router(router, dependencies=[Depends(require_api_key)]) # Health check endpoint @app.get("/api/v1/health") diff --git a/testing/backend/conftest.py b/testing/backend/conftest.py index 22e576b5..941cbb19 100644 --- a/testing/backend/conftest.py +++ b/testing/backend/conftest.py @@ -34,6 +34,9 @@ def setup_test_environment(monkeypatch): monkeypatch.setattr(settings, "plugins_dir", str(repo_root / "plugins")) monkeypatch.setattr(settings, "database_path", f"{temp_path}/test_secuscan.db") monkeypatch.setattr(settings, "vault_key", "test-vault-key-for-unit-tests-only") + monkeypatch.setattr(settings, "api_key", "test-api-key-for-unit-tests-only") + from backend.secuscan.auth import _get_active_key + _get_active_key.cache_clear() settings.ensure_directories()