test_ended_session_gates.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. """An ended session's JWT is refused by every kind of route gate.
  2. Each gate resolves the bearer token itself: the permission gate, the
  3. ownership gate (``*_own`` / ``*_all``), the any-of gate and the admin gate. A
  4. session ends at logout, at a password change (own or by an admin) and when
  5. the user is deactivated; before that the same token passes every gate.
  6. The API gateway (``auth_middleware``) refuses such a token before any route
  7. runs, and most routes stack more than one gate, so a broken gate would not show
  8. on a real route. Each gate is therefore also tried alone, on a route of its
  9. own that the gateway lets through.
  10. The session under test began 30 s ago: a token issued in the same second as a
  11. password change passes on purpose (JWT ``iat`` is whole seconds).
  12. """
  13. from datetime import timedelta
  14. import jwt
  15. import pytest
  16. from httpx import AsyncClient
  17. from sqlalchemy import update
  18. from backend.app.core import auth
  19. from backend.app.models.user import User
  20. from backend.app.utils.local_time import utcnow_naive
  21. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  22. PASSWORD = "Gatepass1!"
  23. def _auth(token: str) -> dict[str, str]:
  24. return {"Authorization": f"Bearer {token}"}
  25. async def _login(client: AsyncClient, username: str, password: str) -> str:
  26. response = await client.post("/api/v1/auth/login", json={"username": username, "password": password})
  27. assert response.status_code == 200, response.text
  28. return response.json()["access_token"]
  29. def _session_from_30_s_ago(username: str) -> str:
  30. issued = utcnow_naive() - timedelta(seconds=30)
  31. return jwt.encode(
  32. {"sub": username, "iat": issued, "exp": issued + timedelta(days=1), "jti": f"gates-{username}"},
  33. auth.SECRET_KEY,
  34. algorithm=auth.ALGORITHM,
  35. )
  36. @pytest.fixture
  37. async def users(async_client: AsyncClient, db_session) -> dict:
  38. """The first admin, and a second admin whose session the tests end."""
  39. await async_client.post(
  40. "/api/v1/auth/setup",
  41. json={"auth_enabled": True, "admin_username": "gateadmin", "admin_password": PASSWORD},
  42. )
  43. admin = await _login(async_client, "gateadmin", PASSWORD)
  44. created = await async_client.post(
  45. "/api/v1/users/",
  46. headers=_auth(admin),
  47. json={"username": "gateuser", "password": PASSWORD, "role": "admin"},
  48. )
  49. assert created.status_code == 201, created.text
  50. await db_session.execute(
  51. update(User)
  52. .where(User.username == "gateuser")
  53. .values(password_changed_at=utcnow_naive() - timedelta(minutes=1))
  54. )
  55. await db_session.commit()
  56. return {"admin": admin, "user_id": created.json()["id"], "jwt": _session_from_30_s_ago("gateuser")}
  57. async def _gates(client: AsyncClient, token: str, name: str) -> dict[str, int]:
  58. headers = _auth(token)
  59. return {
  60. "permission": (await client.get("/api/v1/printers/", headers=headers)).status_code,
  61. "ownership": (await client.get("/api/v1/queue/", headers=headers)).status_code,
  62. "any-of": (await client.get("/api/v1/inventory/spools/by-tag?tag_uid=00", headers=headers)).status_code,
  63. "admin": (
  64. await client.post("/api/v1/groups/", headers=headers, json={"name": name, "permissions": []})
  65. ).status_code,
  66. }
  67. async def _logout(client: AsyncClient, users: dict) -> None:
  68. assert (await client.post("/api/v1/auth/logout", headers=_auth(users["jwt"]))).status_code == 200
  69. async def _own_password_change(client: AsyncClient, users: dict) -> None:
  70. response = await client.post(
  71. "/api/v1/users/me/change-password",
  72. headers=_auth(users["jwt"]),
  73. json={"current_password": PASSWORD, "new_password": "Changed1!pass"},
  74. )
  75. assert response.status_code == 200, response.text
  76. async def _admin_password_change(client: AsyncClient, users: dict) -> None:
  77. response = await client.patch(
  78. f"/api/v1/users/{users['user_id']}", headers=_auth(users["admin"]), json={"password": "Changed1!pass"}
  79. )
  80. assert response.status_code == 200, response.text
  81. async def _deactivation(client: AsyncClient, users: dict) -> None:
  82. response = await client.patch(
  83. f"/api/v1/users/{users['user_id']}", headers=_auth(users["admin"]), json={"is_active": False}
  84. )
  85. assert response.status_code == 200, response.text
  86. @pytest.mark.parametrize("end_session", [_logout, _own_password_change, _admin_password_change, _deactivation])
  87. async def test_every_gate_refuses_an_ended_session(async_client: AsyncClient, users, end_session):
  88. before = await _gates(async_client, users["jwt"], "before")
  89. assert all(code != 401 for code in before.values()), before
  90. await end_session(async_client, users)
  91. after = await _gates(async_client, users["jwt"], "after")
  92. assert after == dict.fromkeys(after, 401)
  93. def _lone_gates():
  94. from fastapi import Depends
  95. from backend.app.core import auth as a
  96. from backend.app.core.permissions import Permission
  97. return {
  98. "gateway": None,
  99. "permission": a.RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  100. "any-of": a.RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_UPDATE),
  101. "ownership": Depends(a.require_ownership_permission(Permission.QUEUE_READ_ALL, Permission.QUEUE_READ_OWN)),
  102. "admin": a.RequireAdminIfAuthEnabled(),
  103. "user": Depends(a.get_current_user),
  104. "optional user": Depends(a.get_current_user_optional),
  105. "auth if enabled": Depends(a.require_auth_if_enabled),
  106. "key scope": Depends(
  107. a.require_api_key_scope("can_update_energy_cost", "update_energy_cost", Permission.SETTINGS_UPDATE)
  108. ),
  109. }
  110. LONE = "/api/v1/lone-gate-test/"
  111. @pytest.fixture
  112. def lone_gate_routes(monkeypatch):
  113. """One route per gate. All but "gateway" are let through by the gateway."""
  114. from backend.app import main
  115. routes = []
  116. for name, gate in _lone_gates().items():
  117. path = LONE + name.replace(" ", "-")
  118. main.app.add_api_route(path, lambda: {"ok": True}, dependencies=[gate] if gate else [])
  119. # Ahead of the SPA's catch-all route
  120. routes.append(main.app.router.routes.pop())
  121. main.app.router.routes.insert(0, routes[-1])
  122. monkeypatch.setattr(main, "PUBLIC_API_ROUTES", {*main.PUBLIC_API_ROUTES, *(r.path for r in routes[1:])})
  123. yield {name: route.path for name, route in zip(_lone_gates(), routes, strict=True)}
  124. for route in routes:
  125. main.app.router.routes.remove(route)
  126. @pytest.mark.parametrize("end_session", [_logout, _own_password_change, _admin_password_change, _deactivation])
  127. @pytest.mark.parametrize("gate", list(_lone_gates()))
  128. async def test_each_gate_alone_refuses_an_ended_session(
  129. async_client: AsyncClient, users, lone_gate_routes, end_session, gate
  130. ):
  131. path = lone_gate_routes[gate]
  132. headers = _auth(users["jwt"])
  133. assert (await async_client.get(path, headers=headers)).status_code == 200
  134. await end_session(async_client, users)
  135. # get_current_user answers a deactivated account with 403 "disabled"
  136. assert (await async_client.get(path, headers=headers)).status_code in (401, 403)