feat(auth): harden sessions + HA Ingress support
CI / backend (pytest + alembic) (push) Has been cancelled
CI / frontend (build) (push) Has been cancelled

- backend: settings SESSION_COOKIE_SECURE + TRUSTED_NETWORK_AUTO_AUTH,
  require_session uses secrets.compare_digest and respects trusted-network
  opt-in, main.py adds require_family_session middleware gating all /api/
  routes except auth/admin/email-vote-token paths
- docker-compose: pass SESSION_COOKIE_SECURE + TRUSTED_NETWORK_AUTO_AUTH
  through to backend + scheduler (fixes env-file changes not reaching runtime)
- frontend: Ingress path-prefix support (APP_BASE_PATH, BrowserRouter basename,
  vite base './'), Login redirect honors APP_BASE_PATH
- nginx: no-cache headers on root + /assets/
- docs: Home Assistant Ingress install/troubleshooting + plan file
- tests: test_auth expects 401 on no-session GET

Defaults: SESSION_COOKIE_SECURE=false, TRUSTED_NETWORK_AUTO_AUTH=true
(HA is the auth boundary; MealPlanner must not be port-forwarded directly).
This commit is contained in:
2026-06-30 16:11:33 -07:00
parent 7f5757094e
commit 7838c49721
14 changed files with 148 additions and 38 deletions
+22 -28
View File
@@ -1,17 +1,6 @@
"""
Auth dependencies for the MealPlanner backend.
"""Auth dependencies for the MealPlanner backend."""
Two flavors:
- ``require_admin`` — bearer token for ``/api/admin/*`` routes; token compared
to ``settings.ADMIN_TOKEN`` (must be set in env).
- ``require_session`` — auto-returns the first family_profile_id (no login
required). This app runs on a private home network so auth is disabled
for family-facing routes. Kept as a dependency so admin/token endpoints
can be re-enabled later by restoring cookie logic.
The per-voter approval-token flow on meal items is intentionally NOT covered
here — it has its own short-lived single-use tokens elsewhere.
"""
import secrets
from fastapi import HTTPException, Request, status
from itsdangerous import TimestampSigner
@@ -35,7 +24,7 @@ def require_admin(request: Request) -> None:
detail="Admin auth not configured",
)
auth = request.headers.get(bearer_header, "")
if not auth.startswith("Bearer ") or auth[7:] != expected:
if not auth.startswith("Bearer ") or not secrets.compare_digest(auth[7:], expected):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid admin token",
@@ -52,13 +41,7 @@ def issue_session(family_profile_id: str) -> str:
def require_session(request: Request) -> str:
"""Auto-authenticate: return the first family_profile_id from the DB.
No cookie or password needed — this app runs on a private home network.
If no FamilyProfile exists yet, return \"bootstrap\" so the app can
initialise itself on first run.
"""
# 1. Try to read the signed cookie (backward-compat with existing sessions)
"""Require a signed session cookie, with explicit LAN auto-auth opt-in."""
raw = request.cookies.get(SESSION_COOKIE)
if raw:
try:
@@ -66,13 +49,24 @@ def require_session(request: Request) -> str:
_signer().unsign(raw.encode(), max_age=SESSION_MAX_AGE).decode()
)
except Exception:
pass # fall through to auto-auth
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired session",
)
# 2. Auto-auth: grab the first family profile from the DB
db = next(get_db())
profile = db.query(FamilyProfile).first()
if profile:
return str(profile.id)
if not settings.TRUSTED_NETWORK_AUTO_AUTH:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session required",
)
db_gen = get_db()
db = next(db_gen)
try:
profile = db.query(FamilyProfile).first()
if profile:
return str(profile.id)
finally:
db_gen.close()
# 3. Bootstrap hatch — no profile yet, return a sentinel value
return "bootstrap"