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
This commit is contained in:
@@ -9,12 +9,18 @@ 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
|
||||
@@ -32,13 +38,46 @@ 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 # no key set → open
|
||||
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()
|
||||
@@ -48,6 +87,30 @@ 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),
|
||||
@@ -56,6 +119,7 @@ def require_auth(
|
||||
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 ""
|
||||
@@ -63,19 +127,27 @@ def require_auth(
|
||||
return "localhost"
|
||||
|
||||
configured = get_console_api_key()
|
||||
if not configured:
|
||||
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
|
||||
# 1) Cookie check — primary key cookie
|
||||
cookie_val = request.cookies.get(_COOKIE_NAME, "")
|
||||
if cookie_val and secrets.compare_digest(cookie_val, _expected_cookie_token()):
|
||||
return "cookie"
|
||||
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 (for API clients / curl)
|
||||
if x_api_key and _key_valid(x_api_key):
|
||||
return "header"
|
||||
# 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")
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ from .auth import (
|
||||
require_api_key, require_api_key_strict, require_auth, require_auth_strict, require_audit_auth,
|
||||
get_console_api_key, _key_valid, _cookie_token, _expected_cookie_token,
|
||||
_COOKIE_NAME, _COOKIE_MAX_AGE, _IS_PROD,
|
||||
validate_any_key, _expected_team_cookie_tokens,
|
||||
)
|
||||
|
||||
from .config import (
|
||||
@@ -5114,11 +5115,11 @@ class _LoginBody(BaseModel):
|
||||
@app.post("/api/auth/login")
|
||||
async def auth_login(body: _LoginBody, response: Response):
|
||||
"""
|
||||
Verify API key (sent in JSON body — avoids header encoding issues).
|
||||
On success: set httpOnly session cookie, return ok=true.
|
||||
No CORS/header encoding issues since key travels in request body.
|
||||
Verify API key (primary or team key, sent in JSON body).
|
||||
On success: set httpOnly session cookie, return ok=true + identity.
|
||||
"""
|
||||
if not _key_valid(body.key):
|
||||
valid, identity = validate_any_key(body.key)
|
||||
if not valid:
|
||||
raise HTTPException(status_code=401, detail="Invalid key")
|
||||
|
||||
token = _cookie_token(body.key)
|
||||
@@ -5131,7 +5132,7 @@ async def auth_login(body: _LoginBody, response: Response):
|
||||
max_age=_COOKIE_MAX_AGE,
|
||||
path="/",
|
||||
)
|
||||
return {"ok": True, "auth": "cookie"}
|
||||
return {"ok": True, "auth": "cookie", "identity": identity}
|
||||
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
@@ -5143,19 +5144,23 @@ async def auth_logout(response: Response):
|
||||
|
||||
@app.get("/api/auth/check")
|
||||
async def auth_check(request: Request):
|
||||
"""Returns 200 if session is valid, 401 otherwise. Used by UI on startup."""
|
||||
# Localhost is always open — no auth needed
|
||||
"""Returns 200 + identity if session is valid, 401 otherwise. Used by UI on startup."""
|
||||
import secrets as _sec
|
||||
client_ip = (request.client.host if request.client else "") or ""
|
||||
if client_ip in ("127.0.0.1", "::1", "localhost"):
|
||||
return {"ok": True, "auth": "localhost"}
|
||||
return {"ok": True, "auth": "localhost", "identity": "localhost"}
|
||||
configured = get_console_api_key()
|
||||
if not configured:
|
||||
return {"ok": True, "auth": "open"}
|
||||
from .auth import _expected_cookie_token as _ect
|
||||
from .auth import _get_team_keys
|
||||
team_keys = _get_team_keys()
|
||||
if not configured and not team_keys:
|
||||
return {"ok": True, "auth": "open", "identity": "anonymous"}
|
||||
cookie_val = request.cookies.get(_COOKIE_NAME, "")
|
||||
import secrets as _sec
|
||||
if cookie_val and _sec.compare_digest(cookie_val, _ect()):
|
||||
return {"ok": True, "auth": "cookie"}
|
||||
if cookie_val:
|
||||
if configured and _sec.compare_digest(cookie_val, _expected_cookie_token()):
|
||||
return {"ok": True, "auth": "cookie", "identity": "operator"}
|
||||
team_tokens = _expected_team_cookie_tokens()
|
||||
if cookie_val in team_tokens:
|
||||
return {"ok": True, "auth": "cookie", "identity": f"user:{team_tokens[cookie_val]}"}
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user