Files
bambuddy/backend/app/models/long_lived_token.py
maziggy fcda728af4 feat(#1108): long-lived camera-stream tokens + fix(#1089) audit-pass tweaks
#1108 — Long-lived camera-stream tokens for HA / Frigate / kiosks. Camera-only
  V1, hard 365-day cap (no infinite tokens), pbkdf2 hashed at rest, plaintext
  shown to user exactly once on creation. New "Camera API Tokens" panel under
  Settings → API Keys with self-service create/revoke, styled confirm modal,
  admin "All users" view for leak triage. Auth path: /camera/stream tries the
  existing 60-min ephemeral table first, falls through to the long-lived path.
  Indexed lookup_prefix keeps verify O(1) per token.

  Permission audit: gated the existing API-keys-CRUD + Webhook docs + API
  Browser content behind api_keys:read so non-admins with camera:view land on
  the API Keys tab and see only the Camera Tokens panel they actually have
  permission to use. Grid layout collapses to single column for non-admins.

  Tests: 29 new backend (15 service + 14 integration covering create/list/
  revoke ownership rules, the auth fall-through, scope enforcement, prefix
  collisions) + 6 new frontend tests for the section UI including the new
  modal flow. All 77 backend tests + 21 frontend camera tests pass. Ruff
  clean (lint + format).

  Docs: README updated with fan-out + long-lived-token bullets. Wiki gets a
  new "Long-Lived Camera Tokens" section under features/camera.md (HA YAML
  example, security model, permission requirements, revoke flow). Website
  features.html gets the bullet under Camera Streaming.

  Also includes #1089 follow-up tweaks already merged in this branch:
  _stream_start_times.setdefault for accurate stream_uptime, subscribe()
  RuntimeError retry to close the grace-vs-subscribe race, atomic
  unsubscribe count via the iter_subscriber on_unsubscribe callback.
2026-04-25 10:44:37 +02:00

78 lines
3.6 KiB
Python

"""Long-lived camera-stream tokens (#1108).
Issue #1108: the existing 60-minute ``camera_stream`` ephemeral tokens are
too short-lived for home-automation integrations (Home Assistant cards,
Frigate, kiosks), which expect a token they can paste once and forget.
Why a separate table from ``AuthEphemeralToken``:
- These are user-owned, named, and revocable from the UI — different
lifecycle from ephemeral / single-use tokens.
- Hashed at rest (bcrypt). Ephemeral tokens are stored as raw strings
because their short TTL caps the impact of a DB read; a long-lived
token must survive a DB dump unscathed.
Why a separate table from ``api_keys``:
- ``api_keys`` is for webhook integrations and has no ``user_id`` FK
(the keys are global). Long-lived camera tokens are explicitly per-user
so the UI can show "your tokens" and so a leak can be traced to one user.
- Different permission shape (``api_keys`` carries can_queue / can_control
flags; long-lived tokens are pure read-only camera streaming).
V1 hard rules:
- ``expires_at`` is required (the issue's ``expire_in: 0 = never`` was
rejected — irrevocable infinite tokens are a footgun).
- 365-day max — enforced in the create route, not the DB, so a future
policy change is just a config bump.
- Scope column exists today ("camera_stream" is the only valid value)
to keep the door open for other long-lived scopes later without a
schema migration.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.core.database import Base
class LongLivedToken(Base):
"""Per-user, hashed-at-rest, revocable token for long-running camera viewers."""
__tablename__ = "long_lived_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# User-given label — "Home Assistant", "Kitchen kiosk", etc.
name: Mapped[str] = mapped_column(String(100), nullable=False)
# Public lookup prefix — first 8 chars of the secret part. Indexed so
# verify() can fetch one row instead of scanning + bcrypting all rows.
# Format: ``bblt_<8-char-prefix>_<32-char-secret>``.
lookup_prefix: Mapped[str] = mapped_column(String(8), nullable=False, index=True)
# bcrypt hash of the 32-char secret part. Never stored or returned in plaintext.
secret_hash: Mapped[str] = mapped_column(String(255), nullable=False)
# V1: only "camera_stream" is accepted. Column exists so future scopes
# don't need a schema migration.
scope: Mapped[str] = mapped_column(String(32), nullable=False, default="camera_stream")
# Required — no infinite tokens. Capped at 365 days at create time.
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
# Updated on successful verify (rate-limited to once per minute per token
# to avoid thrashing the DB on every MJPEG keep-alive read).
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Set when the user (or an admin) revokes; verify treats revoked == invalid.
revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
def __repr__(self) -> str:
return f"<LongLivedToken id={self.id} user_id={self.user_id} name={self.name!r} scope={self.scope}>"