Public Access
- 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).
73 lines
2.1 KiB
Python
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"
|