long_lived_tokens.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. """Service layer for long-lived camera-stream tokens (#1108).
  2. Token format: ``bblt_<8-char-prefix>_<32-char-secret>``.
  3. - The full token is shown to the user **exactly once** at create time.
  4. - ``lookup_prefix`` (the 8-char middle part) is indexed and used to cheaply
  5. fetch the candidate row — at most one in practice — without scanning the
  6. whole table on every request.
  7. - ``secret_hash`` is a pbkdf2_sha256 hash of the full token (matching the
  8. rest of the codebase's password hashing). Even a DB dump can't be replayed
  9. against the camera endpoint.
  10. - ``last_used_at`` is updated on successful verify, but rate-limited to once
  11. per minute per token so an MJPEG keep-alive doesn't write to the DB on
  12. every chunk.
  13. - ``revoked_at`` set → verify returns False; admins or the owning user can
  14. flip it.
  15. Maximum lifetime is 365 days (issue #1108 explicitly rejected "infinite"
  16. tokens — a leaked permanent token would be irrevocable footgun-by-design).
  17. """
  18. from __future__ import annotations
  19. import secrets
  20. from collections.abc import Collection
  21. from dataclasses import dataclass
  22. from datetime import datetime, timedelta, timezone
  23. from sqlalchemy import select
  24. from sqlalchemy.ext.asyncio import AsyncSession
  25. from backend.app.core.auth import get_password_hash, verify_password
  26. from backend.app.models.long_lived_token import LongLivedToken
  27. # Issue #1108 hard cap. Bump here if policy changes — UI default is shorter
  28. # (90 days) and the create route enforces this ceiling.
  29. MAX_TOKEN_LIFETIME_DAYS = 365
  30. # Every scope is a separate grant, never implied by another. A token minted for
  31. # one purpose must not silently widen when a later scope is added.
  32. #
  33. # camera_stream — the MJPEG stream / snapshot endpoints and nothing else
  34. # (#1108). What a Home Assistant or Frigate card needs.
  35. # camwall — those same streams *plus* the read-only tile metadata the
  36. # Cam Wall draws: printer names and print state (#2531).
  37. # Strictly wider than camera_stream, so it gets its own scope
  38. # rather than quietly extending tokens already handed out.
  39. # overlay — the streaming overlay (#2613): the camera stream plus the
  40. # single-printer status the /overlay page draws, which unlike
  41. # the Cam Wall *includes the print filename*. A distinct grant
  42. # precisely because it reveals the part name a camwall token
  43. # is trusted never to expose, so folding it into camwall would
  44. # silently widen every wall token already handed out.
  45. ALLOWED_SCOPES: frozenset[str] = frozenset({"camera_stream", "camwall", "overlay"})
  46. # Scopes the camera stream / snapshot endpoints honour. A Cam Wall or overlay
  47. # token has to be able to pull the video its own view is showing.
  48. STREAM_SCOPES: tuple[str, ...] = ("camera_stream", "camwall", "overlay")
  49. # Don't write to last_used_at more than once per minute per token. MJPEG
  50. # streams call verify() at most once per fetch (the browser holds the
  51. # connection open), but snapshots may rapid-fire — this caps DB churn.
  52. _LAST_USED_DEBOUNCE = timedelta(minutes=1)
  53. # Token format constants — kept in one place so format changes are localized.
  54. _TOKEN_PREFIX = "bblt_"
  55. _LOOKUP_LEN = 8
  56. _SECRET_LEN = 32 # urlsafe characters → ~190 bits of entropy
  57. @dataclass(frozen=True)
  58. class CreatedToken:
  59. """Returned to the route on create. ``plaintext`` is shown to the user
  60. exactly once and never persisted; only ``record`` survives in the DB.
  61. """
  62. record: LongLivedToken
  63. plaintext: str
  64. def _generate_token_parts() -> tuple[str, str, str]:
  65. """Return ``(plaintext, lookup_prefix, hash_input)``.
  66. ``hash_input`` is the same string we hand to pbkdf2 so verify() can
  67. produce a matching hash from the user-submitted token.
  68. The prefix is hex on purpose — ``token_urlsafe`` can emit ``_`` which
  69. would collide with the ``bblt_<prefix>_<secret>`` format separator and
  70. break the parser. Hex is fine for a non-secret indexed lookup column;
  71. the security comes from the 32-char ``token_urlsafe`` secret part.
  72. """
  73. lookup_prefix = secrets.token_hex(_LOOKUP_LEN // 2) # 4 bytes → 8 hex chars
  74. secret_part = secrets.token_urlsafe(48).replace("_", "").replace("-", "")[:_SECRET_LEN]
  75. plaintext = f"{_TOKEN_PREFIX}{lookup_prefix}_{secret_part}"
  76. return plaintext, lookup_prefix, plaintext
  77. def _parse_token(token: str) -> tuple[str, str] | None:
  78. """Pull ``(lookup_prefix, full_token)`` from a submitted string.
  79. Returns None if the format doesn't match — short-circuits the DB lookup
  80. on garbage / wrong-format inputs.
  81. """
  82. if not token.startswith(_TOKEN_PREFIX):
  83. return None
  84. rest = token[len(_TOKEN_PREFIX) :]
  85. sep = rest.find("_")
  86. if sep != _LOOKUP_LEN:
  87. return None
  88. lookup_prefix = rest[:_LOOKUP_LEN]
  89. return lookup_prefix, token
  90. def _is_expired(record: LongLivedToken, now: datetime) -> bool:
  91. expires = record.expires_at
  92. if expires.tzinfo is None:
  93. expires = expires.replace(tzinfo=timezone.utc)
  94. return expires <= now
  95. async def create_token(
  96. db: AsyncSession,
  97. *,
  98. user_id: int,
  99. name: str,
  100. expires_in_days: int,
  101. scope: str = "camera_stream",
  102. ) -> CreatedToken:
  103. """Mint a new long-lived token. Caller is responsible for permission checks.
  104. Raises ValueError if ``expires_in_days`` exceeds the policy cap or
  105. ``scope`` is not in ``ALLOWED_SCOPES``. The route translates these into
  106. a 400 with the offending field.
  107. """
  108. if scope not in ALLOWED_SCOPES:
  109. raise ValueError(f"unsupported scope: {scope!r}")
  110. if expires_in_days <= 0:
  111. raise ValueError("expires_in_days must be positive (#1108: no infinite tokens)")
  112. if expires_in_days > MAX_TOKEN_LIFETIME_DAYS:
  113. raise ValueError(f"expires_in_days exceeds policy maximum of {MAX_TOKEN_LIFETIME_DAYS}")
  114. name = name.strip()
  115. if not name:
  116. raise ValueError("name is required")
  117. if len(name) > 100:
  118. raise ValueError("name must be 100 chars or fewer")
  119. plaintext, lookup_prefix, hash_input = _generate_token_parts()
  120. now = datetime.now(timezone.utc)
  121. record = LongLivedToken(
  122. user_id=user_id,
  123. name=name,
  124. lookup_prefix=lookup_prefix,
  125. secret_hash=get_password_hash(hash_input),
  126. scope=scope,
  127. expires_at=now + timedelta(days=expires_in_days),
  128. )
  129. db.add(record)
  130. await db.commit()
  131. await db.refresh(record)
  132. return CreatedToken(record=record, plaintext=plaintext)
  133. async def verify_token(
  134. db: AsyncSession,
  135. token: str,
  136. *,
  137. scope: str | Collection[str] = "camera_stream",
  138. ) -> LongLivedToken | None:
  139. """Validate a token. Returns the matching record on success, None otherwise.
  140. ``scope`` accepts a single scope or a collection of acceptable ones — the
  141. stream endpoints pass ``STREAM_SCOPES`` because more than one scope may
  142. legitimately reach them. The record must carry one of them; a token is
  143. never accepted on the strength of a scope it does not hold.
  144. The pbkdf2 verify is the slow step (intentional), so we pre-filter by the
  145. indexed ``lookup_prefix`` to ensure the verify runs against at most one or
  146. two candidate rows.
  147. """
  148. parsed = _parse_token(token)
  149. if parsed is None:
  150. return None
  151. lookup_prefix, full_token = parsed
  152. scopes = (scope,) if isinstance(scope, str) else tuple(scope)
  153. now = datetime.now(timezone.utc)
  154. result = await db.execute(
  155. select(LongLivedToken).where(
  156. LongLivedToken.lookup_prefix == lookup_prefix,
  157. LongLivedToken.scope.in_(scopes),
  158. LongLivedToken.revoked_at.is_(None),
  159. )
  160. )
  161. candidates = result.scalars().all()
  162. for record in candidates:
  163. if _is_expired(record, now):
  164. continue
  165. if not verify_password(full_token, record.secret_hash):
  166. continue
  167. # Record use, but rate-limit DB writes to keep MJPEG-keepalive cheap.
  168. last = record.last_used_at
  169. if last is None or _coerce_utc(last) + _LAST_USED_DEBOUNCE <= now:
  170. record.last_used_at = now
  171. await db.commit()
  172. return record
  173. return None
  174. def _coerce_utc(dt: datetime) -> datetime:
  175. return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
  176. async def list_user_tokens(db: AsyncSession, user_id: int) -> list[LongLivedToken]:
  177. """All non-revoked tokens for a user, newest first. Includes expired ones
  178. (the UI shows them so the user can clean them up).
  179. """
  180. result = await db.execute(
  181. select(LongLivedToken)
  182. .where(LongLivedToken.user_id == user_id, LongLivedToken.revoked_at.is_(None))
  183. .order_by(LongLivedToken.created_at.desc())
  184. )
  185. return list(result.scalars().all())
  186. async def list_all_tokens(db: AsyncSession) -> list[LongLivedToken]:
  187. """Admin view of every non-revoked token in the system, newest first."""
  188. result = await db.execute(
  189. select(LongLivedToken).where(LongLivedToken.revoked_at.is_(None)).order_by(LongLivedToken.created_at.desc())
  190. )
  191. return list(result.scalars().all())
  192. async def revoke_token(db: AsyncSession, token_id: int) -> bool:
  193. """Mark a token revoked. Returns True if a row was updated, False if the
  194. id didn't exist or was already revoked.
  195. """
  196. result = await db.execute(select(LongLivedToken).where(LongLivedToken.id == token_id))
  197. record = result.scalar_one_or_none()
  198. if record is None or record.revoked_at is not None:
  199. return False
  200. record.revoked_at = datetime.now(timezone.utc)
  201. await db.commit()
  202. return True