test_recreated_username.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. """A username given to a new account carries nothing of its earlier owner.
  2. Sessions and one-time tokens name the user by username. A user created with a
  3. deleted user's name, or renamed to it, accepted the deleted user's still-valid
  4. JWTs (a new user had no password_changed_at, which accepts every token), and
  5. the reset links, 2FA, camera and WebSocket tokens still pending under the name.
  6. """
  7. from datetime import datetime, timedelta, timezone
  8. import jwt
  9. import pytest
  10. from httpx import AsyncClient
  11. from sqlalchemy import select, text
  12. from backend.app.core import auth
  13. from backend.app.models.auth_ephemeral import AuthEphemeralToken
  14. from backend.app.models.user import User
  15. pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
  16. PENDING = (
  17. "pre_auth",
  18. "oidc_exchange",
  19. "password_reset",
  20. "email_otp_setup",
  21. "slicer_download",
  22. "connect_code",
  23. "camera_stream",
  24. "websocket",
  25. )
  26. @pytest.fixture
  27. async def admin(async_client: AsyncClient) -> dict:
  28. await async_client.post(
  29. "/api/v1/auth/setup",
  30. json={"auth_enabled": True, "admin_username": "nameadmin", "admin_password": "AdminPass1!"},
  31. )
  32. login = await async_client.post("/api/v1/auth/login", json={"username": "nameadmin", "password": "AdminPass1!"})
  33. return {"Authorization": f"Bearer {login.json()['access_token']}"}
  34. def _old_session(username: str, issued: datetime) -> dict:
  35. """A JWT this server issued earlier for ``username``, still within its lifetime."""
  36. token = jwt.encode(
  37. {"sub": username, "exp": issued + timedelta(days=7), "jti": "old-" + username, "iat": issued},
  38. auth.SECRET_KEY,
  39. algorithm=auth.ALGORITHM,
  40. )
  41. return {"Authorization": f"Bearer {token}"}
  42. async def _create(client: AsyncClient, admin: dict, username: str) -> int:
  43. response = await client.post(
  44. "/api/v1/users/", headers=admin, json={"username": username, "password": "Userpass1!", "role": "user"}
  45. )
  46. assert response.status_code == 201, response.text
  47. return response.json()["id"]
  48. async def _pending(db, username: str) -> None:
  49. expires = datetime.now(timezone.utc) + timedelta(hours=1)
  50. db.add_all(
  51. [
  52. AuthEphemeralToken(token=f"{kind}-{username}", token_type=kind, username=username, expires_at=expires)
  53. for kind in (*PENDING, "revoked_jti")
  54. ]
  55. )
  56. await db.commit()
  57. async def _kinds(db, username: str) -> list[str]:
  58. rows = await db.execute(
  59. select(AuthEphemeralToken.token_type)
  60. .where(AuthEphemeralToken.username == username)
  61. .execution_options(populate_existing=True)
  62. )
  63. return sorted(rows.scalars().all())
  64. async def test_a_recreated_user_rejects_the_deleted_namesakes_sessions(async_client: AsyncClient, admin):
  65. alice = await _create(async_client, admin, "alice")
  66. old = _old_session("alice", datetime.now(timezone.utc) - timedelta(minutes=5))
  67. assert (await async_client.delete(f"/api/v1/users/{alice}", headers=admin)).status_code == 204
  68. await _create(async_client, admin, "alice")
  69. assert (await async_client.get("/api/v1/auth/me", headers=old)).status_code == 401
  70. async def test_a_new_user_records_when_it_started(db_session):
  71. """Every way of creating a user (admin, setup, LDAP, OIDC) goes through the model."""
  72. user = User(username="ldapuser", password_hash=None, role="user", auth_source="ldap")
  73. db_session.add(user)
  74. await db_session.commit()
  75. assert user.password_changed_at is not None
  76. @pytest.mark.parametrize("name", ["alice", "Ärger"])
  77. async def test_a_deleted_users_pending_tokens_go_but_logouts_stay(async_client: AsyncClient, admin, db_session, name):
  78. """A reset link, 2FA step, camera or WebSocket token of the deleted user would
  79. work for whoever is given the name next. The logout list keeps its entries,
  80. or a token logged out would work again."""
  81. # SQLite's lower() changes only ASCII letters: a name with an umlaut matched nothing.
  82. user = await _create(async_client, admin, name)
  83. await _pending(db_session, name)
  84. assert (await async_client.delete(f"/api/v1/users/{user}", headers=admin)).status_code == 204
  85. assert await _kinds(db_session, name) == ["revoked_jti"]
  86. async def test_a_user_renamed_to_a_deleted_name_rejects_its_sessions(async_client: AsyncClient, admin, db_session):
  87. carol_session = _old_session("carol", datetime.now(timezone.utc) - timedelta(minutes=5))
  88. bob = await _create(async_client, admin, "bob")
  89. # bob has had his password for a while; carol's session is younger.
  90. await db_session.execute(
  91. text("UPDATE users SET password_changed_at = :t WHERE id = :id"),
  92. {"t": datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=30), "id": bob},
  93. )
  94. await db_session.commit()
  95. response = await async_client.patch(f"/api/v1/users/{bob}", headers=admin, json={"username": "carol"})
  96. assert response.status_code == 200, response.text
  97. assert (await async_client.get("/api/v1/auth/me", headers=carol_session)).status_code == 401
  98. async def test_a_renamed_users_old_name_leaves_nothing_pending(async_client: AsyncClient, admin, db_session):
  99. """The next user given the old name would take over its reset link."""
  100. bob = await _create(async_client, admin, "bob")
  101. await _pending(db_session, "bob")
  102. response = await async_client.patch(f"/api/v1/users/{bob}", headers=admin, json={"username": "bobby"})
  103. assert response.status_code == 200, response.text
  104. assert await _kinds(db_session, "bob") == ["revoked_jti"]
  105. async def test_the_startup_cleanup_drops_tokens_of_users_that_are_gone(db_session):
  106. """Deleted before deletes took them along."""
  107. from backend.app.core.database import _drop_ephemeral_tokens_of_missing_owners
  108. db_session.add(User(username="Dave", password_hash="x", role="user"))
  109. await db_session.commit()
  110. await _pending(db_session, "ghost")
  111. await _pending(db_session, "dave") # names match without case
  112. db_session.add(
  113. AuthEphemeralToken(
  114. token="camera-key", token_type="camera_stream", username="", expires_at=datetime.now(timezone.utc)
  115. )
  116. ) # an API key's: no user
  117. await db_session.commit()
  118. await _drop_ephemeral_tokens_of_missing_owners(await db_session.connection())
  119. await db_session.commit()
  120. assert await _kinds(db_session, "ghost") == ["revoked_jti"]
  121. assert await _kinds(db_session, "dave") == sorted((*PENDING, "revoked_jti"))
  122. assert await _kinds(db_session, "") == ["camera_stream"]
  123. async def test_a_renamed_users_open_connections_close(async_client: AsyncClient, admin):
  124. """An open WebSocket keeps the name it connected with and looks its printers
  125. up by it again on every scope refresh: the next user given the old name."""
  126. from unittest.mock import AsyncMock, patch
  127. bob = await _create(async_client, admin, "bob")
  128. with patch("backend.app.api.routes.users.ws_manager.close_user", AsyncMock()) as close:
  129. response = await async_client.patch(f"/api/v1/users/{bob}", headers=admin, json={"username": "bobby"})
  130. assert response.status_code == 200, response.text
  131. close.assert_awaited_once_with(bob)
  132. async def test_a_rename_that_only_changes_case_counts_too(async_client: AsyncClient, admin, db_session):
  133. """SQLite compares only ASCII letters without case: 'Ärger' and 'ärger' are
  134. two names to it, so the old one can be given to someone else."""
  135. user = await _create(async_client, admin, "Ärger")
  136. await _pending(db_session, "Ärger")
  137. response = await async_client.patch(f"/api/v1/users/{user}", headers=admin, json={"username": "ärger"})
  138. assert response.status_code == 200, response.text
  139. assert await _kinds(db_session, "Ärger") == ["revoked_jti"]
  140. @pytest.mark.parametrize("lookup_fails", [False, True])
  141. async def test_a_websocket_whose_user_is_gone_by_connect_is_closed(async_client: AsyncClient, lookup_fails):
  142. """Renamed or deleted between the token check and the socket joining: the
  143. close by user id missed it, and it kept the old name for scope refreshes."""
  144. from types import SimpleNamespace
  145. from unittest.mock import AsyncMock, MagicMock, patch
  146. from fastapi import WebSocketDisconnect
  147. from backend.app.api.routes import websocket as ws_route
  148. from backend.app.core.printer_scope import ALL_PRINTERS
  149. from backend.app.core.websocket import ws_manager
  150. socket = MagicMock()
  151. socket.state = SimpleNamespace()
  152. socket.accept = AsyncMock()
  153. socket.close = AsyncMock()
  154. socket.send_json = AsyncMock()
  155. socket.receive_json = AsyncMock(side_effect=WebSocketDisconnect())
  156. from backend.app.core import database
  157. session = database.async_session # the test database (the fixture patches it)
  158. if lookup_fails:
  159. calls = {"n": 0}
  160. def session_then_failure():
  161. calls["n"] += 1
  162. if calls["n"] >= 3: # auth probe, scope, then the user lookup
  163. raise OSError("database gone")
  164. return session()
  165. with (
  166. patch.object(ws_route, "is_auth_enabled", AsyncMock(return_value=True)),
  167. patch.object(ws_route, "verify_websocket_token_principal", AsyncMock(return_value=("bob", None))),
  168. patch.object(ws_route, "principal_printer_scope", AsyncMock(return_value=ALL_PRINTERS)),
  169. patch.object(ws_route, "async_session", session_then_failure if lookup_fails else session),
  170. ):
  171. await ws_route.websocket_endpoint(socket, token="t")
  172. socket.close.assert_awaited_once_with(code=4401)
  173. assert socket not in ws_manager.active_connections
  174. async def test_a_password_set_by_an_admin_ends_the_old_sessions(async_client: AsyncClient, admin, db_session):
  175. """An admin setting a password is often taking an account back: the
  176. attacker's JWT and a pending reset link must stop working."""
  177. bob = await _create(async_client, admin, "bob")
  178. session = _old_session("bob", datetime.now(timezone.utc) - timedelta(minutes=5))
  179. await db_session.execute(
  180. text("UPDATE users SET password_changed_at = :t WHERE id = :id"),
  181. {"t": datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=30), "id": bob},
  182. )
  183. await db_session.commit()
  184. await _pending(db_session, "bob")
  185. assert (await async_client.get("/api/v1/auth/me", headers=session)).status_code == 200
  186. response = await async_client.patch(f"/api/v1/users/{bob}", headers=admin, json={"password": "Newpass12!"})
  187. assert response.status_code == 200, response.text
  188. assert (await async_client.get("/api/v1/auth/me", headers=session)).status_code == 401
  189. assert await _kinds(db_session, "bob") == ["revoked_jti"]
  190. async def test_a_rename_landing_while_a_websocket_joins_closes_it(async_client: AsyncClient, admin, db_session):
  191. """The id is read, then the socket joins the list. A rename committing in
  192. between closes the user's sockets by id before this one is listed."""
  193. from types import SimpleNamespace
  194. from unittest.mock import AsyncMock, MagicMock, patch
  195. from fastapi import WebSocketDisconnect
  196. from backend.app.api.routes import websocket as ws_route
  197. from backend.app.core import database
  198. from backend.app.core.printer_scope import ALL_PRINTERS
  199. from backend.app.core.websocket import ws_manager
  200. bob = await _create(async_client, admin, "bob")
  201. socket = MagicMock()
  202. socket.state = SimpleNamespace()
  203. socket.accept = AsyncMock()
  204. socket.close = AsyncMock()
  205. socket.send_json = AsyncMock()
  206. socket.receive_json = AsyncMock(side_effect=WebSocketDisconnect())
  207. joined = ws_manager.connect
  208. async def join_while_renamed(websocket):
  209. await db_session.execute(text("UPDATE users SET username = 'bobby' WHERE id = :id"), {"id": bob})
  210. await db_session.commit()
  211. await ws_manager.close_user(bob) # what the rename does after its commit
  212. await joined(websocket)
  213. with (
  214. patch.object(ws_route, "is_auth_enabled", AsyncMock(return_value=True)),
  215. patch.object(ws_route, "verify_websocket_token_principal", AsyncMock(return_value=("bob", None))),
  216. patch.object(ws_route, "principal_printer_scope", AsyncMock(return_value=ALL_PRINTERS)),
  217. patch.object(ws_route, "async_session", database.async_session),
  218. patch.object(ws_manager, "connect", join_while_renamed),
  219. ):
  220. await ws_route.websocket_endpoint(socket, token="t")
  221. socket.close.assert_awaited_once_with(code=4401)
  222. assert socket not in ws_manager.active_connections
  223. async def test_a_password_set_by_an_admin_closes_open_connections(async_client: AsyncClient, admin):
  224. """Open dashboards are not signed in again: they would be whoever held the account."""
  225. from unittest.mock import AsyncMock, patch
  226. bob = await _create(async_client, admin, "bob")
  227. with patch("backend.app.api.routes.users.ws_manager.close_user", AsyncMock()) as close:
  228. response = await async_client.patch(f"/api/v1/users/{bob}", headers=admin, json={"password": "Newpass12!"})
  229. assert response.status_code == 200, response.text
  230. close.assert_awaited_once_with(bob)