long_lived_tokens.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  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 dataclasses import dataclass
  21. from datetime import datetime, timedelta, timezone
  22. from sqlalchemy import select
  23. from sqlalchemy.ext.asyncio import AsyncSession
  24. from backend.app.core.auth import get_password_hash, verify_password
  25. from backend.app.models.long_lived_token import LongLivedToken
  26. # Issue #1108 hard cap. Bump here if policy changes — UI default is shorter
  27. # (90 days) and the create route enforces this ceiling.
  28. MAX_TOKEN_LIFETIME_DAYS = 365
  29. # Only V1 scope. Adding "snapshot" or "control" later means adding a value
  30. # to this tuple and an `if scope == ...` branch in the route, no schema work.
  31. ALLOWED_SCOPES: frozenset[str] = frozenset({"camera_stream"})
  32. # Don't write to last_used_at more than once per minute per token. MJPEG
  33. # streams call verify() at most once per fetch (the browser holds the
  34. # connection open), but snapshots may rapid-fire — this caps DB churn.
  35. _LAST_USED_DEBOUNCE = timedelta(minutes=1)
  36. # Token format constants — kept in one place so format changes are localized.
  37. _TOKEN_PREFIX = "bblt_"
  38. _LOOKUP_LEN = 8
  39. _SECRET_LEN = 32 # urlsafe characters → ~190 bits of entropy
  40. @dataclass(frozen=True)
  41. class CreatedToken:
  42. """Returned to the route on create. ``plaintext`` is shown to the user
  43. exactly once and never persisted; only ``record`` survives in the DB.
  44. """
  45. record: LongLivedToken
  46. plaintext: str
  47. def _generate_token_parts() -> tuple[str, str, str]:
  48. """Return ``(plaintext, lookup_prefix, hash_input)``.
  49. ``hash_input`` is the same string we hand to pbkdf2 so verify() can
  50. produce a matching hash from the user-submitted token.
  51. The prefix is hex on purpose — ``token_urlsafe`` can emit ``_`` which
  52. would collide with the ``bblt_<prefix>_<secret>`` format separator and
  53. break the parser. Hex is fine for a non-secret indexed lookup column;
  54. the security comes from the 32-char ``token_urlsafe`` secret part.
  55. """
  56. lookup_prefix = secrets.token_hex(_LOOKUP_LEN // 2) # 4 bytes → 8 hex chars
  57. secret_part = secrets.token_urlsafe(48).replace("_", "").replace("-", "")[:_SECRET_LEN]
  58. plaintext = f"{_TOKEN_PREFIX}{lookup_prefix}_{secret_part}"
  59. return plaintext, lookup_prefix, plaintext
  60. def _parse_token(token: str) -> tuple[str, str] | None:
  61. """Pull ``(lookup_prefix, full_token)`` from a submitted string.
  62. Returns None if the format doesn't match — short-circuits the DB lookup
  63. on garbage / wrong-format inputs.
  64. """
  65. if not token.startswith(_TOKEN_PREFIX):
  66. return None
  67. rest = token[len(_TOKEN_PREFIX) :]
  68. sep = rest.find("_")
  69. if sep != _LOOKUP_LEN:
  70. return None
  71. lookup_prefix = rest[:_LOOKUP_LEN]
  72. return lookup_prefix, token
  73. def _is_expired(record: LongLivedToken, now: datetime) -> bool:
  74. expires = record.expires_at
  75. if expires.tzinfo is None:
  76. expires = expires.replace(tzinfo=timezone.utc)
  77. return expires <= now
  78. async def create_token(
  79. db: AsyncSession,
  80. *,
  81. user_id: int,
  82. name: str,
  83. expires_in_days: int,
  84. scope: str = "camera_stream",
  85. ) -> CreatedToken:
  86. """Mint a new long-lived token. Caller is responsible for permission checks.
  87. Raises ValueError if ``expires_in_days`` exceeds the policy cap or
  88. ``scope`` is not in ``ALLOWED_SCOPES``. The route translates these into
  89. a 400 with the offending field.
  90. """
  91. if scope not in ALLOWED_SCOPES:
  92. raise ValueError(f"unsupported scope: {scope!r}")
  93. if expires_in_days <= 0:
  94. raise ValueError("expires_in_days must be positive (#1108: no infinite tokens)")
  95. if expires_in_days > MAX_TOKEN_LIFETIME_DAYS:
  96. raise ValueError(f"expires_in_days exceeds policy maximum of {MAX_TOKEN_LIFETIME_DAYS}")
  97. name = name.strip()
  98. if not name:
  99. raise ValueError("name is required")
  100. if len(name) > 100:
  101. raise ValueError("name must be 100 chars or fewer")
  102. plaintext, lookup_prefix, hash_input = _generate_token_parts()
  103. now = datetime.now(timezone.utc)
  104. record = LongLivedToken(
  105. user_id=user_id,
  106. name=name,
  107. lookup_prefix=lookup_prefix,
  108. secret_hash=get_password_hash(hash_input),
  109. scope=scope,
  110. expires_at=now + timedelta(days=expires_in_days),
  111. )
  112. db.add(record)
  113. await db.commit()
  114. await db.refresh(record)
  115. return CreatedToken(record=record, plaintext=plaintext)
  116. async def verify_token(db: AsyncSession, token: str, *, scope: str = "camera_stream") -> LongLivedToken | None:
  117. """Validate a token. Returns the matching record on success, None otherwise.
  118. The bcrypt-style verify is the slow step (intentional — pbkdf2 by design),
  119. so we pre-filter by the indexed ``lookup_prefix`` to ensure the verify
  120. runs against at most one or two candidate rows.
  121. """
  122. parsed = _parse_token(token)
  123. if parsed is None:
  124. return None
  125. lookup_prefix, full_token = parsed
  126. now = datetime.now(timezone.utc)
  127. result = await db.execute(
  128. select(LongLivedToken).where(
  129. LongLivedToken.lookup_prefix == lookup_prefix,
  130. LongLivedToken.scope == scope,
  131. LongLivedToken.revoked_at.is_(None),
  132. )
  133. )
  134. candidates = result.scalars().all()
  135. for record in candidates:
  136. if _is_expired(record, now):
  137. continue
  138. if not verify_password(full_token, record.secret_hash):
  139. continue
  140. # Record use, but rate-limit DB writes to keep MJPEG-keepalive cheap.
  141. last = record.last_used_at
  142. if last is None or _coerce_utc(last) + _LAST_USED_DEBOUNCE <= now:
  143. record.last_used_at = now
  144. await db.commit()
  145. return record
  146. return None
  147. def _coerce_utc(dt: datetime) -> datetime:
  148. return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
  149. async def list_user_tokens(db: AsyncSession, user_id: int) -> list[LongLivedToken]:
  150. """All non-revoked tokens for a user, newest first. Includes expired ones
  151. (the UI shows them so the user can clean them up).
  152. """
  153. result = await db.execute(
  154. select(LongLivedToken)
  155. .where(LongLivedToken.user_id == user_id, LongLivedToken.revoked_at.is_(None))
  156. .order_by(LongLivedToken.created_at.desc())
  157. )
  158. return list(result.scalars().all())
  159. async def list_all_tokens(db: AsyncSession) -> list[LongLivedToken]:
  160. """Admin view of every non-revoked token in the system, newest first."""
  161. result = await db.execute(
  162. select(LongLivedToken).where(LongLivedToken.revoked_at.is_(None)).order_by(LongLivedToken.created_at.desc())
  163. )
  164. return list(result.scalars().all())
  165. async def revoke_token(db: AsyncSession, token_id: int) -> bool:
  166. """Mark a token revoked. Returns True if a row was updated, False if the
  167. id didn't exist or was already revoked.
  168. """
  169. result = await db.execute(select(LongLivedToken).where(LongLivedToken.id == token_id))
  170. record = result.scalar_one_or_none()
  171. if record is None or record.revoked_at is not None:
  172. return False
  173. record.revoked_at = datetime.now(timezone.utc)
  174. await db.commit()
  175. return True