Canalul web trece de la 100% deschis (hardcodat cont 1) la autentificat si multi-tenant. Un service nou se inregistreaza din browser, primeste o cheie API (o singura data) si o sesiune; contul se creeaza "in asteptare" (active=0) si nu trimite la RAR pana la activarea de catre admin (tools/account.py activate). - users + app/users.py: parole scrypt (salt per-user, eticheta parametri onorata la verify pentru migrare cost), email unic case-insensitive - sesiune: SessionMiddleware (same_site=strict, https_only config) + app/web/session.py (current_account/web_account/require_login->LoginRequired, set_session clear-inainte) - CSRF (app/web/csrf.py) enforce in prod inclusiv pe login/signup + rate-limit in-proces (app/web/ratelimit.py) pe signup si login - signup/login/logout (app/web/auth_routes.py): signup tranzactie atomica, cheie-o-data, log SIGNUP pentru descoperire admin - dashboard + import scoped pe contul sesiunii (regula NULL->cont 1); toate rutele web care ating date sensibile sub require_login; nomenclator ramane global - banner "cont in asteptare" pentru conturi active=0 - gate worker: claim_one LEFT JOIN accounts COALESCE(active,1)=1 (account_id NULL=activ) VERIFY context curat (2 runde): leak cross-account /_fragments/mapari prins+reparat. /code-review high: csrf_token lipsa pe re-randari de eroare, scrypt_params ignorat, login fara rate-limit -- toate reparate. 361 teste pass (de la 313). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
121 lines
3.8 KiB
Python
121 lines
3.8 KiB
Python
"""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
|