Files
microdao-daarion/services/sofiia-console/app/auth.py
Apple e0bea910b9 feat(sofiia-console): add multi-user team key auth + fix aurora DNS env
- auth.py: adds SOFIIA_CONSOLE_TEAM_KEYS="name:key,..." support;
  require_auth now returns identity ("operator"/"user:<name>") for audit;
  validate_any_key checks primary + team keys; login sets per-user cookie
- main.py: auth/login+check endpoints return identity field;
  imports validate_any_key, _expected_team_cookie_tokens from auth
- docker-compose.node1.yml: adds SOFIIA_CONSOLE_TEAM_KEYS env var;
  adds AURORA_SERVICE_URL=http://127.0.0.1:9401 to prevent DNS lookup
  failure for aurora-service (not deployed on NODA1)

Made-with: Cursor
2026-03-03 06:38:26 -08:00

193 lines
6.1 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)
Single operator key: SOFIIA_CONSOLE_API_KEY
Team keys (multi-user): SOFIIA_CONSOLE_TEAM_KEYS = "alice:key1,bob:key2,sergiy:key3"
- Each user gets a personal key; login stores a per-user cookie token.
- require_auth returns "user:<name>" for audit identification.
Dev mode (ENV != prod, no key configured): open access.
"""
import hashlib
import logging
import os
import secrets
from typing import Dict, Optional, Tuple
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 _get_team_keys() -> Dict[str, str]:
"""
Parse SOFIIA_CONSOLE_TEAM_KEYS = "alice:key1,bob:key2"
Returns {name: key} mapping. Entries with empty name or key are skipped.
"""
raw = os.getenv("SOFIIA_CONSOLE_TEAM_KEYS", "").strip()
if not raw:
return {}
result: Dict[str, str] = {}
for entry in raw.split(","):
entry = entry.strip()
if ":" not in entry:
continue
name, _, key = entry.partition(":")
name = name.strip()
key = key.strip()
if name and key:
result[name] = key
return result
def _key_valid(provided: str) -> bool:
"""Check against the primary key. Returns True if no key configured (open)."""
configured = get_console_api_key()
if not configured:
return True
return secrets.compare_digest(provided.strip(), configured)
def _team_key_identity(provided: str) -> Optional[str]:
"""
Check provided key against team keys.
Returns user name if match found, None otherwise.
"""
for name, key in _get_team_keys().items():
if secrets.compare_digest(provided.strip(), key):
return name
return None
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 _expected_team_cookie_tokens() -> Dict[str, str]:
"""Returns {cookie_token: user_name} for all team keys."""
return {_cookie_token(key): name for name, key in _get_team_keys().items()}
def validate_any_key(provided: str) -> Tuple[bool, str]:
"""
Check provided key against primary key AND team keys.
Returns (is_valid, identity_string).
identity_string: "operator" for primary key, "user:<name>" for team key.
"""
# Primary key
configured = get_console_api_key()
if configured and secrets.compare_digest(provided.strip(), configured):
return True, "operator"
if not configured:
return True, "anonymous"
# Team keys
name = _team_key_identity(provided)
if name:
return True, f"user:{name}"
return False, ""
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.
Returns identity string for audit (e.g. "operator", "user:alice", "localhost").
"""
# 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()
team_keys = _get_team_keys()
if not configured and not team_keys:
if _IS_PROD:
logger.warning("SOFIIA_CONSOLE_API_KEY not set in prod — console is OPEN")
return ""
# 1) Cookie check — primary key cookie
cookie_val = request.cookies.get(_COOKIE_NAME, "")
if cookie_val:
if configured and secrets.compare_digest(cookie_val, _expected_cookie_token()):
return "operator"
# Team key cookies
team_tokens = _expected_team_cookie_tokens()
if cookie_val in team_tokens:
return f"user:{team_tokens[cookie_val]}"
# 2) X-API-Key header
if x_api_key:
valid, identity = validate_any_key(x_api_key)
if valid:
return identity
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