diff --git a/app/config.py b/app/config.py index 69c4361..07a432e 100644 --- a/app/config.py +++ b/app/config.py @@ -34,6 +34,25 @@ class Settings(BaseSettings): # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" creds_key: str | None = None + # --- Sesiuni web (US-002, PRD 3.3) --- + # Secret semnat cookie sesiune. None -> efemer la fiecare restart (dev ok; + # in prod seteaza persistent ca si creds_key, altfel cookieurile se invalideaza + # la restart). Genereaza: python -c "import secrets; print(secrets.token_hex(32))" + session_secret: str | None = None + # True (prod): rutele web fara sesiune -> redirect /login. False (dev): fara + # sesiune -> cont implicit id=1, back-compat (C12/§5 Q5). + web_auth_required: bool = False + # True (prod, in spatele Cloudflare Tunnel TLS): cookie cu Secure flag (C4). + # False (dev): cookie fara Secure, functioneaza pe HTTP. + session_https_only: bool = False + + # --- Rate-limit signup + login (US-009, PRD 3.3 C5) --- + # Max cereri POST /signup per IP in fereastra de timp (in-proces, fara dependinta noua). + signup_rate_max: int = 5 + signup_rate_window_s: int = 3600 + # Max incercari POST /login per IP (brute-force parole). Fereastra impartita cu signup. + login_rate_max: int = 10 + # --- RAR --- rar_env: str = "test" # "test" | "prod" rar_base_url_test: str = "https://apps.rarom.ro/test-rar-autopass" diff --git a/app/main.py b/app/main.py index e5487b6..335f580 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,7 @@ Pornire dev: uvicorn app.main:app --reload from __future__ import annotations +import secrets from contextlib import asynccontextmanager from datetime import datetime, timezone from pathlib import Path @@ -16,6 +17,8 @@ from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles +from starlette.middleware.sessions import SessionMiddleware +from starlette.responses import RedirectResponse from . import __version__ from .api.v1.import_router import router as import_v1_router @@ -24,6 +27,9 @@ from .config import get_settings from .db import get_connection, init_db, queue_depth, read_heartbeat from .security import install_log_redaction from .web.routes import router as web_router +from .web.auth_routes import router as auth_router +from .web.csrf import CsrfError +from .web.session import LoginRequired @asynccontextmanager @@ -35,6 +41,26 @@ async def lifespan(app: FastAPI): app = FastAPI(title="Gateway RAR AUTOPASS", version=__version__, lifespan=lifespan) +settings = get_settings() +_session_secret = settings.session_secret or secrets.token_hex(32) +app.add_middleware( + SessionMiddleware, + secret_key=_session_secret, + session_cookie="autopass_session", + https_only=settings.session_https_only, + same_site="strict", +) + + +@app.exception_handler(LoginRequired) +async def login_required_handler(request: Request, exc: LoginRequired) -> RedirectResponse: + return RedirectResponse("/login", status_code=303) + + +@app.exception_handler(CsrfError) +async def csrf_error_handler(request: Request, exc: CsrfError) -> JSONResponse: + return JSONResponse(status_code=403, content={"detail": "CSRF invalid"}) + @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: @@ -59,6 +85,7 @@ app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") app.include_router(api_v1_router) app.include_router(import_v1_router) app.include_router(web_router) +app.include_router(auth_router) @app.get("/healthz") diff --git a/app/schema.sql b/app/schema.sql index da2a0de..5868b74 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -133,6 +133,19 @@ CREATE TABLE IF NOT EXISTS import_attestations ( n_confirmed INTEGER NOT NULL ); +-- Utilizatori web (email+parola, legati de un cont). Parola stocata doar ca scrypt hash. +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + email TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, -- hex scrypt(salt, parola) + salt TEXT NOT NULL, -- hex secrets.token_bytes(16), per-user + scrypt_params TEXT NOT NULL, -- eticheta versiune parametri: 'n16384_r8_p1' + email_verified INTEGER NOT NULL DEFAULT 0, -- C19: pregatire viitor + is_admin INTEGER NOT NULL DEFAULT 0, -- pregatire 3.3b + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + -- Heartbeat worker (un singur rand, id=1). /healthz citeste de aici. CREATE TABLE IF NOT EXISTS worker_heartbeat ( id INTEGER PRIMARY KEY CHECK (id = 1), diff --git a/app/users.py b/app/users.py new file mode 100644 index 0000000..cc95900 --- /dev/null +++ b/app/users.py @@ -0,0 +1,120 @@ +"""Helper-e utilizatori web (email + parola scrypt). US-001 PRD 3.3. + +Parola NICIODATA stocata in clar. Fiecare user are un salt per-user generat cu +secrets.token_bytes(16). Parametrii scrypt stocati ca eticheta de versiune pentru +migrare cost viitoare (C9). +""" + +from __future__ import annotations + +import hashlib +import hmac +import secrets +import sqlite3 + +SCRYPT_PARAMS = "n16384_r8_p1" +_N = 2**14 +_R = 8 +_P = 1 +_DKLEN = 32 +_MAXMEM = 64 * 1024 * 1024 + +_PASSWORD_MIN = 10 +_PASSWORD_MAX = 128 + + +def _parse_scrypt_params(label: str) -> tuple[int, int, int] | None: + """Parseaza 'nN_rR_pP' -> (N, R, P). Returneaza None la format necunoscut/corupt.""" + try: + parts = label.split("_") + if len(parts) != 3 or parts[0][0] != "n" or parts[1][0] != "r" or parts[2][0] != "p": + return None + return (int(parts[0][1:]), int(parts[1][1:]), int(parts[2][1:])) + except (ValueError, IndexError): + return None + + +def _scrypt_hash(password: str, salt: bytes, n: int = _N, r: int = _R, p: int = _P) -> bytes: + return hashlib.scrypt( + password.encode("utf-8"), + salt=salt, + n=n, + r=r, + p=p, + maxmem=_MAXMEM, + dklen=_DKLEN, + ) + + +def create_user(conn: sqlite3.Connection, account_id: int, email: str, password: str) -> int: + """Creeaza un user nou si intoarce id-ul. + + Valideaza ca: contul exista, parola intre 10 si 128 caractere, emailul nu e duplicat. + Stocheaza DOAR hash scrypt + salt (hex), niciodata parola in clar. + Email duplicat (case-insensitive, via UNIQUE COLLATE NOCASE) -> ValueError. + """ + email = email.strip() + + acct = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone() + if not acct: + raise ValueError(f"cont inexistent: {account_id}") + + if len(password) < _PASSWORD_MIN: + raise ValueError(f"parola prea scurta (minim {_PASSWORD_MIN} caractere)") + if len(password) > _PASSWORD_MAX: + raise ValueError(f"parola prea lunga (maxim {_PASSWORD_MAX} caractere, anti-DoS)") + + salt = secrets.token_bytes(16) + pw_hash = _scrypt_hash(password, salt) + + try: + cur = conn.execute( + "INSERT INTO users (account_id, email, password_hash, salt, scrypt_params) " + "VALUES (?, ?, ?, ?, ?)", + (account_id, email, pw_hash.hex(), salt.hex(), SCRYPT_PARAMS), + ) + except sqlite3.IntegrityError: + raise ValueError("email deja folosit") + + return int(cur.lastrowid or 0) + + +def verify_password(conn: sqlite3.Connection, email: str, password: str) -> int | None: + """Verifica parola pentru email. Intoarce account_id la potrivire, None altfel. + + Nu distinge intre email inexistent si parola gresita (evita enumerare useri). + Comparatie constant-time cu hmac.compare_digest. + """ + row = conn.execute( + "SELECT account_id, password_hash, salt, scrypt_params FROM users " + "WHERE email=? COLLATE NOCASE", + (email.strip(),), + ).fetchone() + + if row is None: + # Executa un hash dummy pentru a evita timing oracle pe email inexistent + _scrypt_hash(password, b"\x00" * 16) + return None + + salt = bytes.fromhex(row["salt"]) + expected = bytes.fromhex(row["password_hash"]) + + params = _parse_scrypt_params(row["scrypt_params"] or "") + if params is None: + return None + n, r, p = params + actual = _scrypt_hash(password, salt, n=n, r=r, p=p) + + if hmac.compare_digest(actual, expected): + return int(row["account_id"]) + return None + + +def get_user_by_email(conn: sqlite3.Connection, email: str) -> dict | None: + """Metadate user dupa email (FARA password_hash si salt).""" + row = conn.execute( + "SELECT id, account_id, email, is_admin, email_verified, created_at " + "FROM users WHERE email=? COLLATE NOCASE", + (email.strip(),), + ).fetchone() + return dict(row) if row else None diff --git a/app/web/auth_routes.py b/app/web/auth_routes.py new file mode 100644 index 0000000..1963039 --- /dev/null +++ b/app/web/auth_routes.py @@ -0,0 +1,155 @@ +"""Rute autentificare web: /signup (US-003), /login + /logout (US-004). PRD 3.3.""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates + +from .. import __version__ +from ..accounts import create_account +from ..auth import create_api_key +from ..config import get_settings +from ..db import get_connection +from ..users import create_user, verify_password +from ..web.csrf import get_csrf_token, verify_csrf +from ..web.ratelimit import check_rate_limit +from ..web.session import clear_session, set_session + +router = APIRouter() +_TMPL = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates")) + +_RATE_MSG = "Prea multe cereri. Incearca mai tarziu." +_PASSWORD_MIN = 10 + + +def _ctx(request: Request, **extra) -> dict: + settings = get_settings() + return {"rar_env": settings.rar_env, "version": __version__, **extra} + + +# --- Signup --- + +@router.get("/signup", response_class=HTMLResponse) +async def signup_get(request: Request): + return _TMPL.TemplateResponse(request, "signup.html", _ctx( + request, csrf_token=get_csrf_token(request) + )) + + +@router.post("/signup", response_class=HTMLResponse) +async def signup_post( + request: Request, + name: str = Form(default=""), + cui: str = Form(default=""), + email: str = Form(default=""), + parola: str = Form(default=""), + csrf_token: str = Form(default=""), +): + verify_csrf(request, csrf_token) + + settings = get_settings() + ip = request.client.host if request.client else "unknown" + if not check_rate_limit(ip, settings.signup_rate_max, settings.signup_rate_window_s): + return _TMPL.TemplateResponse(request, "signup.html", _ctx( + request, + csrf_token=get_csrf_token(request), + error=_RATE_MSG, + name=name, cui=cui, email=email, + ), status_code=429) + + if len(parola) < _PASSWORD_MIN: + return _TMPL.TemplateResponse(request, "signup.html", _ctx( + request, + csrf_token=get_csrf_token(request), + error=f"Parola trebuie sa aiba cel putin {_PASSWORD_MIN} caractere.", + name=name, cui=cui, email=email, + ), status_code=422) + + conn = get_connection() + try: + conn.execute("BEGIN IMMEDIATE") + try: + account_id = create_account(conn, name, cui.strip() or None, active=False) + user_id = create_user(conn, account_id, email, parola) + api_key = create_api_key(conn, account_id) + conn.execute("COMMIT") + except Exception as exc: + conn.execute("ROLLBACK") + return _TMPL.TemplateResponse(request, "signup.html", _ctx( + request, + csrf_token=get_csrf_token(request), + error=str(exc), + name=name, cui=cui, email=email, + ), status_code=422) + finally: + conn.close() + + set_session(request, account_id, user_id) + print(f"SIGNUP cont={account_id} email={email}", flush=True) + + return _TMPL.TemplateResponse(request, "signup.html", _ctx( + request, + csrf_token=get_csrf_token(request), + api_key=api_key, + account_id=account_id, + )) + + +# --- Login / Logout --- + +@router.get("/login", response_class=HTMLResponse) +async def login_get(request: Request): + return _TMPL.TemplateResponse(request, "login.html", _ctx( + request, csrf_token=get_csrf_token(request) + )) + + +@router.post("/login", response_class=HTMLResponse) +async def login_post( + request: Request, + email: str = Form(default=""), + parola: str = Form(default=""), + csrf_token: str = Form(default=""), +): + verify_csrf(request, csrf_token) + + settings = get_settings() + ip = request.client.host if request.client else "unknown" + if not check_rate_limit("login:" + ip, settings.login_rate_max, settings.signup_rate_window_s): + return _TMPL.TemplateResponse(request, "login.html", _ctx( + request, + csrf_token=get_csrf_token(request), + error=_RATE_MSG, + ), status_code=429) + + conn = get_connection() + try: + account_id = verify_password(conn, email, parola) + if account_id is None: + return _TMPL.TemplateResponse(request, "login.html", _ctx( + request, + csrf_token=get_csrf_token(request), + error="Email sau parola incorecte.", + ), status_code=401) + row = conn.execute( + "SELECT id FROM users WHERE email=? COLLATE NOCASE", (email.strip(),) + ).fetchone() + user_id = int(row["id"]) if row else 0 + finally: + conn.close() + + set_session(request, account_id, user_id) + return RedirectResponse("/", status_code=303) + + +@router.post("/logout", response_class=HTMLResponse) +async def logout_post( + request: Request, + csrf_token: str = Form(default=""), +): + verify_csrf(request, csrf_token) + clear_session(request) + return RedirectResponse("/login", status_code=303) diff --git a/app/web/csrf.py b/app/web/csrf.py new file mode 100644 index 0000000..d397aef --- /dev/null +++ b/app/web/csrf.py @@ -0,0 +1,51 @@ +"""CSRF token per-sesiune + validare. US-009 PRD 3.3. + +Contract pentru rutele POST web: +- Formulare HTML includ: +- Handler-ul POST apeleaza: verify_csrf(request, form.get("csrf_token")) +- La nepotrivire/lipsa: CsrfError -> @app.exception_handler(CsrfError) -> 403 + +Token e per-sesiune (stabil pana la logout), generat lazy la primul acces. +""" + +from __future__ import annotations + +import hmac +import secrets + +from starlette.requests import Request + +from ..config import get_settings + + +class CsrfError(Exception): + """Token CSRF lipsa sau invalid. Prins de exception_handler in main.py -> 403.""" + + +def get_csrf_token(request: Request) -> str: + """Intoarce tokenul CSRF al sesiunii, generandu-l daca lipseste.""" + token = request.session.get("csrf_token") + if not token: + token = secrets.token_urlsafe(32) + request.session["csrf_token"] = token + return token + + +def verify_csrf(request: Request, submitted: str | None) -> None: + """Verifica tokenul CSRF trimis in formular. + + Gateaza pe MOD, nu pe account_id: + - prod (web_auth_required=True): enforce pe TOATE rutele POST, inclusiv /login si + /signup unde atacatorul ar putea forta victima sa se logheze in contul sau + (login CSRF). GET-urile de formular genereaza token in sesiune via get_csrf_token. + - dev/test (web_auth_required=False, fara account_id): skip transparent, testele + existente raman verzi fara sa fie nevoie de token. + - sesiune autentificata (account_id in sesiune): enforce indiferent de mod. + """ + settings = get_settings() + enforce = settings.web_auth_required or request.session.get("account_id") is not None + if not enforce: + return # dev fara auth: CSRF neaplicabil + expected = request.session.get("csrf_token") + if not expected or not submitted or not hmac.compare_digest(expected.encode(), submitted.encode()): + raise CsrfError("token CSRF invalid") diff --git a/app/web/ratelimit.py b/app/web/ratelimit.py new file mode 100644 index 0000000..64baeb6 --- /dev/null +++ b/app/web/ratelimit.py @@ -0,0 +1,31 @@ +"""Rate-limit in-proces cu fereastra glisanta. US-009 PRD 3.3 C5. + +Fara dependinta externa. Folosit de POST /signup (US-003) cu cheia = IP client. +Configurabil prin AUTOPASS_signup_rate_max / AUTOPASS_signup_rate_window_s (config.py). +""" + +from __future__ import annotations + +import time +from collections import defaultdict + +# ip/key -> lista de timestamps (time.monotonic) ale cererilor din fereastra activa +_hits: dict[str, list[float]] = defaultdict(list) + + +def check_rate_limit(key: str, max_hits: int, window_s: int) -> bool: + """Fereastra glisanta: returneaza True daca cererea e permisa, False la depasire. + + Curata timestamp-urile expirate la fiecare apel (O(n) per cheie, acceptabil + pentru trafic de signup). Thread-safety: GIL Python protejeaza list ops simple; + suficient pentru un singur proces uvicorn. + """ + now = time.monotonic() + cutoff = now - window_s + timestamps = _hits[key] + # Sterge intrari expirate + _hits[key] = [t for t in timestamps if t > cutoff] + if len(_hits[key]) >= max_hits: + return False + _hits[key].append(now) + return True diff --git a/app/web/routes.py b/app/web/routes.py index b3ea45b..2ab8037 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -22,6 +22,8 @@ from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from .. import __version__ +from ..web.csrf import get_csrf_token, verify_csrf +from ..web.session import require_login from ..api.v1.import_router import ( _already_sent_lookup, _build_idempotency_key, @@ -55,11 +57,31 @@ templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "tem _BLOCKED = ("error", "needs_data", "needs_mapping") -def _status_counts(conn) -> dict[str, int]: - rows = conn.execute("SELECT status, COUNT(*) AS n FROM submissions GROUP BY status").fetchall() +def _ctx(request: Request, **extra) -> dict: + """Context de baza pentru template-uri cu formulare: include mereu csrf_token. + + Previne lock-out in prod (web_auth_required=True): orice re-randare de eroare + trebuie sa includa csrf_token negol altfel urmatorul submit da 403 (task #8). + """ + return {"request": request, "csrf_token": get_csrf_token(request), **extra} + + +def _status_counts(conn, account_id: int) -> dict[str, int]: + rows = conn.execute( + "SELECT status, COUNT(*) AS n FROM submissions " + "WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) " + "GROUP BY status", + (account_id, account_id), + ).fetchall() return {r["status"]: int(r["n"]) for r in rows} +def _account_active(conn, account_id: int) -> bool: + """True daca contul e activ (sau legacy cu NULL/absent active).""" + row = conn.execute("SELECT active FROM accounts WHERE id=?", (account_id,)).fetchone() + return bool(row["active"]) if row else True + + def _worker_alive(hb) -> bool: if hb is None or not hb["last_beat"]: return False @@ -92,9 +114,10 @@ def _rar_state(hb, worker_alive: bool) -> str: @router.get("/", response_class=HTMLResponse) def dashboard(request: Request) -> HTMLResponse: + account_id = require_login(request) conn = get_connection() try: - counts = _status_counts(conn) + counts = _status_counts(conn, account_id) hb = read_heartbeat(conn) blocked = sum(counts.get(s, 0) for s in _BLOCKED) worker_alive = _worker_alive(hb) @@ -107,6 +130,8 @@ def dashboard(request: Request) -> HTMLResponse: "worker_alive": worker_alive, "last_login": hb["last_rar_login_ok"] if hb else None, "rar_state": _rar_state(hb, worker_alive), + "account_active": _account_active(conn, account_id), + "csrf_token": get_csrf_token(request), } return templates.TemplateResponse("dashboard.html", ctx) finally: @@ -130,46 +155,62 @@ def fragment_nomenclator(request: Request) -> HTMLResponse: @router.get("/_fragments/banner", response_class=HTMLResponse) def fragment_banner(request: Request) -> HTMLResponse: + account_id = require_login(request) conn = get_connection() try: - counts = _status_counts(conn) + counts = _status_counts(conn, account_id) blocked = sum(counts.get(s, 0) for s in _BLOCKED) - return templates.TemplateResponse("_banner.html", {"request": request, "blocked": blocked}) + return templates.TemplateResponse("_banner.html", { + "request": request, + "blocked": blocked, + "account_active": _account_active(conn, account_id), + }) finally: conn.close() @router.get("/_fragments/submissions", response_class=HTMLResponse) def fragment_submissions(request: Request) -> HTMLResponse: + account_id = require_login(request) conn = get_connection() try: rows = conn.execute( "SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, updated_at " - "FROM submissions ORDER BY id DESC LIMIT 100" + "FROM submissions " + "WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) " + "ORDER BY id DESC LIMIT 100", + (account_id, account_id), ).fetchall() return templates.TemplateResponse("_submissions.html", {"request": request, "rows": rows}) finally: conn.close() -def _render_mapari(request: Request, conn, *, message: str | None = None) -> HTMLResponse: +def _render_mapari( + request: Request, conn, account_id: int, *, message: str | None = None +) -> HTMLResponse: return templates.TemplateResponse( "_mapari.html", { "request": request, - "pending": pending_unmapped(conn), + "pending": pending_unmapped(conn, account_id), "nomenclator": load_nomenclator(conn), "message": message, + "csrf_token": get_csrf_token(request), }, ) @router.get("/_fragments/mapari", response_class=HTMLResponse) def fragment_mapari(request: Request) -> HTMLResponse: - """Editor mapari: operatii ROAAUTO nemapate + sugestii fuzzy pe nomenclator RAR.""" + """Editor mapari: operatii ROAAUTO nemapate + sugestii fuzzy pe nomenclator RAR. + + Scoped pe contul sesiunii (C6/task#7): pending_unmapped primeste account_id explicit. + """ + account_id = require_login(request) conn = get_connection() try: - return _render_mapari(request, conn) + return _render_mapari(request, conn, account_id) finally: conn.close() @@ -179,16 +220,18 @@ def post_mapare( request: Request, cod_op_service: str = Form(...), cod_prestatie: str = Form(...), - account_id: int | None = Form(None), + csrf_token: str | None = Form(None), auto_send: bool = Form(False), ) -> HTMLResponse: """Salveaza maparea aleasa de user, re-rezolva submission-urile blocate, re-randeaza editorul.""" + account_id = require_login(request) + verify_csrf(request, csrf_token) conn = get_connection() try: cod = cod_prestatie.strip().upper() exists = conn.execute("SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)).fetchone() if not exists: - return _render_mapari(request, conn, message=f"Cod necunoscut: {cod}") + return _render_mapari(request, conn, account_id, message=f"Cod necunoscut: {cod}") save_mapping(conn, account_id, cod_op_service, cod, auto_send) stats = reresolve_account(conn, account_id) msg = ( @@ -196,7 +239,7 @@ def post_mapare( f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} cu date lipsa, " f"{stats['still_blocked']} inca nemapate." ) - return _render_mapari(request, conn, message=msg) + return _render_mapari(request, conn, account_id, message=msg) finally: conn.close() @@ -383,6 +426,7 @@ async def web_upload_import( request: Request, file: UploadFile = File(...), sheet_name: str | None = Form(None), + csrf_token: str | None = Form(None), ) -> HTMLResponse: """Upload fisier xlsx/csv → staging; intoarce fragment HTML. @@ -390,7 +434,8 @@ async def web_upload_import( Daca nu: intoarce formularul de mapare coloane. Nu editeaza import_router.py — apeleaza parse_file si DB direct. """ - account_id = DEFAULT_ACCOUNT_ID + account_id = require_login(request) + verify_csrf(request, csrf_token) acct = account_or_default(account_id) data = await file.read() @@ -400,30 +445,15 @@ async def web_upload_import( try: parsed = parse_file(data, filename, sheet_name=sheet_name) except MultipleSheets as ms: - return templates.TemplateResponse("_upload.html", { - "request": request, - "sheets": ms.sheet_names, - }) + return templates.TemplateResponse("_upload.html", _ctx(request, sheets=ms.sheet_names)) except FileTooLarge as e: - return templates.TemplateResponse("_upload.html", { - "request": request, - "error": str(e), - }) + return templates.TemplateResponse("_upload.html", _ctx(request, error=str(e))) except HeaderError as e: - return templates.TemplateResponse("_upload.html", { - "request": request, - "error": f"Antet neclar: {e}", - }) + return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Antet neclar: {e}")) except UnicodeDecodeError as e: - return templates.TemplateResponse("_upload.html", { - "request": request, - "error": f"Encoding nesuportat: {e.reason}", - }) + return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Encoding nesuportat: {e.reason}")) except Exception as e: - return templates.TemplateResponse("_upload.html", { - "request": request, - "error": f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}", - }) + return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}")) conn = get_connection() try: @@ -467,11 +497,13 @@ async def web_upload_import( return templates.TemplateResponse("_upload.html", { "request": request, "error": result, + "csrf_token": get_csrf_token(request), }) return templates.TemplateResponse("_preview_import.html", { "request": request, "import_id": batch_id_int, "message": "Mapare retinuta aplicata automat.", + "csrf_token": get_csrf_token(request), **result, }) @@ -491,6 +523,7 @@ async def web_upload_import( "fuzzy_suggestions": fuzzy_suggestions, "canonical_fields": _CANONICAL_FIELDS, "format_data": None, + "csrf_token": get_csrf_token(request), }) finally: conn.close() @@ -502,13 +535,14 @@ async def web_save_mapare_coloane( import_id: int, ) -> HTMLResponse: """Salveaza maparea de coloane si computa preview. Intoarce fragment HTML.""" - account_id = DEFAULT_ACCOUNT_ID + account_id = require_login(request) acct = account_or_default(account_id) form = await request.form() # Colectare perechi coloana fisier → camp canonic din form # form.getlist intoarce List[str | UploadFile]; filtram la str (campuri text) + verify_csrf(request, str(form.get("csrf_token") or "")) colnames = [str(v) for v in form.getlist("colname") if isinstance(v, str)] canons = [str(v) for v in form.getlist("canon") if isinstance(v, str)] format_data_val = str(form.get("format_data") or "").strip() or None @@ -539,17 +573,17 @@ async def web_save_mapare_coloane( sugg = _fuzzy_suggest_column(col, limit=3) if sugg: fuzzy[col] = sugg - return templates.TemplateResponse("_mapcoloane.html", { - "request": request, - "import_id": import_id, - "columns": columns, - "sample_rows": [], - "fuzzy_suggestions": fuzzy, - "canonical_fields": _CANONICAL_FIELDS, - "format_data": format_data_val, - "message": "Mapeaza cel putin un camp canonic inainte de a continua.", - "error": True, - }) + return templates.TemplateResponse("_mapcoloane.html", _ctx( + request, + import_id=import_id, + columns=columns, + sample_rows=[], + fuzzy_suggestions=fuzzy, + canonical_fields=_CANONICAL_FIELDS, + format_data=format_data_val, + message="Mapeaza cel putin un camp canonic inainte de a continua.", + error=True, + )) finally: conn.close() @@ -561,10 +595,9 @@ async def web_save_mapare_coloane( (import_id, acct), ).fetchone() if not batch: - return templates.TemplateResponse("_upload.html", { - "request": request, - "error": "Batch de import inexistent sau expirat.", - }) + return templates.TemplateResponse("_upload.html", _ctx( + request, error="Batch de import inexistent sau expirat." + )) sig = _signature(list(json_mapare.keys())) @@ -580,15 +613,10 @@ async def web_save_mapare_coloane( # Computa preview result = _web_compute_preview(conn, import_id, account_id) if isinstance(result, str): - return templates.TemplateResponse("_upload.html", { - "request": request, - "error": result, - }) - return templates.TemplateResponse("_preview_import.html", { - "request": request, - "import_id": import_id, - **result, - }) + return templates.TemplateResponse("_upload.html", _ctx(request, error=result)) + return templates.TemplateResponse("_preview_import.html", _ctx( + request, import_id=import_id, **result + )) finally: conn.close() @@ -599,7 +627,7 @@ def web_preview_import( import_id: int, ) -> HTMLResponse: """Preview 6 stari per rand. Tinta HTMX dupa mapare retinuta sau navigare directa.""" - account_id = DEFAULT_ACCOUNT_ID + account_id = require_login(request) conn = get_connection() try: result = _web_compute_preview(conn, import_id, account_id) @@ -607,10 +635,12 @@ def web_preview_import( return templates.TemplateResponse("_upload.html", { "request": request, "error": result, + "csrf_token": get_csrf_token(request), }) return templates.TemplateResponse("_preview_import.html", { "request": request, "import_id": import_id, + "csrf_token": get_csrf_token(request), **result, }) finally: @@ -620,7 +650,10 @@ def web_preview_import( @router.get("/_import/reset", response_class=HTMLResponse) def web_import_reset(request: Request) -> HTMLResponse: """Reseteaza sectiunea de import la starea initiala (drop zone gol).""" - return templates.TemplateResponse("_upload.html", {"request": request}) + return templates.TemplateResponse("_upload.html", { + "request": request, + "csrf_token": get_csrf_token(request), + }) @router.post("/_import/{import_id}/confirma", response_class=HTMLResponse) @@ -632,11 +665,14 @@ async def web_confirma_import( Replica logica din import_router.commit_import dar cu input din form HTML si raspuns HTML (nu JSON). INSERT per-rand ON CONFLICT DO NOTHING (TOCTOU). + C8/OV-2: account_id din sesiune propagat consecvent la build_key si toate lookup-urile. + C12: require_login — pe scrieri NICIODATA fallback cont 1 in prod. """ - account_id = DEFAULT_ACCOUNT_ID + account_id = require_login(request) acct = account_or_default(account_id) form = await request.form() + verify_csrf(request, str(form.get("csrf_token") or "")) # Parseaza n_confirmat (form.get intoarce str | UploadFile | None → cast la str) try: @@ -662,16 +698,14 @@ async def web_confirma_import( (import_id, acct), ).fetchone() if not batch: - return templates.TemplateResponse("_upload.html", { - "request": request, - "error": "Batch de import inexistent sau expirat.", - }) + return templates.TemplateResponse("_upload.html", _ctx( + request, error="Batch de import inexistent sau expirat." + )) if batch["status"] == "committed": - return templates.TemplateResponse("_upload.html", { - "request": request, - "message": "Acest batch a fost deja comis.", - }) + return templates.TemplateResponse("_upload.html", _ctx( + request, message="Acest batch a fost deja comis." + )) # Incarca randurile cu stare ok si needs_review ok_rows_db = conn.execute( @@ -684,14 +718,14 @@ async def web_confirma_import( # Re-arata preview cu eroare result = _web_compute_preview(conn, import_id, account_id) if isinstance(result, str): - return templates.TemplateResponse("_upload.html", {"request": request, "error": result}) - return templates.TemplateResponse("_preview_import.html", { - "request": request, - "import_id": import_id, - "message": "Niciun rand ok de confirmat in acest batch.", - "error": True, + return templates.TemplateResponse("_upload.html", _ctx(request, error=result)) + return templates.TemplateResponse("_preview_import.html", _ctx( + request, + import_id=import_id, + message="Niciun rand ok de confirmat in acest batch.", + error=True, **result, - }) + )) # Decripteaza si construieste lista de randuri de trimis to_enqueue: list[dict[str, Any]] = [] @@ -726,26 +760,22 @@ async def web_confirma_import( f"Verifica preview-ul si retasteaza numarul corect." ) if isinstance(result, str): - return templates.TemplateResponse("_upload.html", {"request": request, "error": msg}) - return templates.TemplateResponse("_preview_import.html", { - "request": request, - "import_id": import_id, - "message": msg, - "error": True, - **result, - }) + return templates.TemplateResponse("_upload.html", _ctx(request, error=msg)) + return templates.TemplateResponse("_preview_import.html", _ctx( + request, import_id=import_id, message=msg, error=True, **result + )) if n_total_ok == 0: result = _web_compute_preview(conn, import_id, account_id) if isinstance(result, str): - return templates.TemplateResponse("_upload.html", {"request": request, "error": result}) - return templates.TemplateResponse("_preview_import.html", { - "request": request, - "import_id": import_id, - "message": "Niciun rand ok de confirmat.", - "error": True, + return templates.TemplateResponse("_upload.html", _ctx(request, error=result)) + return templates.TemplateResponse("_preview_import.html", _ctx( + request, + import_id=import_id, + message="Niciun rand ok de confirmat.", + error=True, **result, - }) + )) # Incarca maparea de coloane pentru payload first_row_db = conn.execute( @@ -867,13 +897,13 @@ async def web_confirma_import( # Succes → drop zone cu mesaj de confirmare toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else "" - return templates.TemplateResponse("_upload.html", { - "request": request, - "message": ( + return templates.TemplateResponse("_upload.html", _ctx( + request, + message=( f"S-au pus in coada {n_enqueued} prezentari{toctou_msg}. " f"Procesarea incepe in cateva secunde — urmareste coada de mai jos." ), - }) + )) finally: conn.close() diff --git a/app/web/session.py b/app/web/session.py new file mode 100644 index 0000000..1ee9b61 --- /dev/null +++ b/app/web/session.py @@ -0,0 +1,75 @@ +"""Helper-e sesiune web. US-002 PRD 3.3. + +Mecanism require_login (C11): NU un dependency FastAPI care intoarce RedirectResponse +(acela nu scurtcircuiteaza handler-ul — FastAPI continua executia). In schimb: +- require_login() RIDICA LoginRequired +- app.main inregistreaza @app.exception_handler(LoginRequired) care intoarce + RedirectResponse('/login', 303) +Astfel handler-ul e intrerupt imediat la raise, independent de logica FastAPI. +""" + +from __future__ import annotations + +from starlette.requests import Request + +from ..config import get_settings +from ..mapping import DEFAULT_ACCOUNT_ID + + +class LoginRequired(Exception): + """Ridica pentru a redirectiona la /login (prinsa de exception_handler in main.py).""" + + +def current_account(request: Request) -> int | None: + """account_id din sesiune sau None daca nu e logat.""" + val = request.session.get("account_id") + return int(val) if val is not None else None + + +def current_user_id(request: Request) -> int | None: + """user_id din sesiune sau None (C19: leaga import_attestations.confirmed_by).""" + val = request.session.get("user_id") + return int(val) if val is not None else None + + +def web_account(request: Request) -> int | None: + """account_id pentru rutele web de CITIRE. + + - sesiune activa -> contul sesiunii + - fara sesiune + web_auth_required=False (dev) -> DEFAULT_ACCOUNT_ID (cont 1, back-compat) + - fara sesiune + web_auth_required=True (prod) -> None + + Rutele de SCRIERE trebuie sa foloseasca require_login() direct, nu web_account(), + ca sa nu cada niciodata tacit pe contul 1 in prod. + """ + aid = current_account(request) + if aid is not None: + return aid + settings = get_settings() + if not settings.web_auth_required: + return DEFAULT_ACCOUNT_ID + return None + + +def require_login(request: Request) -> int: + """Verifica sesiunea activa; ridica LoginRequired daca nu. + + Intoarce account_id la succes. Aruncatorul (exception_handler din main.py) + intercepteaza LoginRequired si intoarce RedirectResponse('/login', 303). + """ + aid = web_account(request) + if aid is None: + raise LoginRequired() + return aid + + +def set_session(request: Request, account_id: int, user_id: int) -> None: + """Seteaza sesiunea dupa login. Curata mai intai (C3 anti-fixare sesiune).""" + request.session.clear() + request.session["account_id"] = account_id + request.session["user_id"] = user_id + + +def clear_session(request: Request) -> None: + """Sterge sesiunea (logout).""" + request.session.clear() diff --git a/app/web/templates/_banner.html b/app/web/templates/_banner.html index 72498ad..0610d31 100644 --- a/app/web/templates/_banner.html +++ b/app/web/templates/_banner.html @@ -1,5 +1,12 @@ - +{% endif %} + diff --git a/app/web/templates/_mapari.html b/app/web/templates/_mapari.html index 837d8e3..dc8e137 100644 --- a/app/web/templates/_mapari.html +++ b/app/web/templates/_mapari.html @@ -17,7 +17,7 @@ {% set top = e.suggestions[0] if e.suggestions else None %} {% set preselect = top.cod_prestatie if (top and top.score >= 60) else '' %}
- +
diff --git a/app/web/templates/_mapcoloane.html b/app/web/templates/_mapcoloane.html index ca41653..a201355 100644 --- a/app/web/templates/_mapcoloane.html +++ b/app/web/templates/_mapcoloane.html @@ -20,6 +20,7 @@ +