Переглянути джерело

Security hardening (maziggy/bambuddy-security #14)

maziggy 1 тиждень тому
батько
коміт
5016f098cd

+ 44 - 9
backend/app/api/routes/users.py

@@ -26,7 +26,7 @@ from backend.app.core.permissions import Permission
 from backend.app.core.websocket import ws_manager
 from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
-from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
+from backend.app.models.auth_ephemeral import AuthEphemeralToken
 from backend.app.models.connected_app import ConnectedApp, ConnectedAppGrant
 from backend.app.models.group import Group
 from backend.app.models.library import LibraryFile
@@ -57,6 +57,7 @@ from backend.app.services.finance_defaults import ensure_user_finance_defaults
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.slice_dispatch import slice_dispatch
 from backend.app.utils.library_paths import remove_library_photos_dir
+from backend.app.utils.local_time import utcnow_naive
 
 router = APIRouter(prefix="/users", tags=["users"])
 
@@ -95,6 +96,24 @@ async def list_users(
     return [_user_to_response(user) for user in users]
 
 
+async def drop_pending_tokens(db: AsyncSession, username: str) -> None:
+    """Delete the one-time tokens pending under a username that is going away.
+
+    Reset links, 2FA and OIDC steps, connect codes, camera, WebSocket and slicer
+    tokens are looked up by username, so they would work for whoever is given
+    the name next. The logout list (revoked_jti) stays: dropping an entry would
+    make the logged-out token valid again.
+    """
+    await db.execute(
+        delete(AuthEphemeralToken).where(
+            # Both sides in SQL: SQLite's lower() changes only ASCII letters, so
+            # it never equals Python's lower() of a name with an umlaut.
+            func.lower(AuthEphemeralToken.username) == func.lower(username),
+            AuthEphemeralToken.token_type != "revoked_jti",
+        )
+    )
+
+
 @router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 @router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 async def create_user(
@@ -257,6 +276,7 @@ async def update_user(
     db: AsyncSession = Depends(get_db),
 ):
     """Update a user."""
+    close_sockets = False
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one_or_none()
     if not user:
@@ -306,6 +326,16 @@ async def update_user(
                 status_code=status.HTTP_400_BAD_REQUEST,
                 detail="Username already exists",
             )
+        if user_data.username != user.username:
+            # The name passes on: a deleted user's still-valid JWTs under the new
+            # name must not sign this user in, and what is pending under the old
+            # one must not reach whoever is given it next. This user's own
+            # sessions carry the old name and end anyway. A change of case counts
+            # too: SQLite compares only ASCII letters without case, so 'Ärger'
+            # and 'ärger' are two names there.
+            await drop_pending_tokens(db, user.username)
+            user.password_changed_at = utcnow_naive()
+            close_sockets = True
         user.username = user_data.username
 
     if user_data.email is not None:
@@ -327,6 +357,11 @@ async def update_user(
                 detail="Cannot set password for LDAP users",
             )
         user.password_hash = get_password_hash(user_data.password)
+        # As a reset: sessions and pending tokens of the old password end (an
+        # admin setting a password is often taking an account back).
+        await drop_pending_tokens(db, user.username)
+        user.password_changed_at = utcnow_naive()
+        close_sockets = True
 
     if user_data.role is not None:
         if user_data.role not in ["admin", "user"]:
@@ -353,6 +388,12 @@ async def update_user(
     await ensure_user_finance_defaults(db, user)
 
     await db.commit()
+    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
+        # scope refresh (the next user given it); after a password set they are
+        # whoever held the account before.
+        await ws_manager.close_user(user_id)
     if user_data.group_ids is not None or user_data.role is not None or user_data.is_active is not None:
         # Groups, admin role and deactivation all change which printers the
         # user's open dashboards may hear about (#1727)
@@ -536,17 +577,11 @@ async def delete_user(
     await db.execute(delete(LongLivedToken).where(LongLivedToken.user_id == user_id))
 
     # Connected apps, same SQLite/FK pattern. A leftover consent row would
-    # skip the consent screen for whoever is next given this user id, and a
-    # code issued in the last minute is keyed by username, not id.
+    # skip the consent screen for whoever is next given this user id.
     from sqlalchemy import update as _update
 
     await db.execute(delete(ConnectedAppGrant).where(ConnectedAppGrant.user_id == user_id))
-    await db.execute(
-        delete(AuthEphemeralToken).where(
-            AuthEphemeralToken.token_type == TokenType.CONNECT_CODE,
-            func.lower(AuthEphemeralToken.username) == user.username.lower(),
-        )
-    )
+    await drop_pending_tokens(db, user.username)
     await db.execute(_update(ConnectedApp).where(ConnectedApp.created_by_id == user_id).values(created_by_id=None))
 
     # Email 2FA is a settings key named after the id, which no foreign key covers.

+ 38 - 15
backend/app/api/routes/websocket.py

@@ -38,6 +38,17 @@ router = APIRouter()
 _WS_CLOSE_UNAUTHORIZED = 4401
 
 
+async def _user_id_of(username: str) -> int | None:
+    """The id of the user with this name, or None (also when it can't be read)."""
+    try:
+        async with async_session() as db:
+            row = await db.execute(select(User.id).where(User.username == username))
+            return row.scalar_one_or_none()
+    except Exception:  # SEC-AUTH-EXC: a failed lookup refuses the socket (fail-closed)
+        logger.warning("WebSocket principal resolve failed for %s", username, 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.
@@ -94,12 +105,32 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
 
     # Token verified (or auth disabled); now safe to admit the connection.
     logger.info("WebSocket client connecting (principal=%s)", principal if principal else "<anonymous>")
+    # Resolve principal username → User.id once at connect so
+    # ``ws_manager.broadcast_to_user()`` can filter without re-querying
+    # per message. Auth-disabled path keeps None (broadcast_to_user fans
+    # out to all when target is None — matches the legacy single-user
+    # toast behaviour). API-keyed principal is empty string → None.
+    #
+    # A user renamed or deleted since the token was checked is refused: the
+    # socket would keep the old name, and every scope refresh would give it the
+    # printers of the next user given that name. Done before connect(), so the
+    # socket carries its id from the moment it is listed, and checked again
+    # after: a rename or delete commits before it closes the user's sockets by
+    # id, so one landing in between is seen by the second check, and one after
+    # it finds this socket listed with its id.
+    principal_user_id: int | None = None
+    if principal:
+        principal_user_id = await _user_id_of(principal)
+        if principal_user_id is None:
+            logger.info("WebSocket connect refused: user %s is gone", principal)
+            await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
+            return
     # Stamped before connect() puts the socket in the broadcast list, so no
     # broadcast can reach it unfiltered (ws_manager refuses printer-bound
     # messages to a socket without a scope anyway).
     websocket.state.bambuddy_printer_scope = printer_scope
     websocket.state.bambuddy_scope_principal = (principal, api_key_id)
-    await ws_manager.connect(websocket)
+    websocket.state.bambuddy_principal_user_id = principal_user_id
     # Stash on connection state for any future per-message permission
     # logic; today the message handlers are read-only and only respond
     # to the requesting socket, so the stash is informational. The
@@ -107,20 +138,12 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
     # ``broadcast_to_principal()`` helper can filter on it without
     # touching every call site.
     websocket.state.bambuddy_principal = principal
-    # Resolve principal username → User.id once at connect so
-    # ``ws_manager.broadcast_to_user()`` can filter without re-querying
-    # per message. Auth-disabled path keeps None (broadcast_to_user fans
-    # out to all when target is None — matches the legacy single-user
-    # toast behaviour). API-keyed principal is empty string → None.
-    principal_user_id: int | None = None
-    if principal:
-        try:
-            async with async_session() as db:
-                row = await db.execute(select(User.id).where(User.username == principal))
-                principal_user_id = row.scalar_one_or_none()
-        except Exception:  # SEC-AUTH-EXC: resolution failure is non-fatal — degrades to no per-user routing
-            logger.warning("WebSocket principal resolve failed for %s", principal, exc_info=True)
-    websocket.state.bambuddy_principal_user_id = principal_user_id
+    await ws_manager.connect(websocket)
+    if principal and await _user_id_of(principal) != principal_user_id:
+        logger.info("WebSocket connect refused: user %s was renamed or deleted while connecting", principal)
+        await ws_manager.disconnect(websocket)
+        await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
+        return
     logger.info("WebSocket client connected")
 
     try:

+ 24 - 0
backend/app/core/database.py

@@ -364,6 +364,29 @@ async def _drop_deleted_printers_from_limits(conn) -> None:
         await drop_virtual_printer(conn, vp_id)
 
 
+async def _drop_ephemeral_tokens_of_missing_users(conn) -> None:
+    """Delete one-time tokens pending under a username no user has (both databases).
+
+    User deletes left them (reset links, 2FA and OIDC steps, camera, WebSocket
+    and slicer tokens), and they are looked up by username: whoever is given the
+    name next would take them over. The logout list (revoked_jti) is kept, and
+    tokens of no user (an API key's, or auth off) are left alone.
+    """
+    from sqlalchemy import delete, func, select
+
+    from backend.app.models.auth_ephemeral import AuthEphemeralToken
+    from backend.app.models.user import User
+
+    await conn.execute(
+        delete(AuthEphemeralToken).where(
+            AuthEphemeralToken.token_type != "revoked_jti",
+            AuthEphemeralToken.username.is_not(None),
+            AuthEphemeralToken.username != "",
+            func.lower(AuthEphemeralToken.username).not_in(select(func.lower(User.username))),
+        )
+    )
+
+
 async def _repair_sqlite_orphans(conn) -> None:
     """Repair rows that earlier deletes left pointing at a row that is gone (SQLite only).
 
@@ -495,6 +518,7 @@ async def init_db():
         # earlier deletes left pointing at nothing. Not a migration.
         await _repair_sqlite_orphans(conn)
         await _drop_deleted_printers_from_limits(conn)
+        await _drop_ephemeral_tokens_of_missing_users(conn)
 
     # Re-encrypt any legacy plaintext OIDC client_secret / TOTP secret rows
     # that exist from before the encryption key was configured.

+ 5 - 1
backend/app/models/user.py

@@ -7,6 +7,7 @@ from sqlalchemy import DateTime, String, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
+from backend.app.utils.local_time import utcnow_naive
 
 if TYPE_CHECKING:
     from backend.app.models.group import Group
@@ -37,7 +38,10 @@ class User(Base):
 
     # Set whenever the local password is changed/reset — used to invalidate JWTs
     # issued before the change (M-R7-B).  NULL means no password change recorded yet.
-    password_changed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    # Set at creation too, whichever way the user is created: sessions name the
+    # user by username, and a deleted user's still-valid JWTs must not sign in
+    # whoever is given the name next.
+    password_changed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=utcnow_naive)
 
     # Per-user Bambu Cloud credentials (when auth is enabled, each user has their own)
     cloud_token: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None)

+ 305 - 0
backend/tests/integration/test_recreated_username.py

@@ -0,0 +1,305 @@
+"""A username given to a new account carries nothing of its earlier owner.
+
+Sessions and one-time tokens name the user by username. A user created with a
+deleted user's name, or renamed to it, accepted the deleted user's still-valid
+JWTs (a new user had no password_changed_at, which accepts every token), and
+the reset links, 2FA, camera and WebSocket tokens still pending under the name.
+"""
+
+from datetime import datetime, timedelta, timezone
+
+import jwt
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select, text
+
+from backend.app.core import auth
+from backend.app.models.auth_ephemeral import AuthEphemeralToken
+from backend.app.models.user import User
+
+pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
+
+PENDING = (
+    "pre_auth",
+    "oidc_exchange",
+    "password_reset",
+    "email_otp_setup",
+    "slicer_download",
+    "connect_code",
+    "camera_stream",
+    "websocket",
+)
+
+
+@pytest.fixture
+async def admin(async_client: AsyncClient) -> dict:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "nameadmin", "admin_password": "AdminPass1!"},
+    )
+    login = await async_client.post("/api/v1/auth/login", json={"username": "nameadmin", "password": "AdminPass1!"})
+    return {"Authorization": f"Bearer {login.json()['access_token']}"}
+
+
+def _old_session(username: str, issued: datetime) -> dict:
+    """A JWT this server issued earlier for ``username``, still within its lifetime."""
+    token = jwt.encode(
+        {"sub": username, "exp": issued + timedelta(days=7), "jti": "old-" + username, "iat": issued},
+        auth.SECRET_KEY,
+        algorithm=auth.ALGORITHM,
+    )
+    return {"Authorization": f"Bearer {token}"}
+
+
+async def _create(client: AsyncClient, admin: dict, username: str) -> int:
+    response = await client.post(
+        "/api/v1/users/", headers=admin, json={"username": username, "password": "Userpass1!", "role": "user"}
+    )
+    assert response.status_code == 201, response.text
+    return response.json()["id"]
+
+
+async def _pending(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 (*PENDING, "revoked_jti")
+        ]
+    )
+    await db.commit()
+
+
+async def _kinds(db, username: str) -> list[str]:
+    rows = await db.execute(
+        select(AuthEphemeralToken.token_type)
+        .where(AuthEphemeralToken.username == username)
+        .execution_options(populate_existing=True)
+    )
+    return sorted(rows.scalars().all())
+
+
+async def test_a_recreated_user_rejects_the_deleted_namesakes_sessions(async_client: AsyncClient, admin):
+    alice = await _create(async_client, admin, "alice")
+    old = _old_session("alice", datetime.now(timezone.utc) - timedelta(minutes=5))
+    assert (await async_client.delete(f"/api/v1/users/{alice}", headers=admin)).status_code == 204
+
+    await _create(async_client, admin, "alice")
+
+    assert (await async_client.get("/api/v1/auth/me", headers=old)).status_code == 401
+
+
+async def test_a_new_user_records_when_it_started(db_session):
+    """Every way of creating a user (admin, setup, LDAP, OIDC) goes through the model."""
+    user = User(username="ldapuser", password_hash=None, role="user", auth_source="ldap")
+    db_session.add(user)
+    await db_session.commit()
+
+    assert user.password_changed_at is not None
+
+
+@pytest.mark.parametrize("name", ["alice", "Ärger"])
+async def test_a_deleted_users_pending_tokens_go_but_logouts_stay(async_client: AsyncClient, admin, db_session, name):
+    """A reset link, 2FA step, camera or WebSocket token of the deleted user would
+    work for whoever is given the name next. The logout list keeps its entries,
+    or a token logged out would work again."""
+    # SQLite's lower() changes only ASCII letters: a name with an umlaut matched nothing.
+    user = await _create(async_client, admin, name)
+    await _pending(db_session, name)
+
+    assert (await async_client.delete(f"/api/v1/users/{user}", headers=admin)).status_code == 204
+
+    assert await _kinds(db_session, name) == ["revoked_jti"]
+
+
+async def test_a_user_renamed_to_a_deleted_name_rejects_its_sessions(async_client: AsyncClient, admin, db_session):
+    carol_session = _old_session("carol", datetime.now(timezone.utc) - timedelta(minutes=5))
+    bob = await _create(async_client, admin, "bob")
+    # bob has had his password for a while; carol's session is younger.
+    await db_session.execute(
+        text("UPDATE users SET password_changed_at = :t WHERE id = :id"),
+        {"t": datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=30), "id": bob},
+    )
+    await db_session.commit()
+
+    response = await async_client.patch(f"/api/v1/users/{bob}", headers=admin, json={"username": "carol"})
+
+    assert response.status_code == 200, response.text
+    assert (await async_client.get("/api/v1/auth/me", headers=carol_session)).status_code == 401
+
+
+async def test_a_renamed_users_old_name_leaves_nothing_pending(async_client: AsyncClient, admin, db_session):
+    """The next user given the old name would take over its reset link."""
+    bob = await _create(async_client, admin, "bob")
+    await _pending(db_session, "bob")
+
+    response = await async_client.patch(f"/api/v1/users/{bob}", headers=admin, json={"username": "bobby"})
+
+    assert response.status_code == 200, response.text
+    assert await _kinds(db_session, "bob") == ["revoked_jti"]
+
+
+async def test_the_startup_cleanup_drops_tokens_of_users_that_are_gone(db_session):
+    """Deleted before deletes took them along."""
+    from backend.app.core.database import _drop_ephemeral_tokens_of_missing_users
+
+    db_session.add(User(username="Dave", password_hash="x", role="user"))
+    await db_session.commit()
+    await _pending(db_session, "ghost")
+    await _pending(db_session, "dave")  # names match without case
+    db_session.add(
+        AuthEphemeralToken(
+            token="camera-key", token_type="camera_stream", username="", expires_at=datetime.now(timezone.utc)
+        )
+    )  # an API key's: no user
+    await db_session.commit()
+
+    await _drop_ephemeral_tokens_of_missing_users(await db_session.connection())
+    await db_session.commit()
+
+    assert await _kinds(db_session, "ghost") == ["revoked_jti"]
+    assert await _kinds(db_session, "dave") == sorted((*PENDING, "revoked_jti"))
+    assert await _kinds(db_session, "") == ["camera_stream"]
+
+
+async def test_a_renamed_users_open_connections_close(async_client: AsyncClient, admin):
+    """An open WebSocket keeps the name it connected with and looks its printers
+    up by it again on every scope refresh: the next user given the old name."""
+    from unittest.mock import AsyncMock, patch
+
+    bob = await _create(async_client, admin, "bob")
+
+    with patch("backend.app.api.routes.users.ws_manager.close_user", AsyncMock()) as close:
+        response = await async_client.patch(f"/api/v1/users/{bob}", headers=admin, json={"username": "bobby"})
+
+    assert response.status_code == 200, response.text
+    close.assert_awaited_once_with(bob)
+
+
+async def test_a_rename_that_only_changes_case_counts_too(async_client: AsyncClient, admin, db_session):
+    """SQLite compares only ASCII letters without case: 'Ärger' and 'ärger' are
+    two names to it, so the old one can be given to someone else."""
+    user = await _create(async_client, admin, "Ärger")
+    await _pending(db_session, "Ärger")
+
+    response = await async_client.patch(f"/api/v1/users/{user}", headers=admin, json={"username": "ärger"})
+
+    assert response.status_code == 200, response.text
+    assert await _kinds(db_session, "Ärger") == ["revoked_jti"]
+
+
+@pytest.mark.parametrize("lookup_fails", [False, True])
+async def test_a_websocket_whose_user_is_gone_by_connect_is_closed(async_client: AsyncClient, lookup_fails):
+    """Renamed or deleted between the token check and the socket joining: the
+    close by user id missed it, and it kept the old name for scope refreshes."""
+    from types import SimpleNamespace
+    from unittest.mock import AsyncMock, MagicMock, patch
+
+    from fastapi import WebSocketDisconnect
+
+    from backend.app.api.routes import websocket as ws_route
+    from backend.app.core.printer_scope import ALL_PRINTERS
+    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())
+    from backend.app.core import database
+
+    session = database.async_session  # the test database (the fixture patches it)
+    if lookup_fails:
+        calls = {"n": 0}
+
+        def session_then_failure():
+            calls["n"] += 1
+            if calls["n"] >= 3:  # auth probe, scope, then the user lookup
+                raise OSError("database gone")
+            return session()
+
+    with (
+        patch.object(ws_route, "is_auth_enabled", AsyncMock(return_value=True)),
+        patch.object(ws_route, "verify_websocket_token_principal", AsyncMock(return_value=("bob", None))),
+        patch.object(ws_route, "principal_printer_scope", AsyncMock(return_value=ALL_PRINTERS)),
+        patch.object(ws_route, "async_session", session_then_failure if lookup_fails else session),
+    ):
+        await ws_route.websocket_endpoint(socket, token="t")
+
+    socket.close.assert_awaited_once_with(code=4401)
+    assert socket not in ws_manager.active_connections
+
+
+async def test_a_password_set_by_an_admin_ends_the_old_sessions(async_client: AsyncClient, admin, db_session):
+    """An admin setting a password is often taking an account back: the
+    attacker's JWT and a pending reset link must stop working."""
+    bob = await _create(async_client, admin, "bob")
+    session = _old_session("bob", datetime.now(timezone.utc) - timedelta(minutes=5))
+    await db_session.execute(
+        text("UPDATE users SET password_changed_at = :t WHERE id = :id"),
+        {"t": datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=30), "id": bob},
+    )
+    await db_session.commit()
+    await _pending(db_session, "bob")
+    assert (await async_client.get("/api/v1/auth/me", headers=session)).status_code == 200
+
+    response = await async_client.patch(f"/api/v1/users/{bob}", headers=admin, json={"password": "Newpass12!"})
+
+    assert response.status_code == 200, response.text
+    assert (await async_client.get("/api/v1/auth/me", headers=session)).status_code == 401
+    assert await _kinds(db_session, "bob") == ["revoked_jti"]
+
+
+async def test_a_rename_landing_while_a_websocket_joins_closes_it(async_client: AsyncClient, admin, db_session):
+    """The id is read, then the socket joins the list. A rename committing in
+    between closes the user's sockets by id before this one is listed."""
+    from types import SimpleNamespace
+    from unittest.mock import AsyncMock, MagicMock, patch
+
+    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
+    from backend.app.core.websocket import ws_manager
+
+    bob = await _create(async_client, admin, "bob")
+    socket = MagicMock()
+    socket.state = SimpleNamespace()
+    socket.accept = AsyncMock()
+    socket.close = AsyncMock()
+    socket.send_json = AsyncMock()
+    socket.receive_json = AsyncMock(side_effect=WebSocketDisconnect())
+    joined = ws_manager.connect
+
+    async def join_while_renamed(websocket):
+        await db_session.execute(text("UPDATE users SET username = 'bobby' WHERE id = :id"), {"id": bob})
+        await db_session.commit()
+        await ws_manager.close_user(bob)  # what the rename does after its commit
+        await joined(websocket)
+
+    with (
+        patch.object(ws_route, "is_auth_enabled", AsyncMock(return_value=True)),
+        patch.object(ws_route, "verify_websocket_token_principal", AsyncMock(return_value=("bob", None))),
+        patch.object(ws_route, "principal_printer_scope", AsyncMock(return_value=ALL_PRINTERS)),
+        patch.object(ws_route, "async_session", database.async_session),
+        patch.object(ws_manager, "connect", join_while_renamed),
+    ):
+        await ws_route.websocket_endpoint(socket, token="t")
+
+    socket.close.assert_awaited_once_with(code=4401)
+    assert socket not in ws_manager.active_connections
+
+
+async def test_a_password_set_by_an_admin_closes_open_connections(async_client: AsyncClient, admin):
+    """Open dashboards are not signed in again: they would be whoever held the account."""
+    from unittest.mock import AsyncMock, patch
+
+    bob = await _create(async_client, admin, "bob")
+
+    with patch("backend.app.api.routes.users.ws_manager.close_user", AsyncMock()) as close:
+        response = await async_client.patch(f"/api/v1/users/{bob}", headers=admin, json={"password": "Newpass12!"})
+
+    assert response.status_code == 200, response.text
+    close.assert_awaited_once_with(bob)