"""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=.""" 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