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
+1 -1
View File
@@ -53,7 +53,7 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)):
value=cookie_value,
max_age=SESSION_MAX_AGE,
httponly=True,
secure=True,
secure=settings.SESSION_COOKIE_SECURE,
samesite="lax",
path="/",
)
+2
View File
@@ -29,6 +29,8 @@ class Settings(BaseSettings):
# Auth (R1-B+D)
ADMIN_TOKEN: str = ""
SESSION_PASSWORD: str = ""
SESSION_COOKIE_SECURE: bool = True
TRUSTED_NETWORK_AUTO_AUTH: bool = False
ADMIN_EMAIL: str = ""
APP_BASE_URL: str = "http://localhost"
+28 -1
View File
@@ -1,9 +1,11 @@
from fastapi import FastAPI, Depends
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from sqlalchemy import text
from app.database import get_db
from app.config import settings
from app.security import require_session
import logging
logging.basicConfig(level=settings.LOG_LEVEL)
@@ -19,6 +21,31 @@ app = FastAPI(
app.mount("/static", StaticFiles(directory="static"), name="static")
def _requires_session(path: str, method: str) -> bool:
if method == "OPTIONS" or not path.startswith("/api/"):
return False
if path.startswith("/api/auth/") or path.startswith("/api/admin/"):
return False
# Email approval links carry their own signed, single-use token.
if path.startswith("/api/meals/vote/"):
return False
return True
@app.middleware("http")
async def require_family_session(request: Request, call_next):
if _requires_session(request.url.path, request.method):
try:
require_session(request)
except HTTPException as exc:
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
headers=getattr(exc, "headers", None),
)
return await call_next(request)
@app.get("/health")
def health_check(db: Session = Depends(get_db)):
return {"status": "ok"}
+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"