Explorar o código

fix(auth): ending a user's sessions also closes their open dashboards

A WebSocket is authenticated once, at connect. A password change or
reset link, an admin removing 2FA, deactivating a user and a backup
restore made earlier sign-ins invalid, but open dashboards kept receiving
live updates and their tokens stayed valid to reconnect for an hour.
These now drop the user's (and a deactivated owner's keys') tokens and
close their sockets; a restore does so only when the restored data has
auth on. Turning auth on closes the anonymous sockets and drops the
tokens minted while it was off. A disabled or expired API key counts as
gone at connect and on every scope refresh. Sockets connecting while any
of this happens are re-checked after they join.
maziggy hai 1 semana
pai
achega
26ea39eb31

+ 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
+- **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.
 - **A printer added after a delete no longer takes over the deleted printer's print, notifications, camera and plate references** — SQLite gives a deleted printer's id to the next printer created, and Bambuddy kept much of this under that id until the next restart.
   - **Prints and billing:** the new printer took over the owner of a print still running when the old one was deleted. Its prints were treated as started from Bambuddy, so the kill switch let them run, and its first finished print was credited to that user. A job sent to the old printer that hadn't started yet could do the same for a print with the same file name. A new printer that was already printing when added could also take over the old print's filament tracking, charging its filament to the deleted printer's spools.

+ 8 - 0
backend/app/api/routes/api_keys.py

@@ -1,4 +1,5 @@
 import logging
