diff --git a/.env.example b/.env.example index c6ea4ff..f6309de 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 6737e43..fee4cb5 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -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="/", ) diff --git a/backend/app/config.py b/backend/app/config.py index 0d8ed6c..f3b62b1 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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" diff --git a/backend/app/main.py b/backend/app/main.py index a90f5b7..40cbcec 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/security.py b/backend/app/security.py index 7f97048..f7387d3 100644 --- a/backend/app/security.py +++ b/backend/app/security.py @@ -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" diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 00d4cb6..e53da1f 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -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 # --------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index 0a0689e..c369434 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/docs/home-assistant-ingress.md b/docs/home-assistant-ingress.md new file mode 100644 index 0000000..691a49d --- /dev/null +++ b/docs/home-assistant-ingress.md @@ -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= +SESSION_PASSWORD= +SECRET_KEY= +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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 614779d..c328cbb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( - +
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 689826b..00aa257 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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, diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index eecc495..dab6912 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -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 { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 91a0315..231a11a 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], + base: './', server: { port: 3000, proxy: { diff --git a/home-assistant-ingress-addon.md b/home-assistant-ingress-addon.md new file mode 100644 index 0000000..f108bdc --- /dev/null +++ b/home-assistant-ingress-addon.md @@ -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. diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 49dfc01..89c2f3f 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -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; } } }