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:
+163
-21
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user