feat(setari): meniu grupat + pagina Setari cu sub-taburi + schimbare parola

Meniu hamburger reorganizat pe grupuri cu etichete (LUCRU / CONT) cu iconite
SVG discrete; "Cont" redenumit "Setari" (URL ?tab=cont pastrat, zero rute moarte).

Pagina Setari restructurata in sub-taburi interne: Date firma / Credentiale RAR /
Cheie API / Cont (plan + schimbare parola), cu indicator de stare per tab. Sub-tabul
activ e pastrat server-side (activ_subtab) dupa fiecare swap HTMX; comutarea intre
taburi e client-side, delegata pe document (supravietuieste swap-urilor).

Credentiale RAR: mediile Testare/Productie ca doua carduri paralele.

Schimbare parola de acces din sub-tabul Cont (functie noua, lipsea):
- users.change_password (scrypt): verifica parola actuala constant-time, valideaza
  noua (10..128, diferita), rescrie hash+salt nou;
- ruta POST /cont/schimba-parola, scoped pe user_id din sesiune, CSRF enforce,
  parola niciodata re-pusa in raspuns, jurnal 'parola_schimbata'.

Referinte copy "Meniu cont > Cont" -> "Setari".

Teste: schimbare parola (succes / parola actuala gresita / confirmare nepotrivita /
lungime) + izolare rate-limit login in fixture-ul clientului.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-07-06 13:40:38 +00:00
parent 164f90d603
commit 86408887e5
7 changed files with 736 additions and 385 deletions

View File

@@ -157,6 +157,53 @@ def verify_password(conn: sqlite3.Connection, email: str, password: str) -> int
return None
def change_password(
conn: sqlite3.Connection,
user_id: int,
current_password: str,
new_password: str,
) -> None:
"""Schimba parola de acces a userului identificat prin user_id.
Verifica parola actuala (scrypt, comparatie constant-time), valideaza parola
noua (10..128, diferita de cea actuala) si rescrie password_hash + salt nou cu
parametrii scrypt curenti. Parola NICIODATA stocata in clar.
Ridica ValueError la: user inexistent, parametri parola corupti, parola actuala
gresita, parola noua invalida. Nu invalideaza sesiunea (apelantul decide).
"""
row = conn.execute(
"SELECT password_hash, salt, scrypt_params FROM users WHERE id=?",
(user_id,),
).fetchone()
if row is None:
raise ValueError("utilizator inexistent")
salt = bytes.fromhex(row["salt"])
expected = bytes.fromhex(row["password_hash"])
params = _parse_scrypt_params(row["scrypt_params"] or "")
if params is None:
raise ValueError("parametri parola corupti")
n, r, p = params
actual = _scrypt_hash(current_password, salt, n=n, r=r, p=p)
if not hmac.compare_digest(actual, expected):
raise ValueError("Parola actuala este incorecta.")
if len(new_password) < _PASSWORD_MIN:
raise ValueError(f"Parola noua e prea scurta (minim {_PASSWORD_MIN} caractere).")
if len(new_password) > _PASSWORD_MAX:
raise ValueError(f"Parola noua e prea lunga (maxim {_PASSWORD_MAX} caractere).")
if new_password == current_password:
raise ValueError("Parola noua trebuie sa difere de cea actuala.")
new_salt = secrets.token_bytes(16)
new_hash = _scrypt_hash(new_password, new_salt)
conn.execute(
"UPDATE users SET password_hash=?, salt=?, scrypt_params=? WHERE id=?",
(new_hash.hex(), new_salt.hex(), SCRYPT_PARAMS, user_id),
)
def get_user_by_email(conn: sqlite3.Connection, email: str) -> dict | None:
"""Metadate user dupa email (FARA password_hash si salt)."""
row = conn.execute(