Files
rar-autopass/app/auth.py
Claude Agent 4a2afc68bf chore: curatare agresiva comentarii — scoatere referinte US/PRD din cod si template-uri
Eliminat zgomotul de trasabilitate (US-xxx, PRD x.x, Rn, OV-x, Tn, decizii/naratiune
istorica) din 41 fisiere app/ + template-uri. Pastrate comentariile care documenteaza
invarianti si logica ne-evidenta (idempotenta/hash, reconciliere anti-duplicat, RAR 500
esec definitiv, creds per cont, WAF User-Agent, 422 fara echo de parola, scope NULL->1),
curatate doar de tokeni.

Verificare: pentru cele 27 module .py curatate, structura de cod (tokeni non-comentariu/
non-string) e IDENTICA fata de HEAD -> doar comentarii/docstring-uri schimbate. Singura
schimbare de cod e in tests/test_web_responsive.py (scos 3 assert pe markeri US-006/007/008,
inlocuite de asertiunile structurale alaturate). 0 tokeni US/PRD reziduali in app/.
Regresie: 896 passed, 1 deselected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:44:24 +00:00

165 lines
6.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, Request
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 _log_auth_esuat(request: Request | None, plaintext: str | None, motiv: str) -> None:
"""Eveniment de jurnal pentru un esec de auth: IP + prefix cheie, NU cheia.
Best-effort (log_event inghite erorile). Import local: evita cuplarea la import-time
(observ -> db; auth -> db) si pastreaza auth.py importabil din CLI fara efecte.
"""
from .observ import log_event
ip = None
if request is not None and request.client is not None:
ip = request.client.host
prefix = (plaintext[:8] + "") if plaintext else None
log_event(
"api_auth_esuat",
nivel="WARNING",
cod="RAR_CREDS_INVALIDE" if plaintext else None,
mesaj=motiv,
context={"ip": ip, "key_prefix": prefix},
)
def resolve_account_id(
request: Request,
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
Esecurile de auth (401) emit `api_auth_esuat` cu IP + prefix cheie.
"""
settings = get_settings()
plaintext = _extract_key(x_api_key, authorization)
if plaintext is None:
if settings.require_api_key:
_log_auth_esuat(request, None, "cheie API lipsa (prod)")
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:
_log_auth_esuat(request, plaintext, "cheie API invalida sau revocata")
raise HTTPException(status_code=401, detail="cheie API invalida sau revocata")
return account_id