Sfoglia il codice sorgente

fix(camera): an open camera view stops once the viewer may no longer watch

A live camera stream checked its token once and then ran for hours.
After a session ended (password change or reset, 2FA removal,
deactivation, restore), an API key was disabled or expired, a Cam Wall
or overlay token was revoked, a group change took the printer away, or
authentication was turned on, an open view kept sending video. The
stream now records who opened it and re-checks every 30 seconds that
they may still watch that printer, ending the stream if not. A user's
stream is measured against the moment their token was minted, so a
session ending while the stream opens still ends it.
maziggy 1 settimana fa
parent
commit
5f8c60a646

+ 1 - 0
CHANGELOG.md

@@ -162,6 +162,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **An open camera view stops once the viewer may no longer watch** — A live camera stream checked its link once, when it started, and then ran for as long as the tab stayed open. After a password change or reset, removing someone's two-factor authentication, deactivating a user, a backup restore, disabling or expiring an API key, revoking a Cam Wall or overlay token, a group change that takes the printer away, or turning authentication on, a view already open kept showing live video. Streams now check every 30 seconds that whoever opened them may still watch that printer, and end if not. A view that is still allowed keeps running, including past the hour its link is valid for.
 - **A tab whose live connection was closed by the server now recovers instead of freezing** — When the server closes the live-update connection because of sign-in (your session ended, authentication was just turned on, or a temporary check failed), the page used to stop updating and stay on screen until reloaded. It now re-checks your sign-in: a tab whose session ended goes to the login page, and one that is still signed in reconnects. While its connection keeps being refused or can't be made it waits longer between attempts, up to five minutes when the server closes an accepted connection again and up to 30 seconds otherwise, instead of retrying every three seconds.
 - **Signing a user out everywhere now also closes their open dashboards** — A dashboard's live connection is checked only when it connects. Changing your password, a password reset link, an admin removing someone's two-factor authentication, deactivating a user and restoring a backup all make earlier sign-ins invalid, but a dashboard already open on the account, possibly someone else's, kept receiving live updates until it disconnected, and the link it connected with stayed valid for an hour to connect again. Those live dashboard connections are now closed and their links removed; your own tab reconnects after signing in again. Turning authentication on likewise closes the dashboards opened while it was off, which kept every printer, and disabling an API key, or one expiring, closes the dashboards it opened.
 - **A deleted API key's open connections and tokens no longer pass to the next key** — SQLite gives a deleted key's id to the next key created. Live dashboards opened with the key stayed connected, and the camera and WebSocket links it created stayed valid for up to an hour; both were tied to the key by id and took the new key's printers. Deleting a key, or a user with keys, now closes those connections and removes the links, and links of keys deleted before are removed at start.

+ 24 - 3
backend/app/api/routes/camera.py

@@ -21,8 +21,10 @@ from backend.app.core.auth import (
     RequireCameraStreamTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     RequirePrinterPermissionIfAuthEnabled,
+    StreamOpener,
     create_camera_stream_token,
     current_api_key_if_present,
+    stream_opener_still_allowed,
 )
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -952,12 +954,31 @@ async def create_stream_token(
     }
 
 
