feat(v2): secure password hashing, finalize OpenAPI, and expand shopping v2
This commit is contained in:
parent
3c69355a5c
commit
0411791ad9
2 changed files with 31 additions and 5 deletions
|
|
@ -1,6 +1,9 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
@ -33,16 +36,34 @@ class TokenResponse(ApiModel):
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
user: User
|
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:
|
def _hash_pw(pw: str) -> str:
|
||||||
# Placeholder; replace with proper hashing (bcrypt/argon2) later
|
salt = os.urandom(SALT_BYTES)
|
||||||
return hashlib.sha256(pw.encode("utf-8")).hexdigest()
|
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:
|
def _jwt_config() -> JwtConfig:
|
||||||
# Secrets can be provided base64-encoded via env; fallback to deterministic dev defaults (NOT for prod)
|
# Secrets can be provided base64-encoded via env; fallback to deterministic dev defaults (NOT for prod)
|
||||||
import base64
|
|
||||||
|
|
||||||
if settings.access_secret_b64:
|
if settings.access_secret_b64:
|
||||||
access = base64.b64decode(settings.access_secret_b64)
|
access = base64.b64decode(settings.access_secret_b64)
|
||||||
else:
|
else:
|
||||||
|
|
@ -99,7 +120,7 @@ async def login(request: Request, body: LoginBody, conn: aiosqlite.Connection =
|
||||||
if not user:
|
if not user:
|
||||||
return error_response(request, 401, "Invalid credentials")
|
return error_response(request, 401, "Invalid credentials")
|
||||||
stored = await users_db.get_local_password_hash(conn, user.id)
|
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")
|
return error_response(request, 401, "Invalid credentials")
|
||||||
access, refresh = _token_pair_for_user(user)
|
access, refresh = _token_pair_for_user(user)
|
||||||
resp = JSONResponse(TokenResponse(access_token=access, user=user).model_dump(by_alias=True))
|
resp = JSONResponse(TokenResponse(access_token=access, user=user).model_dump(by_alias=True))
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- 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.
|
- 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:
|
What to preserve from v1:
|
||||||
- CamelCase response keys, non-null collection properties, `Page<T>` envelope and cursor semantics, RFC7807 responses, `Location` header on create.
|
- CamelCase response keys, non-null collection properties, `Page<T>` envelope and cursor semantics, RFC7807 responses, `Location` header on create.
|
||||||
- Shopping outward storeName normalization ("home" instead of empty string).
|
- Shopping outward storeName normalization ("home" instead of empty string).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue