Firmware build check / build-check (push) Successful in 5m37s
Build and release firmware / build-and-release (push) Successful in 5m36s
Build and push server image / test (push) Successful in 1m37s
Build and push server image / build-and-push (push) Successful in 4m18s
Build and push server image / deploy (push) Failing after 1m20s
Server: migration 41 drops the pre-widget-system Frame columns (mode/album_id/current_asset_id/queue/calendar_*/whiteboard_*, etc) docs/widgets.md flagged as the deliberately-deferred Phase 6 cleanup, with a raw-SQL backfill safety net for any frame that still somehow lacks a Widget. Also drops legacy_token_enabled and the shared MANAGEMENT_TOKEN fallback it gated in require_device/require_browser -- the per-frame manage_token/device_token flow (and the /m/ page) fully supersede it now; MANAGEMENT_TOKEN's only remaining role is the optional pre-setup claim gate. Confirmed with the maintainer that the deployed frame is already off the shared token before removing the server-side fallback. Firmware: the captive portal's "Access Token" field and its NVS/ build_url plumbing only ever mattered for pointing new firmware at an old pre-multi-frame server -- gone along with the server-side fallback it fed. Version bump to publish the change.
358 lines
13 KiB
Python
358 lines
13 KiB
Python
"""Authentication: password hashing, user sessions + CSRF, the pre-setup
|
|
claim gate, and device resolution.
|
|
|
|
Three independent credential classes:
|
|
- User sessions (cookie "session", server-side sessions table, per-
|
|
session CSRF token required on mutating requests) -- humans.
|
|
- MANAGEMENT_TOKEN (env-only, optional). Only meaningful before any user
|
|
account exists yet (fresh install, or freshly migrated, before
|
|
/setup has been run): if set, it gates who gets to be the one to run
|
|
/setup and claim the first admin account; once a user exists, sessions
|
|
are the only way in. Not a standing bearer credential -- the on-panel
|
|
manage QR now embeds a frame's own per-frame manage_token (/m/, see
|
|
routers/manage.py) rather than this shared one; CSRF doesn't apply to
|
|
it either way (it's an 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
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .db import get_db
|
|
from .migration import new_device_token, new_manage_token
|
|
from .models import Frame, PasswordResetToken, PendingClaim, ServerSettings, 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
|
|
PASSWORD_RESET_TOKEN_LIFETIME_S = 3600
|
|
|
|
# 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 get_server_settings(db: Session) -> ServerSettings:
|
|
"""The SMTP config singleton -- migration.py guarantees row id=1
|
|
exists (created at startup if missing), so this is never None."""
|
|
settings = db.get(ServerSettings, 1)
|
|
assert settings is not None
|
|
return settings
|
|
|
|
|
|
def create_password_reset_token(db: Session, user: User) -> str:
|
|
token = secrets.token_urlsafe(32)
|
|
now = time.time()
|
|
# Opportunistic prune, same pattern as sessions/pending claims.
|
|
for stale in db.scalars(select(PasswordResetToken).where(PasswordResetToken.expires_at < now)):
|
|
db.delete(stale)
|
|
db.add(PasswordResetToken(
|
|
token=token, user_id=user.id, created_at=now,
|
|
expires_at=now + PASSWORD_RESET_TOKEN_LIFETIME_S,
|
|
))
|
|
db.commit()
|
|
return token
|
|
|
|
|
|
def consume_password_reset_token(db: Session, token: str) -> User | None:
|
|
"""Looks up the token and, if valid, deletes it (single-use) and
|
|
returns the user it was issued for. None for an unknown/expired
|
|
token -- callers show a generic error either way."""
|
|
row = db.get(PasswordResetToken, token)
|
|
if row is None or row.expires_at < time.time():
|
|
return None
|
|
user = db.get(User, row.user_id)
|
|
db.delete(row)
|
|
db.commit()
|
|
return user
|
|
|
|
|
|
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 user_frames(db: Session, user: User) -> list[Frame]:
|
|
"""The frames this user sees in their sidebar: linked ones, or all of
|
|
them for an admin (admins are the household operators -- they see
|
|
unclaimed/new frames too, that's how those get adopted)."""
|
|
if user.is_admin:
|
|
return list(db.scalars(select(Frame).order_by(Frame.id)))
|
|
return list(
|
|
db.scalars(
|
|
select(Frame)
|
|
.join(UserFrame, UserFrame.frame_id == Frame.id)
|
|
.where(UserFrame.user_id == user.id)
|
|
.order_by(Frame.id)
|
|
)
|
|
)
|
|
|
|
|
|
def can_view_frame(db: Session, user: User, frame: Frame) -> bool:
|
|
return user.is_admin or db.get(UserFrame, (user.id, frame.id)) is not None
|
|
|
|
|
|
def require_frame_view(
|
|
frame_id: int, request: Request, db: Session = Depends(get_db)
|
|
) -> Frame:
|
|
"""JSON-API dependency: a logged-in user who is linked to this frame
|
|
(or an admin). 404 -- not 403 -- for frames outside the user's view,
|
|
so the API doesn't confirm which frame ids exist."""
|
|
user = require_user_api(request, db)
|
|
frame = db.get(Frame, frame_id)
|
|
if frame is None or not can_view_frame(db, user, frame):
|
|
raise HTTPException(404, "No such frame")
|
|
return frame
|
|
|
|
|
|
def require_frame_control(
|
|
frame_id: int, request: Request, db: Session = Depends(get_db)
|
|
) -> Frame:
|
|
"""View access plus the soft control lock: only the user currently
|
|
holding control may mutate settings/queue. The 409 payload names the
|
|
holder so the UI can offer "take control" instead of a dead end.
|
|
Physical device buttons don't go through this -- device actions are
|
|
device actions."""
|
|
user = require_user_api(request, db)
|
|
frame = db.get(Frame, frame_id)
|
|
if frame is None or not can_view_frame(db, user, frame):
|
|
raise HTTPException(404, "No such frame")
|
|
if frame.controlled_by_user_id != user.id:
|
|
holder = frame.controlled_by
|
|
raise HTTPException(
|
|
409,
|
|
{
|
|
"error": "not_controller",
|
|
"holder": (holder.display_name or holder.username) if holder else None,
|
|
},
|
|
)
|
|
return frame
|
|
|
|
|
|
def management_token() -> str:
|
|
"""The pre-setup claim-gate secret. Env-only, never stored -- same as
|
|
the old server, where the env var overrode anything on disk on every
|
|
load."""
|
|
return os.environ.get("MANAGEMENT_TOKEN", "")
|
|
|
|
|
|
def browser_token_valid(request: Request) -> bool:
|
|
"""Whether the request carries the current MANAGEMENT_TOKEN, via
|
|
query param or cookie. Only meaningful pre-setup (see require_browser
|
|
below) -- empty configured token => not valid (nothing to match)."""
|
|
token = management_token()
|
|
if not token:
|
|
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_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). While NO users exist
|
|
yet (fresh install, or freshly migrated, before /setup has been run)
|
|
the API instead stays open if no MANAGEMENT_TOKEN is set, or opens
|
|
to whoever supplies it if one is -- there's nobody to log in as yet,
|
|
so this is purely the claim gate for who gets to run /setup. Once a
|
|
user exists, only a session gets in."""
|
|
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 not users_exist(db):
|
|
if not management_token() or browser_token_valid(request):
|
|
return None
|
|
raise HTTPException(401, "Not logged in")
|
|
|
|
|
|
def _register_frame(db: Session, device_id: str) -> Frame:
|
|
"""A device id we've never seen: self-register it as an unclaimed
|
|
frame (this fires from ANY /frame/* route -- the wake cycle hits
|
|
/frame/image before /frame/config). If a user already submitted a
|
|
claim for this id (they beat the device to the server after
|
|
provisioning), attach it now."""
|
|
frame = Frame(
|
|
name=f"Frame {device_id[-6:]}",
|
|
device_id=device_id,
|
|
device_token=new_device_token(),
|
|
manage_token=new_manage_token(),
|
|
created_at=time.time(),
|
|
)
|
|
db.add(frame)
|
|
db.flush()
|
|
|
|
now = time.time()
|
|
# Opportunistically prune expired claims while we're here.
|
|
for stale in db.scalars(select(PendingClaim).where(PendingClaim.expires_at < now)):
|
|
db.delete(stale)
|
|
|
|
pending = db.get(PendingClaim, device_id)
|
|
if pending is not None and pending.expires_at >= now:
|
|
frame.owner_user_id = pending.user_id
|
|
frame.claimed_at = now
|
|
db.add(UserFrame(user_id=pending.user_id, frame_id=frame.id))
|
|
db.delete(pending)
|
|
logger.info("Frame %s self-registered and attached pending claim by user %d",
|
|
device_id, pending.user_id)
|
|
else:
|
|
logger.info("Frame %s self-registered (unclaimed)", device_id)
|
|
return frame
|
|
|
|
|
|
def require_device(request: Request, db: Session = Depends(get_db)) -> Frame:
|
|
"""Resolves and authenticates the frame behind a /frame/* request.
|
|
Firmware sends ?id=<12-hex-mac>&token=<per-frame device token>."""
|
|
device_id = request.query_params.get("id", "").strip().lower()
|
|
token = request.query_params.get("token", "")
|
|
|
|
if not device_id:
|
|
raise HTTPException(401, "Missing device id")
|
|
|
|
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
|
|
if frame is None:
|
|
frame = _register_frame(db, device_id)
|
|
else:
|
|
token_ok = bool(token) and token == frame.device_token
|
|
if token_ok and not frame.device_token_ack:
|
|
frame.device_token_ack = True
|
|
logger.info("Frame #%d acknowledged its device token", frame.id)
|
|
elif not token_ok and frame.device_token_ack:
|
|
raise HTTPException(401, "Missing or invalid access token")
|
|
# else: handshake window -- the device registered but hasn't
|
|
# received its token yet (the wake cycle fetches the image
|
|
# BEFORE polling /frame/config, where the token is delivered) --
|
|
# the id stays the credential, same trust level as the open
|
|
# registration that created the row. Closes permanently on the
|
|
# first authenticated request.
|
|
|
|
frame.last_seen = time.time()
|
|
db.commit()
|
|
return frame
|