Files
rar-autopass/app/auth.py
Claude Agent c17c1aa4f4 feat(securitate-CORE): redactare creds + auth API-key per cont
Redactare:
- handler RequestValidationError dropeaza input/ctx din 422 (vectorul de
  scurgere a rar_credentials.password pe /v1/prezentari); pastreaza type/loc/msg
- app/security.py: scrub/scrub_text + CredentialRedactingFilter pe root+uvicorn
- models.py: password cu repr=False

Auth API-key:
- app/auth.py: hash SHA-256 in api_keys (cheia in clar emisa o singura data),
  header X-API-Key / Authorization: Bearer, dependency resolve_account_id
- enforcement pe flag AUTOPASS_require_api_key (prod on->401, dev off->cont
  default id=1; cheie prezenta invalida->401 mereu)
- account_id real curge din cheie in ingestie + mapare
- tools/apikey.py: CLI create/rotate/revoke/list (fara endpoint HTTP admin)

16 teste noi (tests/test_security.py). 85 pass total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:02:07 +00:00

141 lines
5.0 KiB
Python

"""Autentificare API-key per cont (CORE securitate).
Cheile API sunt separate de credentialele RAR: identifica CONTUL ROAAUTO care
foloseste gateway-ul, nu utilizatorul RAR. Stocam doar SHA-256 al cheii (tabela
`api_keys`), niciodata cheia in clar — emisa o singura data la creare.
Enforcement controlat de `AUTOPASS_require_api_key`:
- True (prod): orice /v1/* protejat cere cheie valida -> 401 fara/cu cheie gresita.
- False (dev/test): fara cheie -> cont implicit id=1 (back-compat); cheie prezenta
dar invalida -> tot 401 (o cheie gresita nu trebuie sa treaca tacit).
Lifecycle (emitere/rotire/revocare) se face din CLI `python -m tools.apikey`
(adminul ruleaza pe masina) — nicio suprafata HTTP de admin de securizat.
"""
from __future__ import annotations
import hashlib
import secrets
import sqlite3
from fastapi import Header, HTTPException
from .config import get_settings
from .db import get_connection
from .mapping import DEFAULT_ACCOUNT_ID
KEY_PREFIX = "rfak_" # romfast autopass key
def generate_key() -> str:
"""Cheie noua in clar (emisa o singura data). `rfak_` + 43 caractere urlsafe."""
return KEY_PREFIX + secrets.token_urlsafe(32)
def hash_key(plaintext: str) -> str:
"""SHA-256 hex al cheii. Comparam mereu pe hash, niciodata pe clar."""
return hashlib.sha256(plaintext.strip().encode("utf-8")).hexdigest()
def create_api_key(conn: sqlite3.Connection, account_id: int) -> str:
"""Emite o cheie pentru cont, stocheaza hash-ul, intoarce cheia in clar.
Verifica intai ca exista contul (FK ON DELETE CASCADE nu raporteaza un cont
inexistent la INSERT decat ca eroare obscura). Intoarce cheia in clar — NU se
mai poate recupera ulterior.
"""
acct = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
if not acct:
raise ValueError(f"cont inexistent: {account_id}")
plaintext = generate_key()
conn.execute(
"INSERT INTO api_keys (account_id, key_hash, active) VALUES (?, ?, 1)",
(account_id, hash_key(plaintext)),
)
return plaintext
def revoke_api_key(conn: sqlite3.Connection, key_id: int) -> bool:
"""Revoca o cheie (active=0 + revoked_at). Intoarce True daca a fost activa."""
cur = conn.execute(
"UPDATE api_keys SET active=0, revoked_at=datetime('now') WHERE id=? AND active=1",
(key_id,),
)
return cur.rowcount > 0
def rotate_api_key(conn: sqlite3.Connection, account_id: int) -> str:
"""Revoca toate cheile active ale contului si emite una noua. Intoarce cheia noua."""
conn.execute(
"UPDATE api_keys SET active=0, revoked_at=datetime('now') WHERE account_id=? AND active=1",
(account_id,),
)
return create_api_key(conn, account_id)
def list_keys(conn: sqlite3.Connection, account_id: int | None = None) -> list[dict]:
"""Metadate chei (FARA hash). Pentru CLI list."""
if account_id is not None:
rows = conn.execute(
"SELECT id, account_id, active, created_at, revoked_at FROM api_keys "
"WHERE account_id=? ORDER BY id",
(account_id,),
).fetchall()
else:
rows = conn.execute(
"SELECT id, account_id, active, created_at, revoked_at FROM api_keys ORDER BY id"
).fetchall()
return [dict(r) for r in rows]
def account_for_key(conn: sqlite3.Connection, plaintext: str) -> int | None:
"""account_id pentru o cheie activa, sau None daca lipseste/revocata."""
if not plaintext:
return None
row = conn.execute(
"SELECT account_id FROM api_keys WHERE key_hash=? AND active=1",
(hash_key(plaintext),),
).fetchone()
return int(row["account_id"]) if row else None
def _extract_key(x_api_key: str | None, authorization: str | None) -> str | None:
"""Cheia din `X-API-Key` sau `Authorization: Bearer <key>` (prima are prioritate)."""
if x_api_key and x_api_key.strip():
return x_api_key.strip()
if authorization and authorization.strip():
parts = authorization.strip().split(None, 1)
if len(parts) == 2 and parts[0].lower() == "bearer":
return parts[1].strip()
return None
def resolve_account_id(
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
authorization: str | None = Header(default=None),
) -> int:
"""Dependency FastAPI: contul cererii din cheia API.
- cheie valida -> account_id al cheii
- cheie invalida (prezenta) -> 401 (mereu, indiferent de flag)
- fara cheie + flag off -> cont implicit (id=1), back-compat
- fara cheie + flag on -> 401
"""
settings = get_settings()
plaintext = _extract_key(x_api_key, authorization)
if plaintext is None:
if settings.require_api_key:
raise HTTPException(status_code=401, detail="cheie API lipsa")
return DEFAULT_ACCOUNT_ID
conn = get_connection()
try:
account_id = account_for_key(conn, plaintext)
finally:
conn.close()
if account_id is None:
raise HTTPException(status_code=401, detail="cheie API invalida sau revocata")
return account_id