From 9504c9cb3484f35f3c4af4425c2c71dab18e5bf2 Mon Sep 17 00:00:00 2001 From: jableader Date: Sat, 1 Nov 2025 19:20:08 +1100 Subject: [PATCH] feat(auth, invitations): switch to Argon2-only password hashing; return household on invitation accept; remove legacy cookie-based export; update OpenAPI wording and spec --- api/auth.py | 51 +++++++++-------------------------------------- api/households.py | 30 ++++++++++++++++++++++------ api/openapi.py | 4 ++-- backend-spec.md | 7 +++---- main.py | 1 - openapi.json | 39 ++++++++++++++++++++++++++++++++---- 6 files changed, 73 insertions(+), 59 deletions(-) diff --git a/api/auth.py b/api/auth.py index ff58bd5..89c0d56 100644 --- a/api/auth.py +++ b/api/auth.py @@ -1,9 +1,6 @@ from __future__ import annotations import base64 -import hashlib -import hmac -import os from typing import Optional import aiosqlite @@ -15,16 +12,13 @@ from common import ApiModel from security import JwtConfig, create_jwt from settings import settings from users import repository as users_db - -# Prefer Argon2 for new passwords; keep PBKDF2 verify for backward compatibility -try: - from argon2 import PasswordHasher - - _ph: PasswordHasher | None = PasswordHasher() -except Exception: # pragma: no cover - optional dependency in some environments - _ph = None from users.models import User +from argon2 import PasswordHasher + +# Argon2-only password hashing +_ph: PasswordHasher = PasswordHasher() + router = APIRouter(prefix="/auth", tags=["auth"]) @@ -45,42 +39,15 @@ class TokenResponse(ApiModel): user: User -PBKDF2_ALG = "pbkdf2_sha256" -PBKDF2_ITER = 390000 # kept for verifying older hashes -SALT_BYTES = 16 - - def _hash_pw(pw: str) -> str: - """Hash a password. - - Uses Argon2 when available; falls back to PBKDF2 for environments without argon2-cffi. - """ - if _ph is not None: - return _ph.hash(pw) - # Fallback - 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()}" + """Hash a password using Argon2.""" + return _ph.hash(pw) def _verify_pw(pw: str, stored: str) -> bool: - """Verify password against either Argon2 or PBKDF2 stored hashes.""" - # Try Argon2 first - if _ph is not None and stored.startswith("$argon2"): - try: - return _ph.verify(stored, pw) - except Exception: - return False - # PBKDF2 fallback + """Verify password using Argon2-only stored hashes.""" 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) - return hmac.compare_digest(dk, expected) + return _ph.verify(stored, pw) except Exception: return False diff --git a/api/households.py b/api/households.py index 4414830..4c64b40 100644 --- a/api/households.py +++ b/api/households.py @@ -152,17 +152,24 @@ async def create_invitation( return error_response(request, 400, "Unable to create invitation") +class AcceptInvitationBody(ApiModel): + token: str + + +class AcceptInvitationResponse(ApiModel): + status: str = "accepted" + household: HouseholdResponse + + # Accept invitation (mounted on root router via main.py) -@router.post("/invitations/accept") +@router.post("/invitations/accept", response_model=AcceptInvitationResponse) async def accept_invitation( request: Request, - body: dict, + body: AcceptInvitationBody, user: User = Depends(get_current_user), conn: aiosqlite.Connection = Depends(get_db), ): - token = body.get("token") - if not token: - return error_response(request, 400, "Token required") + token = body.token # Lookup invitation async with conn.execute( "SELECT id, household_id, status FROM HouseholdInvitation WHERE token = ?", @@ -186,4 +193,15 @@ async def accept_invitation( "UPDATE HouseholdInvitation SET status = 'accepted' WHERE id = ?", (inv_id,), ) - return {"status": "accepted"} + # Load household details for response + async with conn.execute( + "SELECT id, name, slug FROM Household WHERE id = ?", + (hid,), + ) as c: + hrow = await c.fetchone() + if not hrow: + return error_response(request, 404, "Household not found") + return AcceptInvitationResponse( + status="accepted", + household=HouseholdResponse(id=int(hrow[0]), name=hrow[1], slug=hrow[2]), + ) diff --git a/api/openapi.py b/api/openapi.py index 80f6d24..073505d 100644 --- a/api/openapi.py +++ b/api/openapi.py @@ -6,7 +6,7 @@ from fastapi import FastAPI def extend_with_problem_and_cookie_auth(app: FastAPI) -> None: - """Augment FastAPI's OpenAPI spec with RFC7807 responses and cookie auth. + """Augment FastAPI's OpenAPI spec with RFC7807 responses and JWT bearer auth. This mutates the app's OpenAPI generation in-place while delegating to the original generator for the base schema. @@ -63,7 +63,7 @@ def extend_with_problem_and_cookie_auth(app: FastAPI) -> None: }, ) - # Remove legacy cookieAuth; JWT bearer is the only auth now + # Legacy cookieAuth removed; JWT bearer is the only auth now # Bearer (JWT) auth for v2 security_schemes.setdefault( diff --git a/backend-spec.md b/backend-spec.md index 5f7d3d5..5283ad4 100644 --- a/backend-spec.md +++ b/backend-spec.md @@ -8,7 +8,7 @@ This document has been validated against the current codebase (v1) to ensure the plan captures all required changes. It starts with a concise baseline of what exists today, then details the v2 multi-tenancy/auth refactor with concrete, file-scoped steps and acceptance criteria. -Date reviewed: 2025-11-01 (post-cutover: JWT + households live, Argon2 enabled, members endpoint added, v2 routers inlined; all checks green; OpenAPI exported) +Date reviewed: 2025-11-01 (post-cutover: JWT + households live, Argon2-only, members endpoint added, v2 routers inlined; all checks green; OpenAPI exported) Repo modules checked: `main.py`, `api/*` (v2-only; *_v2 modules removed), `users/*`, `households/*`, `meals/*`, `ingredients/*`, `recipes/*`, `products/*`, `shopping/*`, `common.py`, `db.py`, `settings.py`, tests in `tests/*`. `persons/*` remains for migration compatibility but has no routes. @@ -175,7 +175,7 @@ Repositories accept `household_id` and filter by it across `meals`, `recipes`, a - **Add to `api/households.py`**: - **`POST /api/v1/households/{householdSlug}/invitations`**: (Auth: JWT, household membership). Body `{ email }`. Creates `HouseholdInvitation`, sends email. - **Add to `api/auth.py`**: - - **`POST /api/v1/invitations/accept`**: (Auth: JWT). Body `{ token }`. Validates token, adds user to household. + - **`POST /api/v1/invitations/accept`**: (Auth: JWT). Body `{ token }`. Validates token, adds user to household, and returns `{ status: "accepted", household: { id, name, slug } }`. ## 4. Actionable Implementation Steps @@ -280,7 +280,7 @@ Status summary: - Implemented: JWT auth v2 with refresh cookie; household domains and membership; invitations; full household scoping across recipes/meals/shopping; OpenAPI augmentation with bearerAuth and 403; RFC7807 preserved; tests green. - DTO alignment: Meals use MemberRef { id, displayName } (no Person in outward schema). MemberRef consolidated in `api/dtos.py`. Shopping DTOs/mappers consolidated in `api/shopping_models.py`. - DTO alignment (v2): Meals use MemberRef; Recipes include `createdById` and `createdBy` (MemberRef); Shopping `purchasedBy` is now a MemberRef on outward lists. -- Security: Password hashing now prefers Argon2 for new accounts with PBKDF2 verification fallback. +- Security: Password hashing now uses Argon2 exclusively (argon2-cffi). No PBKDF2 fallback remains. - New: Household members listing `GET /api/v1/households/{householdSlug}/members` returning [{ id, displayName, role }]. - Preserved: camelCase responses, `Page` semantics, `Location` headers on create, shopping storeName normalization ("home"). - Codebase cleanup: v2 routers are inlined as canonical modules; legacy `*_v2.py` files removed. v1 cookie auth and routers are not mounted. @@ -329,4 +329,3 @@ Final-state definition (what “done” looks like): - No legacy v1 endpoints mounted; no `*_v2.py` files in repo (done). - No `persons` package in codebase or responses; all tests ported from v1 and no tests are skipped. - OpenAPI reflects only JWT-secured, household-scoped endpoints. - diff --git a/main.py b/main.py index 3588714..d7c29c3 100644 --- a/main.py +++ b/main.py @@ -16,7 +16,6 @@ from api import ( households as households_router, ) from api.deps import ( - cookie_person as cookie_person, # noqa: F401 - re-exported for tests error_response as error_response, # noqa: F401 - re-exported for completeness get_db as get_db, # noqa: F401 - re-exported for tests dependency overrides ) diff --git a/openapi.json b/openapi.json index 4766dda..dffb188 100644 --- a/openapi.json +++ b/openapi.json @@ -229,8 +229,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "title": "Body" + "$ref": "#/components/schemas/AcceptInvitationBody" } } }, @@ -241,7 +240,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/AcceptInvitationResponse" + } } } }, @@ -1498,6 +1499,36 @@ }, "components": { "schemas": { + "AcceptInvitationBody": { + "properties": { + "token": { + "type": "string", + "title": "Token" + } + }, + "type": "object", + "required": [ + "token" + ], + "title": "AcceptInvitationBody" + }, + "AcceptInvitationResponse": { + "properties": { + "status": { + "type": "string", + "title": "Status", + "default": "accepted" + }, + "household": { + "$ref": "#/components/schemas/HouseholdResponse" + } + }, + "type": "object", + "required": [ + "household" + ], + "title": "AcceptInvitationResponse" + }, "CreateHouseholdBody": { "properties": { "name": { @@ -3054,4 +3085,4 @@ } } } -} \ No newline at end of file +}