long_lived_token.py 3.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
  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. # SQLite would give a freed id to the next row; see core/database AUTOINCREMENT_TABLES
  35. __table_args__ = {"sqlite_autoincrement": True}
  36. id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
  37. user_id: Mapped[int] = mapped_column(
  38. Integer,
  39. ForeignKey("users.id", ondelete="CASCADE"),
  40. nullable=False,
  41. index=True,
  42. )
  43. # User-given label — "Home Assistant", "Kitchen kiosk", etc.
  44. name: Mapped[str] = mapped_column(String(100), nullable=False)
  45. # Public lookup prefix — first 8 chars of the secret part. Indexed so
  46. # verify() can fetch one row instead of scanning + bcrypting all rows.
  47. # Format: ``bblt_<8-char-prefix>_<32-char-secret>``.
  48. lookup_prefix: Mapped[str] = mapped_column(String(8), nullable=False, index=True)
  49. # bcrypt hash of the 32-char secret part. Never stored or returned in plaintext.
  50. secret_hash: Mapped[str] = mapped_column(String(255), nullable=False)
  51. # V1: only "camera_stream" is accepted. Column exists so future scopes
  52. # don't need a schema migration.
  53. scope: Mapped[str] = mapped_column(String(32), nullable=False, default="camera_stream")
  54. # Required — no infinite tokens. Capped at 365 days at create time.
  55. expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
  56. # Updated on successful verify (rate-limited to once per minute per token
  57. # to avoid thrashing the DB on every MJPEG keep-alive read).
  58. last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
  59. # Set when the user (or an admin) revokes; verify treats revoked == invalid.
  60. revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
  61. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
  62. def __repr__(self) -> str:
  63. return f"<LongLivedToken id={self.id} user_id={self.user_id} name={self.name!r} scope={self.scope}>"