+# How often an open stream checks that its opener may still watch: a live view
+# runs for hours on a token checked once, and a session ending, a key disabled
+# or a Cam Wall token revoked must stop it.
+STREAM_RECHECK_SECONDS = 30.0
+
+
+async def _while_allowed(frames, opener: StreamOpener, printer_id: int):
+    """Pass the stream's chunks on until its opener may no longer watch."""
+    next_check = time.monotonic() + STREAM_RECHECK_SECONDS
+    async with contextlib.aclosing(frames) as chunks:
+        async for chunk in chunks:
+            if time.monotonic() >= next_check:
+                if not await stream_opener_still_allowed(opener, printer_id):
+                    logger.info("Camera stream for printer %s ended: its opener may no longer watch", printer_id)
+                    return
+                next_check = time.monotonic() + STREAM_RECHECK_SECONDS
+            yield chunk
+
+
 @router.get("/{printer_id}/camera/stream")
 async def camera_stream(
     printer_id: int,
     request: Request,
     fps: int = 10,
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    opener: StreamOpener = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Stream live video from printer camera as MJPEG.
 
@@ -1100,7 +1121,7 @@ async def camera_stream(
                 logger.info("External camera stream ended for printer %s", printer_id)
 
         return StreamingResponse(
-            external_stream_wrapper(),
+            _while_allowed(external_stream_wrapper(), opener, printer_id),
             media_type="multipart/x-mixed-replace; boundary=frame",
             headers={
                 "Cache-Control": "no-cache, no-store, must-revalidate",
@@ -1197,7 +1218,7 @@ async def camera_stream(
                 yield chunk
 
     return StreamingResponse(
-        _generate(),
+        _while_allowed(_generate(), opener, printer_id),
         media_type="multipart/x-mixed-replace; boundary=frame",
         headers={
             "Cache-Control": "no-cache, no-store, must-revalidate",

+ 114 - 14
backend/app/core/auth.py

@@ -5,7 +5,7 @@ import os
 import secrets
 import time
 from contextvars import ContextVar
-from dataclasses import dataclass
+from dataclasses import dataclass, field
 from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
@@ -32,6 +32,7 @@ from backend.app.models.api_key import APIKey
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
+from backend.app.utils.local_time import utcnow_naive
 
 logger = logging.getLogger(__name__)
 
@@ -1013,14 +1014,35 @@ async def principal_printer_scope(db: AsyncSession, username: str | None, api_ke
     return _NO_PRINTERS
 
 
-async def verify_camera_stream_token(token: str) -> PrinterScope | None:
-    """Verify a camera stream token (reusable -- does not consume it).
+@dataclass(frozen=True)
+class StreamOpener:
+    """Who opened a camera stream, and on what authority; re-checked while it runs.
+
+    A live view runs for hours on a token checked once. Its opener is a user
+    (a 60-minute token minted with their session), an API key, a long-lived
+    token (Cam Wall, overlay, Home Assistant), or nobody while auth was off.
+    ``started_at`` is when the user's token was minted, not when the stream
+    opened: a session that ended in between (its token checked just before)
+    must still end the stream.
+    """
 
-    Returns the printer scope of whoever minted it (#1727), or None when the
-    token is invalid. Tries the ephemeral 60-minute token first (the common,
-    browser-bound case) and falls through to long-lived tokens (#1108) for HA
-    / kiosk integrations that paste a token once and expect it to keep
-    working for days; those carry their owner's scope.
+    auth_on: bool
+    username: str | None = None
+    api_key_id: int | None = None
+    api_key_prefix: str | None = None
+    long_lived_id: int | None = None
+    long_lived_user_id: int | None = None
+    long_lived_prefix: str | None = None
+    started_at: datetime = field(default_factory=utcnow_naive)
+
+
+async def camera_stream_access(token: str) -> tuple[PrinterScope, StreamOpener] | None:
+    """Verify a camera stream token: the minter's printer scope and who it is.
+
+    Tries the ephemeral 60-minute token first (the common, browser-bound case)
+    and falls through to long-lived tokens (#1108) for HA / kiosk integrations
+    that paste a token once and expect it to keep working for days; those
+    carry their owner's scope. None when the token is invalid.
     """
     now = datetime.now(timezone.utc)
     async with async_session() as db:
@@ -1033,7 +1055,16 @@ async def verify_camera_stream_token(token: str) -> PrinterScope | None:
         )
         row = result.scalar_one_or_none()
         if row is not None:
-            return await principal_printer_scope(db, row.username, row.api_key_id)
+            scope = await principal_printer_scope(db, row.username, row.api_key_id)
+            prefix = await usable_api_key_prefix(db, row.api_key_id) if row.api_key_id is not None else None
+            minted = row.expires_at.replace(tzinfo=None) - timedelta(minutes=CAMERA_STREAM_TOKEN_EXPIRE_MINUTES)
+            return scope, StreamOpener(
+                auth_on=True,
+                username=row.username or None,
+                api_key_id=row.api_key_id,
+                api_key_prefix=prefix,
+                started_at=minted,
+            )
 
         # Long-lived path. Imported lazily so the auth module stays importable
         # at startup before the long_lived_tokens model is registered.
@@ -1042,7 +1073,74 @@ async def verify_camera_stream_token(token: str) -> PrinterScope | None:
         record = await verify_long_lived(db, token, scope=STREAM_SCOPES)
         if record is None:
             return None
-        return await resolve_user_id_printer_scope(db, record.user_id)
+        scope = await resolve_user_id_printer_scope(db, record.user_id)
+        return scope, StreamOpener(
+            auth_on=True,
+            long_lived_id=record.id,
+            long_lived_user_id=record.user_id,
+            long_lived_prefix=record.lookup_prefix,
+        )
+
+
+async def verify_camera_stream_token(token: str) -> PrinterScope | None:
+    """Verify a camera stream token (reusable -- does not consume it).
+
+    Returns the printer scope of whoever minted it (#1727), or None when the
+    token is invalid. See camera_stream_access.
+    """
+    access = await camera_stream_access(token)
+    return access[0] if access is not None else None
+
+
+async def stream_opener_still_allowed(opener: StreamOpener, printer_id: int) -> bool:
+    """Whether a stream's opener may still watch this printer (False also when
+    it can't be read).
+
+    A user's sessions must not have ended since the stream started (password
+    change or reset, 2FA removal, restore), and they must still exist and be
+    active; a key must still be usable and the same key; a long-lived token
+    must not be revoked or expired; a stream opened while auth was off ends
+    once it is on. In every case the printer must still be in scope.
+    """
+    from backend.app.models.long_lived_token import LongLivedToken
+    from backend.app.services.long_lived_tokens import _is_expired
+
+    try:
+        async with async_session() as db:
+            auth_on = await is_auth_enabled(db)
+            if not opener.auth_on:
+                return not auth_on
+            if not auth_on:
+                return True
+            if opener.long_lived_id is not None:
+                record = await db.get(LongLivedToken, opener.long_lived_id)
+                if (
+                    record is None
+                    or record.revoked_at is not None
+                    or record.user_id != opener.long_lived_user_id
+                    or record.lookup_prefix != opener.long_lived_prefix
+                    or _is_expired(record, datetime.now(timezone.utc))
+                ):
+                    return False
+                scope = await resolve_user_id_printer_scope(db, record.user_id)
+            elif opener.api_key_id is not None:
+                if await usable_api_key_prefix(db, opener.api_key_id) != opener.api_key_prefix:
+                    return False
+                scope = await principal_printer_scope(db, None, opener.api_key_id)
+            elif opener.username:
+                user = await get_user_by_username(db, opener.username)
+                if user is None or not user.is_active:
+                    return False
+                changed = user.password_changed_at
+                if changed is not None and changed.replace(tzinfo=None) > opener.started_at:
+                    return False
+                scope = await principal_printer_scope(db, opener.username, None)
+            else:
+                return False
+            return scope.allows(printer_id)
+    except Exception:  # SEC-AUTH-EXC: a failed re-check ends the stream (fail-closed)
+        logger.warning("Camera stream re-check failed", exc_info=True)
+        return False
 
 
 async def verify_camwall_token(token: str) -> PrinterScope | None:
@@ -2425,18 +2523,20 @@ def require_camera_stream_token_if_auth_enabled():
     tell one user's rows from another's (#3025).
     """
 
-    async def checker(printer_id: int, token: str | None = None) -> None:
+    async def checker(printer_id: int, token: str | None = None) -> StreamOpener:
         async with async_session() as db:
             if not await is_auth_enabled(db):
-                return  # Auth disabled, allow access
-        scope = await verify_camera_stream_token(token) if token else None
-        if scope is None:
+                return StreamOpener(auth_on=False)  # Auth disabled, allow access
+        access = await camera_stream_access(token) if token else None
+        if access is None:
             raise HTTPException(
                 status_code=status.HTTP_401_UNAUTHORIZED,
                 detail="Valid camera stream token required. Obtain one from POST /api/v1/printers/camera/stream-token",
             )
+        scope, opener = access
         # The token's minter may not see this printer (#1727)
         scope.ensure(printer_id)
+        return opener
 
     return checker
 

+ 171 - 0
backend/tests/integration/test_camera_stream_recheck.py

@@ -0,0 +1,171 @@
+"""An open camera stream ends once whoever opened it may no longer watch.
+
+The token was checked once, when the stream started, and a live view then ran
+for hours: after a password change or reset, a deactivation, a disabled key or
+a revoked Cam Wall token it kept sending video. The stream now re-checks its
+opener while it runs (every 30 s; at once here).
+"""
+
+import asyncio
+from datetime import datetime, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import text
+
+from backend.app.api.routes import camera
+from backend.app.core import auth
+from backend.app.models.api_key import APIKey
+from backend.app.models.printer import Printer
+from backend.app.utils.local_time import utcnow_naive
+
+pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
+
+
+@pytest.fixture
+async def admin(async_client: AsyncClient) -> int:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "camadmin", "admin_password": "AdminPass1!"},
+    )
+    login = await async_client.post("/api/v1/auth/login", json={"username": "camadmin", "password": "AdminPass1!"})
+    return login.json()["user"]["id"]
+
+
+@pytest.fixture
+async def printer(db_session) -> int:
+    row = Printer(
+        name="Cam",
+        serial_number="CAM-RECHECK",
+        ip_address="192.0.2.61",
+        access_code="x",
+        external_camera_enabled=True,
+        external_camera_url="http://192.0.2.62/mjpeg",
+        external_camera_type="mjpeg",
+    )
+    db_session.add(row)
+    await db_session.commit()
+    return row.id
+
+
+@pytest.fixture(autouse=True)
+def _frames(monkeypatch):
+    """An endless camera, and a re-check before every frame."""
+
+    async def frames(*_args, on_frame=None, **_kwargs):
+        while True:
+            on_frame(b"\xff\xd8f\xff\xd9")
+            yield b"--frame\r\n"
+            await asyncio.sleep(0)
+
+    monkeypatch.setattr(camera, "STREAM_RECHECK_SECONDS", 0.0)
+    with patch("backend.app.services.external_camera.generate_mjpeg_stream", frames):
+        yield
+
+
+async def _open(printer_id: int, opener):
+    response = await camera.camera_stream(printer_id, MagicMock(), fps=5, opener=opener)
+    stream = response.body_iterator
+    await anext(stream)
+    return stream
+
+
+async def _ended(stream) -> bool:
+    try:
+        await asyncio.wait_for(anext(stream), 2)
+    except StopAsyncIteration:
+        return True
+    return False
+
+
+async def _opener(token: str):
+    access = await auth.camera_stream_access(token)
+    assert access is not None
+    return access[1]
+
+
+async def test_a_users_stream_ends_when_their_sessions_end(admin, printer, db_session):
+    """A password change or reset, a 2FA removal or a restore."""
+    token = await auth.create_camera_stream_token(username="camadmin")
+    stream = await _open(printer, await _opener(token))
+
+    await db_session.execute(
+        text("UPDATE users SET password_changed_at = :t WHERE id = :id"), {"t": utcnow_naive(), "id": admin}
+    )
+    await db_session.commit()
+
+    assert await _ended(stream)
+
+
+async def test_a_deactivated_users_stream_ends(admin, printer, db_session):
+    token = await auth.create_camera_stream_token(username="camadmin")
+    stream = await _open(printer, await _opener(token))
+
+    await db_session.execute(text("UPDATE users SET is_active = 0 WHERE id = :id"), {"id": admin})
+    await db_session.commit()
+
+    assert await _ended(stream)
+
+
+async def test_a_valid_stream_outlives_its_token(admin, printer, db_session):
+    """The token lasts an hour; a stream opened with it may run longer."""
+    token = await auth.create_camera_stream_token(username="camadmin")
+    stream = await _open(printer, await _opener(token))
+
+    await db_session.execute(text("DELETE FROM auth_ephemeral_tokens WHERE token = :t"), {"t": token})
+    await db_session.commit()
+
+    assert not await _ended(stream)
+    assert not await _ended(stream)
+
+
+async def test_a_disabled_keys_stream_ends(admin, printer, db_session):
+    key = APIKey(name="cam", key_hash="h", key_prefix="bb_cam", printer_ids=None)
+    db_session.add(key)
+    await db_session.commit()
+    token = await auth.create_camera_stream_token(api_key_id=key.id)
+    stream = await _open(printer, await _opener(token))
+
+    await db_session.execute(text("UPDATE api_keys SET enabled = 0 WHERE id = :id"), {"id": key.id})
+    await db_session.commit()
+
+    assert await _ended(stream)
+
+
+async def test_a_revoked_cam_wall_tokens_stream_ends(admin, printer, db_session):
+    from backend.app.services.long_lived_tokens import create_token
+
+    created = await create_token(db_session, user_id=admin, name="wall", expires_in_days=30, scope="camwall")
+    stream = await _open(printer, await _opener(created.plaintext))
+
+    await db_session.execute(
+        text("UPDATE long_lived_tokens SET revoked_at = :t WHERE id = :id"),
+        {"t": datetime.now(timezone.utc).replace(tzinfo=None), "id": created.record.id},
+    )
+    await db_session.commit()
+
+    assert await _ended(stream)
+
+
+async def test_a_stream_opened_with_auth_off_ends_when_it_is_turned_on(async_client: AsyncClient, printer):  # noqa: ARG001 - patches the session factory
+    stream = await _open(printer, auth.StreamOpener(auth_on=False))
+
+    with patch("backend.app.core.auth.is_auth_enabled", AsyncMock(return_value=True)):
+        assert await _ended(stream)
+
+
+async def test_a_stream_opened_as_the_session_ends_does_not_outlive_it(admin, printer, db_session):
+    """The password change commits between the token check and the stream's
+    start: its sessions ended after the token was minted, so the stream ends."""
+    token = await auth.create_camera_stream_token(username="camadmin")
+    await asyncio.sleep(0.01)
+    await db_session.execute(
+        text("UPDATE users SET password_changed_at = :t WHERE id = :id"), {"t": utcnow_naive(), "id": admin}
+    )
+    await db_session.commit()
+    await asyncio.sleep(0.01)
+    opener = await _opener(token)  # the token row was still there when it was read
+
+    response = await camera.camera_stream(printer, MagicMock(), fps=5, opener=opener)
+    assert await _ended(response.body_iterator)

+ 2 - 1
backend/tests/integration/test_printer_delete_leftovers.py

@@ -111,6 +111,7 @@ async def test_an_external_camera_stream_of_the_printer_ends(async_client: Async
     from unittest.mock import MagicMock, patch
 
     from backend.app.api.routes import camera
+    from backend.app.core.auth import StreamOpener
 
     printer = Printer(
         name="A",
@@ -131,7 +132,7 @@ async def test_an_external_camera_stream_of_the_printer_ends(async_client: Async
             await asyncio.sleep(0)
 
     with patch("backend.app.services.external_camera.generate_mjpeg_stream", _frames):
-        response = await camera.camera_stream(printer.id, MagicMock(), fps=5, _=None)
+        response = await camera.camera_stream(printer.id, MagicMock(), fps=5, opener=StreamOpener(auth_on=False))
         stream = response.body_iterator
         await anext(stream)
         assert camera._last_frames.get(printer.id)