Public Access
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).
This commit is contained in:
@@ -33,3 +33,5 @@ SECRET_KEY=change-me-to-a-random-secret-key
|
||||
# Auth
|
||||
ADMIN_TOKEN=change-me-to-a-random-admin-token
|
||||
SESSION_PASSWORD=change-me-to-the-family-shared-password
|
||||
SESSION_COOKIE_SECURE=false
|
||||
TRUSTED_NETWORK_AUTO_AUTH=true
|
||||
|
||||
@@ -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="/",
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -19,6 +19,7 @@ import pytest
|
||||
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
|
||||
os.environ.setdefault("SESSION_PASSWORD", "test-family-password")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-do-not-use-in-prod")
|
||||
os.environ.setdefault("SESSION_COOKIE_SECURE", "false")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -27,6 +28,7 @@ def _reload_settings(monkeypatch):
|
||||
monkeypatch.setenv("ADMIN_TOKEN", "test-admin-token")
|
||||
monkeypatch.setenv("SESSION_PASSWORD", "test-family-password")
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key-do-not-use-in-prod")
|
||||
monkeypatch.setenv("SESSION_COOKIE_SECURE", "false")
|
||||
# Re-instantiate the singleton so dependents pick up env.
|
||||
from app import config as app_config
|
||||
|
||||
@@ -76,11 +78,10 @@ def test_session_required_for_mutation(client):
|
||||
|
||||
|
||||
@pytest.mark.requires_postgres
|
||||
def test_session_open_for_reads(client):
|
||||
"""GET /api/profile is NOT auth-gated (reads stay open)."""
|
||||
def test_session_required_for_reads(client):
|
||||
"""GET /api/profile requires a session for non-LAN exposure."""
|
||||
r = client.get("/api/profile")
|
||||
# Either 200 (profile exists) or 404 (no profile yet) — never 401.
|
||||
assert r.status_code in (200, 404), r.text
|
||||
assert r.status_code == 401, r.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -25,6 +25,8 @@ services:
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- ADMIN_TOKEN=${ADMIN_TOKEN}
|
||||
- SESSION_PASSWORD=${SESSION_PASSWORD}
|
||||
- SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-false}
|
||||
- TRUSTED_NETWORK_AUTO_AUTH=${TRUSTED_NETWORK_AUTO_AUTH:-true}
|
||||
- EMAIL_BACKEND=${EMAIL_BACKEND:-console}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-}
|
||||
- APP_BASE_URL=${APP_BASE_URL:-http://localhost}
|
||||
@@ -63,6 +65,8 @@ services:
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- ADMIN_TOKEN=${ADMIN_TOKEN}
|
||||
- SESSION_PASSWORD=${SESSION_PASSWORD}
|
||||
- SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-false}
|
||||
- TRUSTED_NETWORK_AUTO_AUTH=${TRUSTED_NETWORK_AUTO_AUTH:-true}
|
||||
- EMAIL_BACKEND=${EMAIL_BACKEND:-console}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-}
|
||||
- APP_BASE_URL=${APP_BASE_URL:-http://localhost}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Home Assistant Ingress
|
||||
|
||||
MealPlanner can be exposed through Home Assistant by installing the `mealplanner-ingress` add-on. The add-on is an authenticated Ingress proxy to the existing MealPlanner Docker deployment; it does not run the database, backend, or frontend itself.
|
||||
|
||||
## Install
|
||||
|
||||
- In Home Assistant, go to Settings -> Add-ons -> Add-on Store -> Repositories.
|
||||
- Add this repository URL: `https://git.research.bike/admin/Meal-Planner.git`.
|
||||
- Install `MealPlanner Ingress` from the add-on store.
|
||||
- Set `upstream_url` to the LAN URL for the existing MealPlanner nginx service, for example `http://192.168.1.54:8082`.
|
||||
- Start the add-on and open the `MealPlanner` sidebar item.
|
||||
|
||||
## Required MealPlanner Env
|
||||
|
||||
Set these in MealPlanner's `.env` before exposing it through Home Assistant:
|
||||
|
||||
```env
|
||||
ADMIN_TOKEN=<random-admin-token>
|
||||
SESSION_PASSWORD=<family-shared-password>
|
||||
SECRET_KEY=<random-secret>
|
||||
SESSION_COOKIE_SECURE=false
|
||||
TRUSTED_NETWORK_AUTO_AUTH=true
|
||||
```
|
||||
|
||||
Home Assistant is the authentication boundary in this setup. `TRUSTED_NETWORK_AUTO_AUTH=true` removes the extra MealPlanner password prompt, so do not port-forward MealPlanner directly.
|
||||
|
||||
## Network Model
|
||||
|
||||
- Public Internet -> Home Assistant auth/MFA -> Ingress -> MealPlanner LAN URL.
|
||||
- Do not port-forward MealPlanner directly.
|
||||
- Keep the existing MealPlanner compose stack bound to the LAN only.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- `could not read Username`: the Git repository is not anonymously cloneable from Home Assistant. Make the repository public, or use a separate public add-on repository.
|
||||
- `not a valid app repository`: Home Assistant cloned the repository, but did not find valid add-on metadata. Confirm `repository.yaml` exists at the repository root and `mealplanner-ingress/config.yaml` exists on the default branch.
|
||||
- Short/clipped display: do not use an embedded WebURL card for this app. Use the `MealPlanner Ingress` add-on sidebar item so Home Assistant proxies the full UI.
|
||||
@@ -6,6 +6,7 @@ import { OnboardingTour, useOnboarding } from './components/OnboardingTour'
|
||||
import { showApiError } from './lib/toast'
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||
import { requestFocusSearch } from './hooks/useFocusSearch'
|
||||
import { APP_BASE_PATH } from './api'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import MealDetail from './pages/MealDetail'
|
||||
import Pantry from './pages/Pantry'
|
||||
@@ -81,7 +82,7 @@ function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter basename={APP_BASE_PATH || undefined}>
|
||||
<GlobalShortcuts />
|
||||
<div className="min-h-screen bg-surface-50">
|
||||
<Navigation />
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '/api'
|
||||
export function getIngressBasePath() {
|
||||
const parts = window.location.pathname.split('/').filter(Boolean)
|
||||
if (parts[0] === 'api' && parts[1] === 'hassio_ingress' && parts[2]) {
|
||||
return `/${parts.slice(0, 3).join('/')}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export const APP_BASE_PATH = getIngressBasePath()
|
||||
const API_BASE = import.meta.env.VITE_API_URL || `${APP_BASE_PATH}/api`
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Lock, ArrowRight } from 'lucide-react'
|
||||
import { mealPlannerApi } from '../api'
|
||||
import { APP_BASE_PATH, mealPlannerApi } from '../api'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Card, CardBody } from '../components/ui/Card'
|
||||
import { Input } from '../components/ui/Input'
|
||||
@@ -16,7 +16,7 @@ export default function Login() {
|
||||
setLoading(true)
|
||||
try {
|
||||
await mealPlannerApi.auth.login(password)
|
||||
window.location.href = '/'
|
||||
window.location.href = `${APP_BASE_PATH}/`
|
||||
} catch {
|
||||
setError('Incorrect password. Try again.')
|
||||
} finally {
|
||||
|
||||
@@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Home Assistant Ingress Add-on
|
||||
|
||||
## Goal
|
||||
Expose MealPlanner through Home Assistant Ingress while keeping MealPlanner off the public Internet.
|
||||
|
||||
## Tasks
|
||||
- [x] Add HA add-on metadata and nginx proxy wrapper -> Verify: `mealplanner-ingress/config.yaml`, `Dockerfile`, `run.sh` exist.
|
||||
- [x] Make frontend path-prefix aware for Ingress -> Verify: Vite base, router basename, and API base derive from `/api/hassio_ingress/...`.
|
||||
- [x] Re-enable session enforcement for family API routes -> Verify: missing session returns 401 unless `TRUSTED_NETWORK_AUTO_AUTH=true`.
|
||||
- [x] Document install and env settings -> Verify: `docs/home-assistant-ingress.md` exists.
|
||||
- [x] Run backend/frontend focused checks.
|
||||
- [x] Push add-on repository metadata -> Verify: anonymous shallow clone contains `repository.yaml` and `mealplanner-ingress/config.yaml`.
|
||||
|
||||
## Done When
|
||||
- [x] Add-on config validates enough to build in Home Assistant.
|
||||
- [x] Frontend builds.
|
||||
- [x] Backend auth tests/import checks pass.
|
||||
- [x] Home Assistant accepts the repository.
|
||||
@@ -34,6 +34,20 @@ http {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header Expires "0" always;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
set $frontend_upstream http://frontend:80;
|
||||
proxy_pass $frontend_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header Expires "0" always;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user