Files
Meal-Planner/backend/app/security.py
T
admin 7838c49721
CI / backend (pytest + alembic) (push) Has been cancelled
CI / frontend (build) (push) Has been cancelled
feat(auth): harden sessions + HA Ingress support
- 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).
2026-06-30 16:11:33 -07:00

73 lines
2.1 KiB
Python

"""Auth dependencies for the MealPlanner backend."""
import secrets
from fastapi import HTTPException, Request, status
from itsdangerous import TimestampSigner
from app.config import settings
from app.database import get_db
from app.models import FamilyProfile
bearer_header = "Authorization"
SESSION_COOKIE = "mp_session"
SESSION_MAX_AGE = 60 * 60 * 24 * 30 # 30 days
def require_admin(request: Request) -> None:
"""Enforce a shared bearer token. 401 on bad/missing token, 503 if unset."""
expected = settings.ADMIN_TOKEN
if not expected:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Admin auth not configured",
)
auth = request.headers.get(bearer_header, "")
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",
)
def _signer() -> TimestampSigner:
return TimestampSigner(settings.SECRET_KEY)
def issue_session(family_profile_id: str) -> str:
"""Sign the family_profile_id and return the cookie value."""
return _signer().sign(family_profile_id.encode()).decode()
def require_session(request: Request) -> str:
"""Require a signed session cookie, with explicit LAN auto-auth opt-in."""
raw = request.cookies.get(SESSION_COOKIE)
if raw:
try:
return (
_signer().unsign(raw.encode(), max_age=SESSION_MAX_AGE).decode()
)
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired session",
)
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()
return "bootstrap"