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>
This commit is contained in:
140
app/auth.py
Normal file
140
app/auth.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user