long_lived_token.py 3.6 KB

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