feat(auth, invitations): switch to Argon2-only password hashing; return household on invitation accept; remove legacy cookie-based export; update OpenAPI wording and spec

This commit is contained in:
jableader 2025-11-01 19:20:08 +11:00
parent 32a7e6226f
commit 9504c9cb34
6 changed files with 73 additions and 59 deletions

View file

@ -1,9 +1,6 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import hashlib
import hmac
import os
from typing import Optional from typing import Optional
import aiosqlite import aiosqlite
@ -15,16 +12,13 @@ from common import ApiModel
from security import JwtConfig, create_jwt from security import JwtConfig, create_jwt
from settings import settings from settings import settings
from users import repository as users_db 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 users.models import User
from argon2 import PasswordHasher
# Argon2-only password hashing
_ph: PasswordHasher = PasswordHasher()
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@ -45,42 +39,15 @@ class TokenResponse(ApiModel):
user: User user: User
PBKDF2_ALG = "pbkdf2_sha256"
PBKDF2_ITER = 390000 # kept for verifying older hashes
SALT_BYTES = 16
def _hash_pw(pw: str) -> str: def _hash_pw(pw: str) -> str:
"""Hash a password. """Hash a password using Argon2."""
return _ph.hash(pw)
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()}"
def _verify_pw(pw: str, stored: str) -> bool: def _verify_pw(pw: str, stored: str) -> bool:
"""Verify password against either Argon2 or PBKDF2 stored hashes.""" """Verify password using Argon2-only 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
try: try:
alg, iter_s, salt_b64, hash_b64 = stored.split("$", 3) return _ph.verify(stored, pw)
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)
except Exception: except Exception:
return False return False

View file

@ -152,17 +152,24 @@ async def create_invitation(
return error_response(request, 400, "Unable to 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) # Accept invitation (mounted on root router via main.py)
@router.post("/invitations/accept") @router.post("/invitations/accept", response_model=AcceptInvitationResponse)
async def accept_invitation( async def accept_invitation(
request: Request, request: Request,
body: dict, body: AcceptInvitationBody,
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
): ):
token = body.get("token") token = body.token
if not token:
return error_response(request, 400, "Token required")
# Lookup invitation # Lookup invitation
async with conn.execute( async with conn.execute(
"SELECT id, household_id, status FROM HouseholdInvitation WHERE token = ?", "SELECT id, household_id, status FROM HouseholdInvitation WHERE token = ?",
@ -186,4 +193,15 @@ async def accept_invitation(
"UPDATE HouseholdInvitation SET status = 'accepted' WHERE id = ?", "UPDATE HouseholdInvitation SET status = 'accepted' WHERE id = ?",
(inv_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]),
)

View file

@ -6,7 +6,7 @@ from fastapi import FastAPI
def extend_with_problem_and_cookie_auth(app: FastAPI) -> None: 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 This mutates the app's OpenAPI generation in-place while delegating to the
original generator for the base schema. 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 # Bearer (JWT) auth for v2
security_schemes.setdefault( security_schemes.setdefault(

View file

@ -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. 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. 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`**: - **Add to `api/households.py`**:
- **`POST /api/v1/households/{householdSlug}/invitations`**: (Auth: JWT, household membership). Body `{ email }`. Creates `HouseholdInvitation`, sends email. - **`POST /api/v1/households/{householdSlug}/invitations`**: (Auth: JWT, household membership). Body `{ email }`. Creates `HouseholdInvitation`, sends email.
- **Add to `api/auth.py`**: - **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 ## 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. - 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: 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. - 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 }]. - New: Household members listing `GET /api/v1/households/{householdSlug}/members` returning [{ id, displayName, role }].
- Preserved: camelCase responses, `Page<T>` semantics, `Location` headers on create, shopping storeName normalization ("home"). - Preserved: camelCase responses, `Page<T>` 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. - 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 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. - 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. - OpenAPI reflects only JWT-secured, household-scoped endpoints.

View file

@ -16,7 +16,6 @@ from api import (
households as households_router, households as households_router,
) )
from api.deps import ( 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 error_response as error_response, # noqa: F401 - re-exported for completeness
get_db as get_db, # noqa: F401 - re-exported for tests dependency overrides get_db as get_db, # noqa: F401 - re-exported for tests dependency overrides
) )

View file

@ -229,8 +229,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"type": "object", "$ref": "#/components/schemas/AcceptInvitationBody"
"title": "Body"
} }
} }
}, },
@ -241,7 +240,9 @@
"description": "Successful Response", "description": "Successful Response",
"content": { "content": {
"application/json": { "application/json": {
"schema": {} "schema": {
"$ref": "#/components/schemas/AcceptInvitationResponse"
}
} }
} }
}, },
@ -1498,6 +1499,36 @@
}, },
"components": { "components": {
"schemas": { "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": { "CreateHouseholdBody": {
"properties": { "properties": {
"name": { "name": {