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:
2026-07-21 23:28:14 -04:00
parent 9fbbb8ed2b
commit 1e8d6803ac
9 changed files with 863 additions and 39 deletions
+163 -21
View File
@@ -1,17 +1,24 @@
"""Authentication dependencies.
"""Authentication: password hashing, user sessions + CSRF, the legacy
shared-token gate, and device resolution.
Phase A scope: browser routes keep the legacy shared-token gate
(MANAGEMENT_TOKEN env var -- empty means open on a trusted LAN, exactly
the old behavior), and device routes move to require_device, which
already implements the full multi-frame resolution: per-frame device
tokens, self-registration by device id, the legacy-token migration
window, and pending-claim attachment. User sessions arrive in Phase B.
Three independent credential classes:
- User sessions (cookie "session", server-side sessions table, per-
session CSRF token required on mutating requests) -- humans.
- The legacy shared MANAGEMENT_TOKEN (env-only). Still accepted on
browser routes so the deployed frame's on-panel manage QR (which
embeds ?token=) keeps working until Phase C replaces it with the
limited /m/ page; CSRF doesn't apply to it (it's explicit per-request
credential, not an ambient cookie a cross-site request could ride).
- Device credentials (?id= + ?token=, see require_device below).
"""
from __future__ import annotations
import hashlib
import hmac
import logging
import os
import secrets
import time
from fastapi import Depends, HTTPException, Request
@@ -20,11 +27,133 @@ from sqlalchemy.orm import Session
from .db import get_db
from .migration import new_device_token, new_manage_token
from .models import Frame, PendingClaim, UserFrame
from .models import Frame, PendingClaim, User, UserFrame, UserSession
logger = logging.getLogger(__name__)
MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
SESSION_COOKIE = "session"
SESSION_LIFETIME_S = 30 * 86400
SESSION_REFRESH_BELOW_S = 15 * 86400 # rolling expiry: extend when under this much left
# stdlib scrypt instead of a passlib/argon2 dependency: zero new deps,
# and the parameters are baked into each stored hash so they can be
# raised later without invalidating existing ones.
_SCRYPT_N = 16384
_SCRYPT_R = 8
_SCRYPT_P = 1
def hash_password(password: str) -> str:
salt = os.urandom(16)
digest = hashlib.scrypt(
password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P
)
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt.hex()}${digest.hex()}"
def verify_password(password: str, stored: str) -> bool:
try:
scheme, n, r, p, salt_hex, hash_hex = stored.split("$")
if scheme != "scrypt":
return False
digest = hashlib.scrypt(
password.encode(), salt=bytes.fromhex(salt_hex), n=int(n), r=int(r), p=int(p)
)
return hmac.compare_digest(digest.hex(), hash_hex)
except (ValueError, AttributeError):
return False
def _hash_session_token(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()
def create_session(db: Session, user: User) -> tuple[str, UserSession]:
"""Returns (cookie_value, session row). Only the sha256 of the cookie
value is stored, so a leaked database doesn't yield usable cookies."""
cookie_value = secrets.token_urlsafe(32)
now = time.time()
session = UserSession(
token_hash=_hash_session_token(cookie_value),
user_id=user.id,
csrf_token=secrets.token_urlsafe(32),
created_at=now,
expires_at=now + SESSION_LIFETIME_S,
)
db.add(session)
# Opportunistic prune -- no background scheduler in this project.
for stale in db.scalars(select(UserSession).where(UserSession.expires_at < now)):
db.delete(stale)
db.commit()
return cookie_value, session
def destroy_session(db: Session, request: Request) -> None:
cookie_value = request.cookies.get(SESSION_COOKIE)
if not cookie_value:
return
session = db.scalars(
select(UserSession).where(UserSession.token_hash == _hash_session_token(cookie_value))
).first()
if session is not None:
db.delete(session)
db.commit()
def current_session(request: Request, db: Session) -> UserSession | None:
cookie_value = request.cookies.get(SESSION_COOKIE)
if not cookie_value:
return None
session = db.scalars(
select(UserSession).where(UserSession.token_hash == _hash_session_token(cookie_value))
).first()
now = time.time()
if session is None or session.expires_at < now:
return None
if session.expires_at - now < SESSION_REFRESH_BELOW_S:
session.expires_at = now + SESSION_LIFETIME_S
db.commit()
return session
def current_user(request: Request, db: Session) -> User | None:
session = current_session(request, db)
if session is None:
return None
return db.get(User, session.user_id)
def users_exist(db: Session) -> bool:
return db.scalars(select(User).limit(1)).first() is not None
def _csrf_ok(request: Request, session: UserSession) -> bool:
supplied = request.headers.get("X-CSRF-Token") or ""
return hmac.compare_digest(supplied, session.csrf_token)
def require_user_api(request: Request, db: Session = Depends(get_db)) -> User:
"""JSON-API dependency: a logged-in user, with CSRF enforced on
mutating methods (the session rides an ambient cookie; the CSRF
header is what proves the request came from our own JS, not a
cross-site form)."""
session = current_session(request, db)
if session is None:
raise HTTPException(401, "Not logged in")
if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session):
raise HTTPException(403, "Missing or invalid CSRF token")
user = db.get(User, session.user_id)
if user is None:
raise HTTPException(401, "Not logged in")
return user
def require_admin_api(request: Request, db: Session = Depends(get_db)) -> User:
user = require_user_api(request, db)
if not user.is_admin:
raise HTTPException(403, "Admin only")
return user
def management_token() -> str:
@@ -34,25 +163,38 @@ def management_token() -> str:
def browser_token_valid(request: Request) -> bool:
"""No MANAGEMENT_TOKEN configured means the web UI stays open on a
trusted LAN, matching this project's original default. Once one's
set, a request is authorized by either a ?token= query param or the
cookie index() sets after a valid query-param hit (so the web UI's
own fetch()/<img> calls, which carry no query string, stay authorized
for the rest of that browsing visit)."""
"""The legacy shared-token check. No MANAGEMENT_TOKEN configured means
token-holders don't exist -- but unlike Phase A this no longer means
"open": once users exist, sessions are the primary gate and this is
only the compatibility path for the deployed frame's manage QR
(?token=) until Phase C. Empty token => not valid (sessions rule)."""
token = management_token()
if not token:
return True
return False
supplied = request.query_params.get("token") or request.cookies.get(MANAGEMENT_TOKEN_COOKIE)
return supplied is not None and supplied == token
def require_access_token(request: Request) -> None:
"""Dependency for the browser-facing /api/* routes (Phase A only --
replaced by real sessions in Phase B). index() handles the
unauthorized case itself with a friendlier HTML prompt."""
if not browser_token_valid(request):
raise HTTPException(401, "Missing or invalid access token")
def require_browser(request: Request, db: Session = Depends(get_db)) -> User | None:
"""Dependency for the web UI's /api/* routes: a real user session
(CSRF-checked on mutations, returns the User), or the legacy shared
token (returns None -- token bearers act as an anonymous operator,
exactly the pre-user model). While NO users exist yet (fresh install
or freshly migrated, before /setup has been run) the API stays open
if no MANAGEMENT_TOKEN is set -- the Phase A/legacy behavior --
since there's nobody to log in as yet."""
session = current_session(request, db)
if session is not None:
if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session):
raise HTTPException(403, "Missing or invalid CSRF token")
user = db.get(User, session.user_id)
if user is not None:
return user
if browser_token_valid(request):
return None
if not users_exist(db) and not management_token():
return None
raise HTTPException(401, "Not logged in")
def _register_frame(db: Session, device_id: str) -> Frame: