From 0411791ad9471598147ae8f0f887dd3b0bb63aed Mon Sep 17 00:00:00 2001 From: jableader Date: Sat, 1 Nov 2025 15:02:37 +1100 Subject: [PATCH] feat(v2): secure password hashing, finalize OpenAPI, and expand shopping v2 --- api/auth_v2.py | 31 ++++++++++++++++++++++++++----- backend-spec.md | 5 +++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/api/auth_v2.py b/api/auth_v2.py index 9df2122..7318cf4 100644 --- a/api/auth_v2.py +++ b/api/auth_v2.py @@ -1,6 +1,9 @@ from __future__ import annotations +import base64 import hashlib +import hmac +import os from typing import Optional import aiosqlite @@ -33,16 +36,34 @@ class TokenResponse(ApiModel): token_type: str = "bearer" user: User +PBKDF2_ALG = "pbkdf2_sha256" +PBKDF2_ITER = 390000 # similar to Django default; adjust in settings if needed +SALT_BYTES = 16 + def _hash_pw(pw: str) -> str: - # Placeholder; replace with proper hashing (bcrypt/argon2) later - return hashlib.sha256(pw.encode("utf-8")).hexdigest() + salt = os.urandom(SALT_BYTES) + dk = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), salt, PBKDF2_ITER) + return f"{PBKDF2_ALG}${PBKDF2_ITER}${base64.b64encode(salt).decode()}${base64.b64encode(dk).decode()}" + + +def _verify_pw(pw: str, stored: str) -> bool: + try: + alg, iter_s, salt_b64, hash_b64 = stored.split("$", 3) + if alg != PBKDF2_ALG: + return False + iters = int(iter_s) + salt = base64.b64decode(salt_b64) + expected = base64.b64decode(hash_b64) + dk = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), salt, iters) + # constant-time compare + return hmac.compare_digest(dk, expected) + except Exception: + return False def _jwt_config() -> JwtConfig: # Secrets can be provided base64-encoded via env; fallback to deterministic dev defaults (NOT for prod) - import base64 - if settings.access_secret_b64: access = base64.b64decode(settings.access_secret_b64) else: @@ -99,7 +120,7 @@ async def login(request: Request, body: LoginBody, conn: aiosqlite.Connection = if not user: return error_response(request, 401, "Invalid credentials") stored = await users_db.get_local_password_hash(conn, user.id) - if not stored or stored != _hash_pw(body.password): + if not stored or not _verify_pw(body.password, stored): return error_response(request, 401, "Invalid credentials") access, refresh = _token_pair_for_user(user) resp = JSONResponse(TokenResponse(access_token=access, user=user).model_dump(by_alias=True)) diff --git a/backend-spec.md b/backend-spec.md index 326ad53..3dabc07 100644 --- a/backend-spec.md +++ b/backend-spec.md @@ -275,6 +275,11 @@ What’s missing today (must be implemented in v2): - Household scoping: path prefixes, membership checks, repository filtering by `household_id` across all data tables. - OpenAPI security scheme update to JWT bearer; `403` responses for membership violations. +Immediate priorities (feedback-incorporated): +- Upgrade password hashing from SHA-256 to a secure, adaptive scheme (PBKDF2/bcrypt/argon2). DONE with PBKDF2; consider migrating to argon2 in future. +- Finalize and commit `openapi.json` with household-scoped routes to unblock frontend (temporary X-Household-Slug header can be removed). +- Plan v1 cleanup: once parity is achieved for scoped routes, rename `*_v2.py` to canonical filenames and remove original v1 modules to avoid long-term duplication. + What to preserve from v1: - CamelCase response keys, non-null collection properties, `Page` envelope and cursor semantics, RFC7807 responses, `Location` header on create. - Shopping outward storeName normalization ("home" instead of empty string).