+from datetime import datetime, timezone
 
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import delete, select
@@ -182,6 +183,13 @@ async def update_api_key(
 
     await db.flush()
     await db.refresh(api_key)
+    if not api_key.enabled or (
+        api_key.expires_at is not None
+        and api_key.expires_at.replace(tzinfo=api_key.expires_at.tzinfo or timezone.utc) < datetime.now(timezone.utc)
+    ):
+        # Its camera and WebSocket tokens go with it; the scope refresh below
+        # closes its open dashboards.
+        await drop_api_key_tokens(db, [api_key.id])
     if data.printer_ids is not None or data.enabled is not None or data.expires_at is not None:
         # Sockets opened with this key may now see fewer printers, or none
         # (#1727). Committed first so the refresh reads the new row.

+ 28 - 1
backend/app/api/routes/auth.py

@@ -8,7 +8,7 @@ import jwt as _jwt
 from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, Response, status
 from fastapi.security import HTTPAuthorizationCredentials
 from jwt.exceptions import PyJWTError
-from sqlalchemy import delete, select
+from sqlalchemy import delete, or_, select
 from sqlalchemy.exc import SQLAlchemyError
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
@@ -375,7 +375,24 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
         # Set auth enabled and mark setup as completed
         await set_auth_enabled(db, request.auth_enabled)
         await set_setup_completed(db, True)
+        if request.auth_enabled:
+            # Tokens minted while it was off name nobody (every tab mints a
+            # WebSocket one): they would reconnect the dashboards closed below.
+            await db.execute(
+                delete(AuthEphemeralToken).where(
+                    or_(AuthEphemeralToken.username.is_(None), AuthEphemeralToken.username == ""),
+                    AuthEphemeralToken.api_key_id.is_(None),
+                    AuthEphemeralToken.token_type != "revoked_jti",
+                )
+            )
         await db.commit()
+        if request.auth_enabled:
+            # Dashboards opened while it was off have every printer and would
+            # keep them. Closed, such a tab stops updating until it is reloaded
+            # and signs in (the SPA treats the close as final).
+            from backend.app.core.websocket import ws_manager
+
+            await ws_manager.close_anonymous()
 
         if admin_created:
             await db.refresh(admin_user)
@@ -1243,7 +1260,17 @@ async def forgot_password_confirm(request: ForgotPasswordConfirmRequest, db: Asy
 
     user.password_hash = get_password_hash(request.new_password)
     user.password_changed_at = now  # M-R7-B: invalidate all prior JWTs
+    # The WebSocket and camera tokens too: reusable for an hour, they would
+    # reconnect a closed dashboard straight away.
+    from backend.app.api.routes.users import drop_pending_tokens
+
+    await drop_pending_tokens(db, user.username)
     await db.commit()
+    # Open dashboards are never signed in again: one someone else had open on
+    # the account would keep receiving its printers.
+    from backend.app.core.websocket import ws_manager
+
+    await ws_manager.close_user(user.id)
     _logger.info("Password reset completed for user '%s'", username)
 
     return ForgotPasswordResponse(message="Password has been reset successfully.")

+ 10 - 0
backend/app/api/routes/mfa.py

@@ -1305,8 +1305,18 @@ async def admin_disable_2fa(
     target_user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
     if target_user:
         target_user.password_changed_at = datetime.now(timezone.utc)
+        # The WebSocket and camera tokens too: reusable for an hour, they would
+        # reconnect a closed dashboard straight away.
+        from backend.app.api.routes.users import drop_pending_tokens
+
+        await drop_pending_tokens(db, target_user.username)
 
     await db.commit()
+    if target_user:
+        # And their open dashboards: they are never signed in again.
+        from backend.app.core.websocket import ws_manager
+
+        await ws_manager.close_user(user_id)
     actor = current_user.username if current_user else "anonymous"
     logger.info("Admin %s disabled all 2FA for user_id=%d", actor, user_id)
     return {"message": "2FA disabled for user"}

+ 19 - 0
backend/app/api/routes/settings.py

@@ -16,6 +16,7 @@ from backend.app.core.auth import (
     RequirePermissionIfAuthEnabled,
     ScopedCaller,
     caller_is_api_key,
+    is_auth_enabled,
     require_auth_if_enabled,
     require_energy_cost_update,
 )
@@ -1618,6 +1619,24 @@ async def restore_backup(
 
             async with _db_mod.engine.begin() as conn:
                 await _db_mod.invalidate_all_sessions(conn)
+            # Open dashboards of signed-in users too: they are never signed in
+            # again. API-key and anonymous ones are not closed outright (the
+            # dashboard treats the close as final until a sign-in), but the
+            # restored data decides what they may still see: the anonymous ones
+            # go if the backup turns authentication on, and the refresh closes
+            # those of a key the backup does not have (or has under another key).
+            from backend.app.core.auth import invalidate_auth_enabled_cache
+            from backend.app.core.websocket import ws_manager
+
+            # Read from the restored data: this request has just cached the
+            # setting it had before.
+            invalidate_auth_enabled_cache()
+            async with _db_mod.async_session() as auth_db:
+                auth_on = await is_auth_enabled(auth_db)
+            if auth_on:
+                await ws_manager.close_signed_in()
+                await ws_manager.close_anonymous()
+            await ws_manager.refresh_printer_scopes()
 
             logger.info("Restore complete - restart required")
             message = "Backup restored successfully. Please restart Bambuddy for changes to take effect."

+ 24 - 0
backend/app/api/routes/users.py

@@ -277,6 +277,7 @@ async def update_user(
 ):
     """Update a user."""
     close_sockets = False
+    deactivated_keys: list[int] = []
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one_or_none()
     if not user:
@@ -372,6 +373,20 @@ async def update_user(
         user.role = user_data.role
 
     if user_data.is_active is not None:
+        if user.is_active and not user_data.is_active:
+            # Deactivated: their printers go at the scope refresh below, but
+            # events of no printer (inventory, SpoolBuddy tags, announcements)
+            # would still reach their open dashboards, and their tokens would
+            # open new ones. The same for their API keys' (a kiosk), which
+            # REST already refuses for a deactivated owner.
+            from backend.app.api.routes.api_keys import drop_api_key_tokens
+
+            await drop_pending_tokens(db, user.username)
+            deactivated_keys = list(
+                (await db.execute(select(APIKey.id).where(APIKey.user_id == user_id))).scalars().all()
+            )
+            await drop_api_key_tokens(db, deactivated_keys)
+            close_sockets = True
         user.is_active = user_data.is_active
 
     # Handle group assignments
@@ -388,6 +403,8 @@ async def update_user(
     await ensure_user_finance_defaults(db, user)
 
     await db.commit()
+    if deactivated_keys:
+        await ws_manager.close_api_keys(deactivated_keys)
     if close_sockets:
         # Open dashboards are not signed in again. After a rename they keep the
         # name they connected with and look their printers up by it on every
@@ -663,7 +680,14 @@ async def change_own_password(
     # Update password
     user.password_hash = get_password_hash(password_data.new_password)
     user.password_changed_at = datetime.now(timezone.utc)  # M-R7-B: invalidate all prior JWTs
+    # The WebSocket and camera tokens too: reusable for an hour, they would
+    # reconnect a closed dashboard straight away.
+    await drop_pending_tokens(db, user.username)
     await db.commit()
+    # Open dashboards are never signed in again: one someone else had open on
+    # the account would keep receiving its printers. This tab signs in again
+    # (the route revokes its JWT anyway).
+    await ws_manager.close_user(user.id)
 
     # L-R6-A: Password verified successfully — reset the failure counter
     from backend.app.api.routes.mfa import clear_failed_attempts

+ 41 - 9
backend/app/api/routes/websocket.py

@@ -21,11 +21,15 @@ import logging
 from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
 from sqlalchemy import select
 
-from backend.app.core.auth import is_auth_enabled, principal_printer_scope, verify_websocket_token_principal
+from backend.app.core.auth import (
+    is_auth_enabled,
+    principal_printer_scope,
+    usable_api_key_prefix,
+    verify_websocket_token_principal,
+)
 from backend.app.core.database import async_session
 from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope
 from backend.app.core.websocket import ws_manager
-from backend.app.models.api_key import APIKey
 from backend.app.models.user import User
 from backend.app.services.printer_manager import printer_manager, printer_state_to_dict
 
@@ -52,16 +56,36 @@ async def _user_id_of(username: str) -> int | None:
 
 async def _api_key_prefix(api_key_id: int) -> str | None:
     """The key's prefix, which tells it from a later key given its id; None when
-    the key is gone (also when it can't be read)."""
+    the key is gone, disabled or expired (also when it can't be read)."""
     try:
         async with async_session() as db:
-            row = await db.execute(select(APIKey.key_prefix).where(APIKey.id == api_key_id))
-            return row.scalar_one_or_none()
+            return await usable_api_key_prefix(db, api_key_id)
     except Exception:  # SEC-AUTH-EXC: a failed lookup refuses the socket (fail-closed)
         logger.warning("WebSocket API key lookup failed for %s", api_key_id, exc_info=True)
         return None
 
 
+async def _auth_turned_on() -> bool:
+    """Whether authentication is on now (True also when it can't be read)."""
+    try:
+        async with async_session() as db:
+            return await is_auth_enabled(db)
+    except Exception:  # SEC-AUTH-EXC: a failed read refuses the socket (fail-closed)
+        logger.warning("WebSocket auth re-check failed", exc_info=True)
+        return True
+
+
+async def _token_principal(token: str | None):
+    """The token's principal again, or None (also when it can't be read)."""
+    if not token:
+        return None
+    try:
+        return await verify_websocket_token_principal(token)
+    except Exception:  # SEC-AUTH-EXC: a failed lookup refuses the socket (fail-closed)
+        logger.warning("WebSocket token re-check failed", exc_info=True)
+        return None
+
+
 @router.websocket("/ws")
 async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(default=None)) -> None:
     """WebSocket endpoint for real-time updates.
@@ -143,7 +167,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
             return
     api_key_prefix = await _api_key_prefix(api_key_id) if api_key_id is not None else None
     if api_key_id is not None and api_key_prefix is None:
-        logger.info("WebSocket connect refused: API key %s is gone", api_key_id)
+        logger.info("WebSocket connect refused: API key %s is gone, disabled or expired", api_key_id)
         await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
         return
     # Stamped before connect() puts the socket in the broadcast list, so no
@@ -162,10 +186,18 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
     # touching every call site.
     websocket.state.bambuddy_principal = principal
     await ws_manager.connect(websocket)
-    if (principal and await _user_id_of(principal) != principal_user_id) or (
-        api_key_id is not None and await _api_key_prefix(api_key_id) != api_key_prefix
+    # And the token still there: whatever ends a session (password change or
+    # reset, 2FA removal, deactivation) drops it before closing the user's
+    # sockets, so one landing in between is seen here. Admitted with auth off,
+    # auth must still be off: turning it on closes the anonymous sockets, which
+    # this one may have joined just after.
+    if (
+        (principal and await _user_id_of(principal) != principal_user_id)
+        or (api_key_id is not None and await _api_key_prefix(api_key_id) != api_key_prefix)
+        or (auth_required and await _token_principal(token) != (principal, api_key_id))
+        or (not auth_required and await _auth_turned_on())
     ):
-        logger.info("WebSocket connect refused: its user or API key went while it connected")
+        logger.info("WebSocket connect refused: its session ended while it connected")
         await ws_manager.disconnect(websocket)
         await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
         return

+ 19 - 0
backend/app/core/auth.py

@@ -965,6 +965,25 @@ async def verify_websocket_token(token: str) -> str | None:
 _NO_PRINTERS = PrinterScope(frozenset())
 
 
+async def usable_api_key_prefix(db: AsyncSession, api_key_id: int) -> str | None:
+    """The prefix of an API key that may still be used, else None.
+
+    None for a key that is gone, disabled or expired. The prefix tells a key
+    from a later one given its id (SQLite reuses ids), so an open WebSocket can
+    tell whether its key is still the one it connected with.
+    """
+    api_key = (await db.execute(select(APIKey).where(APIKey.id == api_key_id))).scalar_one_or_none()
+    if api_key is None or not api_key.enabled:
+        return None
+    if api_key.expires_at is not None:
+        expires = api_key.expires_at
+        if expires.tzinfo is None:
+            expires = expires.replace(tzinfo=timezone.utc)
+        if expires < datetime.now(timezone.utc):
+            return None
+    return api_key.key_prefix
+
+
 async def principal_printer_scope(db: AsyncSession, username: str | None, api_key_id: int | None) -> PrinterScope:
     """Printer scope of the principal a token was minted for (#1727).
 

+ 16 - 7
backend/app/core/websocket.py

@@ -114,6 +114,19 @@ class ConnectionManager:
         the messages of whoever is given their id next."""
         await self._close(lambda c: getattr(c.state, "bambuddy_principal_user_id", None) == user_id)
 
+    async def close_signed_in(self):
+        """Close every signed-in user's socket, after everyone is signed out (a
+        backup restore). API-key and anonymous sockets stay: nothing ended
+        theirs, and the dashboard treats the close as final until a sign-in."""
+        await self._close(lambda c: getattr(c.state, "bambuddy_principal_user_id", None) is not None)
+
+    async def close_anonymous(self):
+        """Close the sockets opened while authentication was off, which have
+        every printer, once it is turned on."""
+        await self._close(
+            lambda c: getattr(c.state, "bambuddy_scope_principal", (None, None)) in ((None, None), ("", None))
+        )
+
     async def close_api_keys(self, key_ids):
         """Close the sockets opened with deleted API keys. Their scope is looked
         up by key id on every refresh: SQLite gives the id to the next key."""
@@ -138,7 +151,7 @@ class ConnectionManager:
         is in a group, so open dashboards stop (or start) receiving those
         printers' events without a reconnect.
         """
-        from backend.app.core.auth import is_auth_enabled, principal_printer_scope
+        from backend.app.core.auth import is_auth_enabled, principal_printer_scope, usable_api_key_prefix
         from backend.app.core.database import async_session
         from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope
 
@@ -146,9 +159,6 @@ class ConnectionManager:
             connections = list(self.active_connections)
         if not connections:
             return
-        from sqlalchemy import select
-
-        from backend.app.models.api_key import APIKey
 
         orphaned = []
         try:
@@ -164,9 +174,8 @@ class ConnectionManager:
                         # next key created: a socket whose key is gone, or whose
                         # id another key now has, is closed, not re-scoped (a key
                         # rotated from the command line, which can't close it).
-                        prefix = (
-                            await db.execute(select(APIKey.key_prefix).where(APIKey.id == api_key_id))
-                        ).scalar_one_or_none()
+                        # Disabled or expired counts as gone: REST refuses it.
+                        prefix = await usable_api_key_prefix(db, api_key_id)
                         if prefix is None or prefix != getattr(connection.state, "bambuddy_api_key_prefix", prefix):
                             connection.state.bambuddy_printer_scope = PrinterScope(frozenset())
                             orphaned.append(connection)

+ 546 - 0
backend/tests/integration/test_session_end_closes_sockets.py

@@ -0,0 +1,546 @@
+"""Whatever ends a user's sessions also closes their open WebSockets.
+
+A socket is authenticated once, at connect. A password change or reset, an
+admin removing 2FA, or a backup restore makes every earlier JWT invalid, but a
+dashboard someone else had open on the account kept receiving its printers'
+status and events until the socket dropped.
+"""
+
+import sqlite3
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
+
+pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
+
+CLOSE_USER = "backend.app.core.websocket.ws_manager.close_user"
+
+
+@pytest.fixture
+async def admin(async_client: AsyncClient) -> dict:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "sockadmin", "admin_password": "AdminPass1!"},
+    )
+    login = await async_client.post("/api/v1/auth/login", json={"username": "sockadmin", "password": "AdminPass1!"})
+    return {"Authorization": f"Bearer {login.json()['access_token']}"}
+
+
+async def _user(client: AsyncClient, admin: dict) -> int:
+    response = await client.post(
+        "/api/v1/users/", headers=admin, json={"username": "carol", "password": "Userpass1!", "role": "user"}
+    )
+    assert response.status_code == 201, response.text
+    return response.json()["id"]
+
+
+async def test_changing_ones_password_closes_ones_sockets(async_client: AsyncClient, admin):
+    carol = await _user(async_client, admin)
+    login = await async_client.post("/api/v1/auth/login", json={"username": "carol", "password": "Userpass1!"})
+    headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
+
+    with patch(CLOSE_USER, AsyncMock()) as close:
+        response = await async_client.post(
+            "/api/v1/users/me/change-password",
+            headers=headers,
+            json={"current_password": "Userpass1!", "new_password": "Newpass12!"},
+        )
+
+    assert response.status_code == 200, response.text
+    close.assert_awaited_once_with(carol)
+
+
+async def test_a_reset_link_closes_the_sockets(async_client: AsyncClient, admin, db_session):
+    carol = await _user(async_client, admin)
+    db_session.add(
+        AuthEphemeralToken(
+            token="reset-carol",
+            token_type=TokenType.PASSWORD_RESET,
+            username="carol",
+            expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
+        )
+    )
+    await db_session.commit()
+
+    with patch(CLOSE_USER, AsyncMock()) as close:
+        response = await async_client.post(
+            "/api/v1/auth/forgot-password/confirm", json={"token": "reset-carol", "new_password": "Newpass12!"}
+        )
+
+    assert response.status_code == 200, response.text
+    close.assert_awaited_once_with(carol)
+
+
+async def test_an_admin_removing_2fa_closes_the_sockets(async_client: AsyncClient, admin):
+    carol = await _user(async_client, admin)
+
+    with patch(CLOSE_USER, AsyncMock()) as close:
+        response = await async_client.request(
+            "DELETE", f"/api/v1/auth/2fa/admin/{carol}", headers=admin, json={"admin_password": "AdminPass1!"}
+        )
+
+    assert response.status_code == 200, response.text
+    close.assert_awaited_once_with(carol)
+
+
+def _socket(user_id=None, key_id=None):
+    from types import SimpleNamespace
+    from unittest.mock import MagicMock
+
+    socket = MagicMock()
+    socket.state = SimpleNamespace(
+        bambuddy_principal_user_id=user_id,
+        bambuddy_scope_principal=("someone" if user_id else ("" if key_id else None), key_id),
+    )
+    socket.close = AsyncMock()
+    return socket
+
+
+@pytest.mark.parametrize("auth_on", [True, False])
+async def test_a_backup_restore_closes_the_signed_in_sockets(async_client: AsyncClient, monkeypatch, tmp_path, auth_on):
+    """With auth on in the restored data, sessions end, and what the restored
+    data no longer allows goes. With auth off nothing is closed: the dashboard
+    treats the close as final and would wait for a sign-in that never comes."""
+    from backend.app.api.routes.settings import create_backup_zip
+    from backend.app.core.config import settings as app_settings
+    from backend.app.core.websocket import ws_manager
+
+    db_path = tmp_path / "bambuddy.db"
+    with sqlite3.connect(db_path) as db:
+        db.execute("CREATE TABLE backup_marker (id INTEGER PRIMARY KEY)")
+    monkeypatch.setenv("DATA_DIR", str(tmp_path))
+    monkeypatch.setattr(app_settings, "base_dir", tmp_path)
+    monkeypatch.setattr(app_settings, "database_url", f"sqlite+aiosqlite:///{db_path}")
+    zip_path, _ = await create_backup_zip(output_path=tmp_path)
+    user, key, anonymous = _socket(user_id=5), _socket(key_id=3), _socket()
+    ws_manager.active_connections.extend([user, key, anonymous])
+
+    try:
+        with (
+            patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock),
+            patch("backend.app.core.database.reinitialize_database", new_callable=AsyncMock),
+            patch("backend.app.core.database.init_db", new_callable=AsyncMock),
+            patch("backend.app.core.database.invalidate_all_sessions", new_callable=AsyncMock),
+            patch("backend.app.services.print_scheduler.scheduler.stop"),
+            patch("backend.app.services.smart_plug_manager.smart_plug_manager.stop_scheduler"),
+            patch("backend.app.services.notification_service.notification_service.stop_digest_scheduler"),
+            patch("backend.app.api.routes.settings.is_auth_enabled", AsyncMock(return_value=auth_on)),
+        ):
+            response = await async_client.post(
+                "/api/v1/settings/restore", files={"file": ("backup.zip", zip_path.read_bytes(), "application/zip")}
+            )
+
+        assert response.status_code == 200, response.text
+        if auth_on:
+            # The user's session ended, and the anonymous one has every printer.
+            # (A key missing from the restored data goes at the scope refresh,
+            # see test_api_key_id_reuse.)
+            for socket in (user, anonymous):
+                socket.close.assert_awaited_once_with(code=4401)
+        else:  # auth off: everyone may see every printer, and no sign-in would reopen them
+            for socket in (user, key, anonymous):
+                socket.close.assert_not_awaited()
+    finally:
+        for socket in (user, key, anonymous):
+            if socket in ws_manager.active_connections:
+                ws_manager.active_connections.remove(socket)
+
+
+async def test_a_restore_that_turns_authentication_on_closes_the_anonymous_sockets(
+    async_client: AsyncClient, monkeypatch, tmp_path
+):
+    """The restore replaces the auth setting too: dashboards opened while it was
+    off would keep every printer on an install that now needs a sign-in."""
+    from backend.app.api.routes.settings import create_backup_zip
+    from backend.app.core.config import settings as app_settings
+    from backend.app.core.websocket import ws_manager
+
+    db_path = tmp_path / "bambuddy.db"
+    with sqlite3.connect(db_path) as db:
+        db.execute("CREATE TABLE backup_marker (id INTEGER PRIMARY KEY)")
+    monkeypatch.setenv("DATA_DIR", str(tmp_path))
+    monkeypatch.setattr(app_settings, "base_dir", tmp_path)
+    monkeypatch.setattr(app_settings, "database_url", f"sqlite+aiosqlite:///{db_path}")
+    zip_path, _ = await create_backup_zip(output_path=tmp_path)
+    anonymous = _socket()
+    ws_manager.active_connections.append(anonymous)
+
+    try:
+        with (
+            patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock),
+            patch("backend.app.core.database.reinitialize_database", new_callable=AsyncMock),
+            patch("backend.app.core.database.init_db", new_callable=AsyncMock),
+            patch("backend.app.core.database.invalidate_all_sessions", new_callable=AsyncMock),
+            patch("backend.app.services.print_scheduler.scheduler.stop"),
+            patch("backend.app.services.smart_plug_manager.smart_plug_manager.stop_scheduler"),
+            patch("backend.app.services.notification_service.notification_service.stop_digest_scheduler"),
+            patch("backend.app.api.routes.settings.is_auth_enabled", AsyncMock(return_value=True), create=True),
+        ):
+            response = await async_client.post(
+                "/api/v1/settings/restore", files={"file": ("backup.zip", zip_path.read_bytes(), "application/zip")}
+            )
+
+        assert response.status_code == 200, response.text
+        anonymous.close.assert_awaited_once_with(code=4401)
+    finally:
+        if anonymous in ws_manager.active_connections:
+            ws_manager.active_connections.remove(anonymous)
+
+
+async def _ws_tokens(db, username: str) -> list[str]:
+    from sqlalchemy import select
+
+    rows = await db.execute(
+        select(AuthEphemeralToken.token_type)
+        .where(AuthEphemeralToken.username == username, AuthEphemeralToken.token_type != "revoked_jti")
+        .execution_options(populate_existing=True)
+    )
+    return list(rows.scalars().all())
+
+
+async def _give_tokens(db, username: str) -> None:
+    expires = datetime.now(timezone.utc) + timedelta(hours=1)
+    db.add_all(
+        [
+            AuthEphemeralToken(token=f"{kind}-{username}", token_type=kind, username=username, expires_at=expires)
+            for kind in ("websocket", "camera_stream")
+        ]
+    )
+    await db.commit()
+
+
+async def test_change_password_drops_the_tokens_a_closed_dashboard_would_reconnect_with(
+    async_client: AsyncClient, admin, db_session
+):
+    """A WebSocket token is reusable for an hour: closing the socket alone let
+    whoever held it connect again straight away."""
+    await _user(async_client, admin)
+    login = await async_client.post("/api/v1/auth/login", json={"username": "carol", "password": "Userpass1!"})
+    await _give_tokens(db_session, "carol")
+
+    response = await async_client.post(
+        "/api/v1/users/me/change-password",
+        headers={"Authorization": f"Bearer {login.json()['access_token']}"},
+        json={"current_password": "Userpass1!", "new_password": "Newpass12!"},
+    )
+
+    assert response.status_code == 200, response.text
+    assert await _ws_tokens(db_session, "carol") == []
+
+
+async def test_a_reset_link_drops_them_too(async_client: AsyncClient, admin, db_session):
+    await _user(async_client, admin)
+    await _give_tokens(db_session, "carol")
+    db_session.add(
+        AuthEphemeralToken(
+            token="reset-carol2",
+            token_type=TokenType.PASSWORD_RESET,
+            username="carol",
+            expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
+        )
+    )
+    await db_session.commit()
+
+    response = await async_client.post(
+        "/api/v1/auth/forgot-password/confirm", json={"token": "reset-carol2", "new_password": "Newpass12!"}
+    )
+
+    assert response.status_code == 200, response.text
+    assert await _ws_tokens(db_session, "carol") == []
+
+
+async def test_an_admin_removing_2fa_drops_them_too(async_client: AsyncClient, admin, db_session):
+    carol = await _user(async_client, admin)
+    await _give_tokens(db_session, "carol")
+
+    response = await async_client.request(
+        "DELETE", f"/api/v1/auth/2fa/admin/{carol}", headers=admin, json={"admin_password": "AdminPass1!"}
+    )
+
+    assert response.status_code == 200, response.text
+    assert await _ws_tokens(db_session, "carol") == []
+
+
+async def test_deactivating_a_user_closes_their_sockets_and_drops_their_tokens(
+    async_client: AsyncClient, admin, db_session
+):
+    """Their printers go at the next scope refresh, but events of no printer
+    (inventory, SpoolBuddy tags, announcements) still reached them."""
+    carol = await _user(async_client, admin)
+    await _give_tokens(db_session, "carol")
+
+    with patch(CLOSE_USER, AsyncMock()) as close:
+        response = await async_client.patch(f"/api/v1/users/{carol}", headers=admin, json={"is_active": False})
+
+    assert response.status_code == 200, response.text
+    close.assert_awaited_once_with(carol)
+    assert await _ws_tokens(db_session, "carol") == []
+
+
+async def test_turning_authentication_on_closes_the_anonymous_sockets(async_client: AsyncClient):
+    """Opened while auth was off, they had every printer and kept it."""
+    from backend.app.core.websocket import ws_manager
+
+    anonymous = _socket()
+    ws_manager.active_connections.append(anonymous)
+    try:
+        response = await async_client.post(
+            "/api/v1/auth/setup",
+            json={"auth_enabled": True, "admin_username": "sockadmin3", "admin_password": "AdminPass1!"},
+        )
+
+        assert response.status_code == 200, response.text
+        anonymous.close.assert_awaited_once_with(code=4401)
+    finally:
+        if anonymous in ws_manager.active_connections:
+            ws_manager.active_connections.remove(anonymous)
+
+
+async def test_deactivating_a_user_also_ends_their_keys_connections(async_client: AsyncClient, admin, db_session):
+    """REST already refuses a deactivated owner's key; its open dashboards kept
+    receiving the events of no printer, and its tokens opened new ones."""
+    from backend.app.core.websocket import ws_manager
+    from backend.app.models.api_key import APIKey
+
+    carol = await _user(async_client, admin)
+    key = APIKey(name="kiosk", key_hash="h", key_prefix="bb_kiosk", user_id=carol)
+    db_session.add(key)
+    await db_session.commit()
+    db_session.add(
+        AuthEphemeralToken(
+            token="ws-kiosk",
+            token_type="websocket",
+            username="",
+            api_key_id=key.id,
+            expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
+        )
+    )
+    await db_session.commit()
+    socket = _socket(key_id=key.id)
+    ws_manager.active_connections.append(socket)
+    try:
+        response = await async_client.patch(f"/api/v1/users/{carol}", headers=admin, json={"is_active": False})
+
+        assert response.status_code == 200, response.text
+        socket.close.assert_awaited_once_with(code=4401)
+        from sqlalchemy import select
+
+        left = await db_session.execute(
+            select(AuthEphemeralToken.token)
+            .where(AuthEphemeralToken.api_key_id == key.id)
+            .execution_options(populate_existing=True)
+        )
+        assert left.scalars().all() == []
+    finally:
+        if socket in ws_manager.active_connections:
+            ws_manager.active_connections.remove(socket)
+
+
+async def _key_with_socket(db, **fields):
+    from backend.app.core.websocket import ws_manager
+    from backend.app.models.api_key import APIKey
+
+    key = APIKey(name="kiosk", key_hash="h", key_prefix="bb_kiosk2", **fields)
+    db.add(key)
+    await db.commit()
+    db.add(
+        AuthEphemeralToken(
+            token=f"ws-key-{key.id}",
+            token_type="websocket",
+            username="",
+            api_key_id=key.id,
+            expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
+        )
+    )
+    await db.commit()
+    socket = _socket(key_id=key.id)
+    socket.state.bambuddy_api_key_prefix = "bb_kiosk2"
+    ws_manager.active_connections.append(socket)
+    return key.id, socket
+
+
+async def test_disabling_a_key_ends_its_connections(async_client: AsyncClient, admin, db_session):
+    """REST refuses it from then on; its dashboards kept the events of no printer
+    and its token reconnected for an hour."""
+    from sqlalchemy import select
+
+    from backend.app.core.websocket import ws_manager
+
+    key, socket = await _key_with_socket(db_session)
+    try:
+        response = await async_client.patch(f"/api/v1/api-keys/{key}", headers=admin, json={"enabled": False})
+
+        assert response.status_code == 200, response.text
+        socket.close.assert_awaited_once_with(code=4401)
+        left = await db_session.execute(
+            select(AuthEphemeralToken.token)
+            .where(AuthEphemeralToken.api_key_id == key)
+            .execution_options(populate_existing=True)
+        )
+        assert left.scalars().all() == []
+    finally:
+        if socket in ws_manager.active_connections:
+            ws_manager.active_connections.remove(socket)
+
+
+async def test_a_scope_refresh_closes_the_connections_of_an_expired_key(async_client: AsyncClient, admin, db_session):
+    from backend.app.core import database
+    from backend.app.core.websocket import ws_manager
+
+    key, socket = await _key_with_socket(db_session, expires_at=datetime.now(timezone.utc) - timedelta(minutes=1))
+    try:
+        with patch("backend.app.core.database.async_session", database.async_session):
+            await ws_manager.refresh_printer_scopes()
+
+        socket.close.assert_awaited_once_with(code=4401)
+    finally:
+        if socket in ws_manager.active_connections:
+            ws_manager.active_connections.remove(socket)
+
+
+async def _connect(principal: tuple, verify_results: list):
+    from types import SimpleNamespace
+    from unittest.mock import MagicMock
+
+    from fastapi import WebSocketDisconnect
+
+    from backend.app.api.routes import websocket as ws_route
+    from backend.app.core import database
+    from backend.app.core.printer_scope import ALL_PRINTERS
+
+    socket = MagicMock()
+    socket.state = SimpleNamespace()
+    socket.accept = AsyncMock()
+    socket.close = AsyncMock()
+    socket.send_json = AsyncMock()
+    socket.receive_json = AsyncMock(side_effect=WebSocketDisconnect())
+    with (
+        patch.object(ws_route, "is_auth_enabled", AsyncMock(return_value=True)),
+        patch.object(ws_route, "verify_websocket_token_principal", AsyncMock(side_effect=verify_results)),
+        patch.object(ws_route, "principal_printer_scope", AsyncMock(return_value=ALL_PRINTERS)),
+        patch.object(ws_route, "async_session", database.async_session),
+    ):
+        await ws_route.websocket_endpoint(socket, token="t")
+    return socket
+
+
+async def test_a_websocket_of_a_disabled_key_is_refused(async_client: AsyncClient, db_session):
+    from backend.app.core.websocket import ws_manager
+    from backend.app.models.api_key import APIKey
+
+    key = APIKey(name="off", key_hash="h", key_prefix="bb_off", enabled=False)
+    db_session.add(key)
+    await db_session.commit()
+
+    socket = await _connect(("", key.id), [("", key.id)] * 2)
+
+    socket.close.assert_awaited_once_with(code=4401)
+    socket.accept.assert_not_awaited()
+    assert socket not in ws_manager.active_connections
+
+
+async def test_a_session_ending_while_a_websocket_joins_closes_it(async_client: AsyncClient, admin):
+    """The password change drops the tokens, then closes the user's sockets: one
+    that checked its token before and joins after is missed by the close."""
+    from backend.app.core.websocket import ws_manager
+
+    await _user(async_client, admin)
+
+    socket = await _connect(("carol", None), [("carol", None), None])  # gone by the second check
+
+    socket.close.assert_awaited_once_with(code=4401)
+    assert socket not in ws_manager.active_connections
+
+
+async def test_turning_authentication_on_drops_the_tokens_minted_while_it_was_off(
+    async_client: AsyncClient, db_session
+):
+    """Every tab mints one with auth off, naming nobody: it would reconnect the
+    closed dashboard straight away (to the events of no printer)."""
+    from sqlalchemy import select
+
+    expires = datetime.now(timezone.utc) + timedelta(hours=1)
+    db_session.add_all(
+        [
+            AuthEphemeralToken(token="anon-ws", token_type="websocket", username="", expires_at=expires),
+            AuthEphemeralToken(token="anon-cam", token_type="camera_stream", username="", expires_at=expires),
+            AuthEphemeralToken(token="logged-out", token_type="revoked_jti", username="", expires_at=expires),
+        ]
+    )
+    await db_session.commit()
+
+    response = await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "sockadmin4", "admin_password": "AdminPass1!"},
+    )
+
+    assert response.status_code == 200, response.text
+    left = await db_session.execute(select(AuthEphemeralToken.token).execution_options(populate_existing=True))
+    assert sorted(left.scalars().all()) == ["logged-out"]
+
+
+async def test_a_restore_reads_the_restored_auth_setting_not_the_cached_one(
+    async_client: AsyncClient, monkeypatch, tmp_path
+):
+    """The restore request itself has just cached 'auth on' for 30 s."""
+    from backend.app.api.routes.settings import create_backup_zip
+    from backend.app.core import auth
+    from backend.app.core.config import settings as app_settings
+
+    db_path = tmp_path / "bambuddy.db"
+    with sqlite3.connect(db_path) as db:
+        db.execute("CREATE TABLE backup_marker (id INTEGER PRIMARY KEY)")
+    monkeypatch.setenv("DATA_DIR", str(tmp_path))
+    monkeypatch.setattr(app_settings, "base_dir", tmp_path)
+    monkeypatch.setattr(app_settings, "database_url", f"sqlite+aiosqlite:///{db_path}")
+    zip_path, _ = await create_backup_zip(output_path=tmp_path)
+    calls = []
+    real = auth.invalidate_auth_enabled_cache
+    monkeypatch.setattr(auth, "invalidate_auth_enabled_cache", lambda: (calls.append(1), real())[1])
+    with (
+        patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock),
+        patch("backend.app.core.database.reinitialize_database", new_callable=AsyncMock),
+        patch("backend.app.core.database.init_db", new_callable=AsyncMock),
+        patch("backend.app.core.database.invalidate_all_sessions", new_callable=AsyncMock),
+        patch("backend.app.services.print_scheduler.scheduler.stop"),
+        patch("backend.app.services.smart_plug_manager.smart_plug_manager.stop_scheduler"),
+        patch("backend.app.services.notification_service.notification_service.stop_digest_scheduler"),
+    ):
+        response = await async_client.post(
+            "/api/v1/settings/restore", files={"file": ("backup.zip", zip_path.read_bytes(), "application/zip")}
+        )
+
+    assert response.status_code == 200, response.text
+    assert calls, "the auth setting cache was not cleared after the restore"
+
+
+async def test_authentication_turned_on_while_a_websocket_joins_closes_it(async_client: AsyncClient):
+    """Admitted with auth off, it joined after the anonymous sockets were closed:
+    nothing checked it again, and it kept every printer."""
+    from types import SimpleNamespace
+    from unittest.mock import MagicMock
+
+    from fastapi import WebSocketDisconnect
+
+    from backend.app.api.routes import websocket as ws_route
+    from backend.app.core import database
+    from backend.app.core.websocket import ws_manager
+
+    socket = MagicMock()
+    socket.state = SimpleNamespace()
+    socket.accept = AsyncMock()
+    socket.close = AsyncMock()
+    socket.send_json = AsyncMock()
+    socket.receive_json = AsyncMock(side_effect=WebSocketDisconnect())
+    with (
+        patch.object(ws_route, "is_auth_enabled", AsyncMock(side_effect=[False, True])),  # on by the second read
+        patch.object(ws_route, "async_session", database.async_session),
+    ):
+        await ws_route.websocket_endpoint(socket, token=None)
+
+    socket.close.assert_awaited_once_with(code=4401)
+    assert socket not in ws_manager.active_connections