Files
maziggy 258db95483 fix(overlay): authenticate the OBS overlay with a token when login is enabled (#2613)
The /overlay/{id} route renders without a login, but everything it draws is
auth-gated: printer status and name (PRINTERS_READ), one setting (SETTINGS_READ),
and the camera stream (a camera-stream token). A signed-in browser rides its JWT
from local storage; OBS is a fresh browser with no session, so the overlay stayed
blank whenever authentication was enabled. Cloudflare/remote access was never the
cause -- an incognito window fails identically.

Give the overlay a self-contained kiosk-token mode, mirroring the Cam Wall:

- New `overlay` long-lived-token scope, kept separate from `camwall`: the overlay
  names the printed file on screen, which a Cam Wall token is trusted never to
  expose, so folding it in would silently widen every existing wall token.
- New token-authed GET /printers/{id}/overlay-status returning exactly the fields
  the overlay draws and nothing else; added to the auth-middleware allowlist so it
  reaches its own RequireOverlayTokenIfAuthEnabled gate.
- StreamOverlayPage reads ?token= and, in that mode, authenticates its status and
  camera calls with the token and skips the WebSocket (the 2s poll is the feed).
  The logged-in path is unchanged.
- Token-mint UI (Settings > API Keys) offers the scope with a ready-made
  /overlay/{id}?token= URL copied once on creation.
2026-07-20 13:04:35 +02:00

244 lines
9.5 KiB
Python

"""Service layer for long-lived camera-stream tokens (#1108).
Token format: ``bblt_<8-char-prefix>_<32-char-secret>``.
- The full token is shown to the user **exactly once** at create time.
- ``lookup_prefix`` (the 8-char middle part) is indexed and used to cheaply
fetch the candidate row — at most one in practice — without scanning the
whole table on every request.
- ``secret_hash`` is a pbkdf2_sha256 hash of the full token (matching the
rest of the codebase's password hashing). Even a DB dump can't be replayed
against the camera endpoint.
- ``last_used_at`` is updated on successful verify, but rate-limited to once
per minute per token so an MJPEG keep-alive doesn't write to the DB on
every chunk.
- ``revoked_at`` set → verify returns False; admins or the owning user can
flip it.
Maximum lifetime is 365 days (issue #1108 explicitly rejected "infinite"
tokens — a leaked permanent token would be irrevocable footgun-by-design).
"""
from __future__ import annotations
import secrets
from collections.abc import Collection
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import get_password_hash, verify_password
from backend.app.models.long_lived_token import LongLivedToken
# Issue #1108 hard cap. Bump here if policy changes — UI default is shorter
# (90 days) and the create route enforces this ceiling.
MAX_TOKEN_LIFETIME_DAYS = 365
# Every scope is a separate grant, never implied by another. A token minted for
# one purpose must not silently widen when a later scope is added.
#
# camera_stream — the MJPEG stream / snapshot endpoints and nothing else
# (#1108). What a Home Assistant or Frigate card needs.
# camwall — those same streams *plus* the read-only tile metadata the
# Cam Wall draws: printer names and print state (#2531).
# Strictly wider than camera_stream, so it gets its own scope
# rather than quietly extending tokens already handed out.
# overlay — the streaming overlay (#2613): the camera stream plus the
# single-printer status the /overlay page draws, which unlike
# the Cam Wall *includes the print filename*. A distinct grant
# precisely because it reveals the part name a camwall token
# is trusted never to expose, so folding it into camwall would
# silently widen every wall token already handed out.
ALLOWED_SCOPES: frozenset[str] = frozenset({"camera_stream", "camwall", "overlay"})
# Scopes the camera stream / snapshot endpoints honour. A Cam Wall or overlay
# token has to be able to pull the video its own view is showing.
STREAM_SCOPES: tuple[str, ...] = ("camera_stream", "camwall", "overlay")
# Don't write to last_used_at more than once per minute per token. MJPEG
# streams call verify() at most once per fetch (the browser holds the
# connection open), but snapshots may rapid-fire — this caps DB churn.
_LAST_USED_DEBOUNCE = timedelta(minutes=1)
# Token format constants — kept in one place so format changes are localized.
_TOKEN_PREFIX = "bblt_"
_LOOKUP_LEN = 8
_SECRET_LEN = 32 # urlsafe characters → ~190 bits of entropy
@dataclass(frozen=True)
class CreatedToken:
"""Returned to the route on create. ``plaintext`` is shown to the user
exactly once and never persisted; only ``record`` survives in the DB.
"""
record: LongLivedToken
plaintext: str
def _generate_token_parts() -> tuple[str, str, str]:
"""Return ``(plaintext, lookup_prefix, hash_input)``.
``hash_input`` is the same string we hand to pbkdf2 so verify() can
produce a matching hash from the user-submitted token.
The prefix is hex on purpose — ``token_urlsafe`` can emit ``_`` which
would collide with the ``bblt_<prefix>_<secret>`` format separator and
break the parser. Hex is fine for a non-secret indexed lookup column;
the security comes from the 32-char ``token_urlsafe`` secret part.
"""
lookup_prefix = secrets.token_hex(_LOOKUP_LEN // 2) # 4 bytes → 8 hex chars
secret_part = secrets.token_urlsafe(48).replace("_", "").replace("-", "")[:_SECRET_LEN]
plaintext = f"{_TOKEN_PREFIX}{lookup_prefix}_{secret_part}"
return plaintext, lookup_prefix, plaintext
def _parse_token(token: str) -> tuple[str, str] | None:
"""Pull ``(lookup_prefix, full_token)`` from a submitted string.
Returns None if the format doesn't match — short-circuits the DB lookup
on garbage / wrong-format inputs.
"""
if not token.startswith(_TOKEN_PREFIX):
return None
rest = token[len(_TOKEN_PREFIX) :]
sep = rest.find("_")
if sep != _LOOKUP_LEN:
return None
lookup_prefix = rest[:_LOOKUP_LEN]
return lookup_prefix, token
def _is_expired(record: LongLivedToken, now: datetime) -> bool:
expires = record.expires_at
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
return expires <= now
async def create_token(
db: AsyncSession,
*,
user_id: int,
name: str,
expires_in_days: int,
scope: str = "camera_stream",
) -> CreatedToken:
"""Mint a new long-lived token. Caller is responsible for permission checks.
Raises ValueError if ``expires_in_days`` exceeds the policy cap or
``scope`` is not in ``ALLOWED_SCOPES``. The route translates these into
a 400 with the offending field.
"""
if scope not in ALLOWED_SCOPES:
raise ValueError(f"unsupported scope: {scope!r}")
if expires_in_days <= 0:
raise ValueError("expires_in_days must be positive (#1108: no infinite tokens)")
if expires_in_days > MAX_TOKEN_LIFETIME_DAYS:
raise ValueError(f"expires_in_days exceeds policy maximum of {MAX_TOKEN_LIFETIME_DAYS}")
name = name.strip()
if not name:
raise ValueError("name is required")
if len(name) > 100:
raise ValueError("name must be 100 chars or fewer")
plaintext, lookup_prefix, hash_input = _generate_token_parts()
now = datetime.now(timezone.utc)
record = LongLivedToken(
user_id=user_id,
name=name,
lookup_prefix=lookup_prefix,
secret_hash=get_password_hash(hash_input),
scope=scope,
expires_at=now + timedelta(days=expires_in_days),
)
db.add(record)
await db.commit()
await db.refresh(record)
return CreatedToken(record=record, plaintext=plaintext)
async def verify_token(
db: AsyncSession,
token: str,
*,
scope: str | Collection[str] = "camera_stream",
) -> LongLivedToken | None:
"""Validate a token. Returns the matching record on success, None otherwise.
``scope`` accepts a single scope or a collection of acceptable ones — the
stream endpoints pass ``STREAM_SCOPES`` because more than one scope may
legitimately reach them. The record must carry one of them; a token is
never accepted on the strength of a scope it does not hold.
The pbkdf2 verify is the slow step (intentional), so we pre-filter by the
indexed ``lookup_prefix`` to ensure the verify runs against at most one or
two candidate rows.
"""
parsed = _parse_token(token)
if parsed is None:
return None
lookup_prefix, full_token = parsed
scopes = (scope,) if isinstance(scope, str) else tuple(scope)
now = datetime.now(timezone.utc)
result = await db.execute(
select(LongLivedToken).where(
LongLivedToken.lookup_prefix == lookup_prefix,
LongLivedToken.scope.in_(scopes),
LongLivedToken.revoked_at.is_(None),
)
)
candidates = result.scalars().all()
for record in candidates:
if _is_expired(record, now):
continue
if not verify_password(full_token, record.secret_hash):
continue
# Record use, but rate-limit DB writes to keep MJPEG-keepalive cheap.
last = record.last_used_at
if last is None or _coerce_utc(last) + _LAST_USED_DEBOUNCE <= now:
record.last_used_at = now
await db.commit()
return record
return None
def _coerce_utc(dt: datetime) -> datetime:
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
async def list_user_tokens(db: AsyncSession, user_id: int) -> list[LongLivedToken]:
"""All non-revoked tokens for a user, newest first. Includes expired ones
(the UI shows them so the user can clean them up).
"""
result = await db.execute(
select(LongLivedToken)
.where(LongLivedToken.user_id == user_id, LongLivedToken.revoked_at.is_(None))
.order_by(LongLivedToken.created_at.desc())
)
return list(result.scalars().all())
async def list_all_tokens(db: AsyncSession) -> list[LongLivedToken]:
"""Admin view of every non-revoked token in the system, newest first."""
result = await db.execute(
select(LongLivedToken).where(LongLivedToken.revoked_at.is_(None)).order_by(LongLivedToken.created_at.desc())
)
return list(result.scalars().all())
async def revoke_token(db: AsyncSession, token_id: int) -> bool:
"""Mark a token revoked. Returns True if a row was updated, False if the
id didn't exist or was already revoked.
"""
result = await db.execute(select(LongLivedToken).where(LongLivedToken.id == token_id))
record = result.scalar_one_or_none()
if record is None or record.revoked_at is not None:
return False
record.revoked_at = datetime.now(timezone.utc)
await db.commit()
return True