long_lived_tokens.py 10 KB

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