Redesign phase B: users, sessions, first-run setup, admin panel
Real identity on top of phase A's schema: scrypt-hashed passwords (stdlib, no new deps -- parameters baked into each stored hash), server-side sessions (sha256 of the cookie value stored, 30-day rolling expiry), and per-session CSRF tokens enforced on every mutating session-authed request -- via X-CSRF-Token for the JSON API (a fetch() wrapper in base.html injects it, so the existing page scripts didn't need touching) and a hidden form field for the HTML forms. /setup runs once while no users exist: creates admin #1, links every existing frame to them (owner + controller), and inherits the migrated Immich creds onto their account -- per-user creds are now the primary source, with env vars still winning as the operator fallback. /login, /logout, /settings (display name, Immich creds, password change), and /admin (enroll users, reset passwords, link users to frames, close a frame's legacy-token window, delete) round out the pages, all in the existing template/card style. The legacy shared token stays accepted on browser routes so the deployed frame's on-panel manage QR keeps working until phase C swaps it for the limited manage page; token access renders without nav or CSRF shim and is exempt from CSRF (explicit credential, not an ambient cookie). Device routes untouched -- the legacy curl suite passes verbatim. Identity is provider-pluggable (identity_provider/provider_subject already modeled) so OIDC can land later without schema surgery.
This commit is contained in:
+37
-15
@@ -3,22 +3,30 @@ for the panel, and serves ESP32 frames ready-to-display images.
|
||||
|
||||
This module is assembly only -- routes live in app/routers/ (device.py
|
||||
for the firmware-facing /frame/* protocol, api.py for the web UI's
|
||||
/api/*), storage in SQLite via models.py/db.py, with migration.py
|
||||
importing a pre-database config.json deployment on first boot."""
|
||||
/api/*, pages.py for setup/login/settings/admin), storage in SQLite via
|
||||
models.py/db.py, with migration.py importing a pre-database config.json
|
||||
deployment on first boot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from . import migration
|
||||
from .auth import MANAGEMENT_TOKEN_COOKIE, browser_token_valid, management_token
|
||||
from .auth import (
|
||||
MANAGEMENT_TOKEN_COOKIE,
|
||||
browser_token_valid,
|
||||
current_session,
|
||||
current_user,
|
||||
management_token,
|
||||
users_exist,
|
||||
)
|
||||
from .db import SessionLocal
|
||||
from .quiet_hours import ALL_TIMEZONES
|
||||
from .routers import api, device
|
||||
from .routers import api, device, pages
|
||||
from .routers.common import default_frame, immich_creds
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -31,6 +39,7 @@ templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
app.include_router(device.router)
|
||||
app.include_router(api.router)
|
||||
app.include_router(pages.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -40,17 +49,28 @@ def health() -> dict:
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request):
|
||||
"""The web UI (Phase A: still the single-frame page, bound to the
|
||||
default frame). Handles the unauthorized case itself with a friendly
|
||||
token prompt rather than a bare 401, since this is the one route a
|
||||
human lands on with no token yet."""
|
||||
if not browser_token_valid(request):
|
||||
supplied = request.query_params.get("token")
|
||||
return templates.TemplateResponse(
|
||||
"token_prompt.html", {"request": request, "wrong": supplied is not None}
|
||||
)
|
||||
|
||||
"""The web UI (still the single-frame page until Phase D). Access:
|
||||
a user session (normal path once /setup has run), the legacy shared
|
||||
token (the deployed frame's manage QR embeds ?token= -- kept working
|
||||
until Phase C replaces it with the limited manage page), or -- only
|
||||
while no users exist AND no token is configured -- fully open, the
|
||||
original trusted-LAN default."""
|
||||
with SessionLocal() as db:
|
||||
user = current_user(request, db)
|
||||
session = current_session(request, db) if user else None
|
||||
legacy_ok = browser_token_valid(request)
|
||||
|
||||
if user is None and not legacy_ok:
|
||||
if not users_exist(db):
|
||||
if management_token():
|
||||
supplied = request.query_params.get("token")
|
||||
return templates.TemplateResponse(
|
||||
"token_prompt.html", {"request": request, "wrong": supplied is not None}
|
||||
)
|
||||
# Fresh install, nothing configured: open, but nudge setup.
|
||||
return RedirectResponse("/setup", status_code=303)
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
frame = default_frame(db)
|
||||
immich_url, _ = immich_creds(frame)
|
||||
response = templates.TemplateResponse(
|
||||
@@ -60,6 +80,8 @@ def index(request: Request):
|
||||
"cfg": frame,
|
||||
"immich_url": immich_url,
|
||||
"timezones": ALL_TIMEZONES,
|
||||
"user": user,
|
||||
"csrf_token": session.csrf_token if session else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user