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:
parent
32a7e6226f
commit
9504c9cb34
6 changed files with 73 additions and 59 deletions
51
api/auth.py
51
api/auth.py
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
1
main.py
1
main.py
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
39
openapi.json
39
openapi.json
|
|
@ -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": {
|
||||||
|
|
@ -3054,4 +3085,4 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue