"""Authentication: password hashing, user sessions + CSRF, the legacy shared-token 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. - 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 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 legacy shared 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: """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 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), 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: """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. New firmware sends ?id=<12-hex-mac>&token=. Deployed legacy firmware sends only ?token= (or nothing, on an open server) -- those requests resolve to the unique legacy_token_enabled frame for as long as that migration window stays open. The first id-bearing request arriving with legacy credentials while the legacy frame has no device_id yet BINDS that id to it -- that's the moment the deployed frame comes back up on new firmware after its OTA, and it must not register as a second frame. """ device_id = request.query_params.get("id", "").strip().lower() token = request.query_params.get("token", "") legacy = management_token() legacy_ok = not legacy or token == legacy if device_id: frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first() if frame is None: legacy_frame = db.scalars( select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712 ).first() if legacy_frame is not None and legacy_frame.device_id is None and legacy_ok: legacy_frame.device_id = device_id frame = legacy_frame logger.info("Bound device id %s to legacy frame #%d", device_id, frame.id) else: 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) if not token_ok: if frame.legacy_token_enabled and legacy_ok: pass elif not frame.device_token_ack: # 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. pass else: raise HTTPException(401, "Missing or invalid access token") else: if not legacy_ok: raise HTTPException(401, "Missing or invalid access token") frame = db.scalars( select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712 ).first() if frame is None: # Nothing to resolve a no-id request to. migration.py always # creates frame #1 at startup, so this only happens if it was # deleted -- treat like an unknown device. raise HTTPException(401, "No frame accepts legacy credentials") frame.last_seen = time.time() db.commit() return frame