121 lines
3.7 KiB
Python
121 lines
3.7 KiB
Python
"""
|
|
Auth for Sofiia Console.
|
|
|
|
Strategy: cookie-based session (httpOnly, Secure, SameSite=Lax).
|
|
Login: POST /api/auth/login { "key": "..." } → sets console_token cookie
|
|
Logout: POST /api/auth/logout → clears cookie
|
|
|
|
All protected endpoints check:
|
|
1. Cookie "console_token" (browser sessions)
|
|
2. X-API-Key header (backward compat: curl, API clients)
|
|
|
|
Dev mode (ENV != prod, no key configured): open access.
|
|
"""
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
import secrets
|
|
|
|
from fastapi import Cookie, HTTPException, Request, Security
|
|
from fastapi.security import APIKeyHeader
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_IS_PROD = os.getenv("ENV", "dev").strip().lower() in ("prod", "production", "staging")
|
|
_COOKIE_NAME = "console_token"
|
|
_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 # 7 days
|
|
|
|
API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
|
|
|
|
|
|
def get_console_api_key() -> str:
|
|
return os.getenv("SOFIIA_CONSOLE_API_KEY", "").strip()
|
|
|
|
|
|
def _key_valid(provided: str) -> bool:
|
|
configured = get_console_api_key()
|
|
if not configured:
|
|
return True # no key set → open
|
|
return secrets.compare_digest(provided.strip(), configured)
|
|
|
|
|
|
def _cookie_token(api_key: str) -> str:
|
|
"""Derive a stable session token from the api key (so we never store key directly in cookie)."""
|
|
return hashlib.sha256(api_key.encode()).hexdigest()
|
|
|
|
|
|
def _expected_cookie_token() -> str:
|
|
return _cookie_token(get_console_api_key())
|
|
|
|
|
|
def require_auth(
|
|
request: Request,
|
|
x_api_key: str = Security(API_KEY_HEADER),
|
|
) -> str:
|
|
"""
|
|
Check cookie OR X-API-Key header.
|
|
Localhost (127.0.0.1 / ::1) is ALWAYS allowed — no key needed.
|
|
In dev mode without a configured key: pass through.
|
|
"""
|
|
# Localhost bypass — always open for local development
|
|
client_ip = (request.client.host if request.client else "") or ""
|
|
if client_ip in ("127.0.0.1", "::1", "localhost"):
|
|
return "localhost"
|
|
|
|
configured = get_console_api_key()
|
|
if not configured:
|
|
if _IS_PROD:
|
|
logger.warning("SOFIIA_CONSOLE_API_KEY not set in prod — console is OPEN")
|
|
return ""
|
|
|
|
# 1) Cookie check
|
|
cookie_val = request.cookies.get(_COOKIE_NAME, "")
|
|
if cookie_val and secrets.compare_digest(cookie_val, _expected_cookie_token()):
|
|
return "cookie"
|
|
|
|
# 2) X-API-Key header (for API clients / curl)
|
|
if x_api_key and _key_valid(x_api_key):
|
|
return "header"
|
|
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
|
|
|
|
def require_auth_strict(
|
|
request: Request,
|
|
x_api_key: str = Security(API_KEY_HEADER),
|
|
) -> str:
|
|
"""Like require_auth but always fails if no key is configured."""
|
|
configured = get_console_api_key()
|
|
if not configured:
|
|
raise HTTPException(status_code=503, detail="SOFIIA_CONSOLE_API_KEY not configured")
|
|
return require_auth(request, x_api_key)
|
|
|
|
|
|
def require_audit_auth(
|
|
request: Request,
|
|
x_api_key: str = Security(API_KEY_HEADER),
|
|
) -> str:
|
|
"""
|
|
Strict auth for sensitive audit endpoint:
|
|
- Requires configured SOFIIA_CONSOLE_API_KEY
|
|
- Does NOT allow localhost bypass
|
|
- Accepts cookie or X-API-Key when key is configured
|
|
"""
|
|
configured = get_console_api_key()
|
|
if not configured:
|
|
raise HTTPException(status_code=401, detail="Audit endpoint requires SOFIIA_CONSOLE_API_KEY")
|
|
|
|
cookie_val = request.cookies.get(_COOKIE_NAME, "")
|
|
if cookie_val and secrets.compare_digest(cookie_val, _expected_cookie_token()):
|
|
return "cookie"
|
|
|
|
if x_api_key and _key_valid(x_api_key):
|
|
return "header"
|
|
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
|
|
|
|
# Keep old names as aliases so existing imports don't break
|
|
require_api_key = require_auth
|
|
require_api_key_strict = require_auth_strict
|