test_token_outlived_session.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. """A one-time token minted before its user's session began is refused.
  2. Deleting or renaming a user drops the tokens pending under the name in its
  3. transaction; one minted in the milliseconds around it (a WebSocket token from
  4. the old user's still-valid JWT, a forgot-password link) kept the name and
  5. worked for whoever was given it next. Every token named after a user is now
  6. refused if it was minted before that user's password_changed_at, which a new
  7. user, a rename, a password change or reset, a 2FA removal and a restore set.
  8. """
  9. from datetime import datetime, timedelta, timezone
  10. import pytest
  11. from httpx import AsyncClient
  12. from sqlalchemy import text
  13. from backend.app.core import auth
  14. from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
  15. pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
  16. @pytest.fixture
  17. async def carol(async_client: AsyncClient, db_session) -> int:
  18. await async_client.post(
  19. "/api/v1/auth/setup",
  20. json={"auth_enabled": True, "admin_username": "tokadmin", "admin_password": "AdminPass1!"},
  21. )
  22. login = await async_client.post("/api/v1/auth/login", json={"username": "tokadmin", "password": "AdminPass1!"})
  23. admin = {"Authorization": f"Bearer {login.json()['access_token']}"}
  24. groups = (await async_client.get("/api/v1/groups/", headers=admin)).json()
  25. viewers = next(g["id"] for g in groups if g["name"] == "Viewers")
  26. user = await async_client.post(
  27. "/api/v1/users/",
  28. headers=admin,
  29. json={"username": "carol", "password": "Userpass1!", "role": "user", "group_ids": [viewers]},
  30. )
  31. carol_id = user.json()["id"]
  32. # Carol's current session began 30 minutes ago (a rename, a new account...).
  33. await db_session.execute(
  34. text("UPDATE users SET password_changed_at = :t WHERE id = :id"),
  35. {"t": _ago(minutes=30), "id": carol_id},
  36. )
  37. await db_session.commit()
  38. return carol_id
  39. def _ago(**delta) -> datetime:
  40. return datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(**delta)
  41. async def _token(db, kind: str, *, minted_ago_minutes: int, **fields) -> str:
  42. value = f"{kind}-{minted_ago_minutes}"
  43. db.add(
  44. AuthEphemeralToken(
  45. token=value,
  46. token_type=kind,
  47. username=fields.pop("username", "carol"),
  48. created_at=_ago(minutes=minted_ago_minutes),
  49. expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
  50. **fields,
  51. )
  52. )
  53. await db.commit()
  54. return value
  55. @pytest.mark.parametrize(
  56. ("kind", "verify"),
  57. [
  58. ("websocket", lambda t: auth.verify_websocket_token_principal(t)),
  59. ("camera_stream", lambda t: auth.camera_stream_access(t)),
  60. ("media", lambda t: auth.verify_media_token(t)),
  61. ],
  62. )
  63. async def test_a_reusable_token_from_before_the_session_is_refused(carol, db_session, kind, verify):
  64. stale = await _token(db_session, kind, minted_ago_minutes=60)
  65. fresh = await _token(db_session, kind, minted_ago_minutes=10)
  66. assert await verify(stale) is None
  67. assert await verify(fresh) is not None
  68. async def test_a_pre_auth_token_from_before_the_session_is_refused(carol, db_session):
  69. from backend.app.api.routes.mfa import consume_pre_auth_token, peek_pre_auth_token
  70. stale = await _token(db_session, TokenType.PRE_AUTH, minted_ago_minutes=60)
  71. fresh = await _token(db_session, TokenType.PRE_AUTH, minted_ago_minutes=10)
  72. assert await peek_pre_auth_token(db_session, stale) is None
  73. assert await consume_pre_auth_token(db_session, stale) is None
  74. assert await consume_pre_auth_token(db_session, fresh) == "carol"
  75. async def test_a_reset_link_from_before_the_session_is_refused(async_client: AsyncClient, carol, db_session):
  76. stale = await _token(db_session, TokenType.PASSWORD_RESET, minted_ago_minutes=60)
  77. response = await async_client.post(
  78. "/api/v1/auth/forgot-password/confirm", json={"token": stale, "new_password": "Newpass12!"}
  79. )
  80. assert response.status_code == 400, response.text
  81. async def test_an_oidc_exchange_token_from_before_the_session_is_refused(async_client: AsyncClient, carol, db_session):
  82. stale = await _token(db_session, TokenType.OIDC_EXCHANGE, minted_ago_minutes=60)
  83. response = await async_client.post("/api/v1/auth/oidc/exchange", json={"oidc_token": stale})
  84. assert response.status_code == 401, response.text
  85. async def test_an_email_otp_setup_token_from_before_the_session_is_refused(
  86. async_client: AsyncClient, carol, db_session
  87. ):
  88. from backend.app.api.routes.mfa import pwd_context
  89. stale = await _token(db_session, TokenType.EMAIL_OTP_SETUP, minted_ago_minutes=60, nonce=pwd_context.hash("123456"))
  90. login = await async_client.post("/api/v1/auth/login", json={"username": "carol", "password": "Userpass1!"})
  91. response = await async_client.post(
  92. "/api/v1/auth/2fa/email/enable/confirm",
  93. headers={"Authorization": f"Bearer {login.json()['access_token']}"},
  94. json={"setup_token": stale, "code": "123456"},
  95. )
  96. assert response.status_code == 400, response.text
  97. async def test_a_connect_code_from_before_the_session_is_refused(async_client: AsyncClient, carol, db_session):
  98. from backend.tests.integration.test_connected_apps import _exchange, _pkce, _register
  99. login = await async_client.post("/api/v1/auth/login", json={"username": "tokadmin", "password": "AdminPass1!"})
  100. app = await _register(async_client, login.json()["access_token"])
  101. verifier, challenge = _pkce()
  102. code = "connect-code-stale"
  103. from backend.app.api.routes.connected_apps import _hash_code
  104. db_session.add(
  105. AuthEphemeralToken.new_connect_code(
  106. code_hash=_hash_code(code),
  107. username="carol",
  108. app_id=app["id"],
  109. code_challenge=challenge,
  110. expires_at=datetime.now(timezone.utc) + timedelta(minutes=1),
  111. )
  112. )
  113. await db_session.commit()
  114. await db_session.execute(
  115. text("UPDATE auth_ephemeral_tokens SET created_at = :t WHERE token = :h"),
  116. {"t": _ago(minutes=60), "h": _hash_code(code)},
  117. )
  118. await db_session.commit()
  119. response = await _exchange(async_client, app, code, verifier)
  120. assert response.status_code == 400, response.text
  121. async def test_every_token_kind_named_after_a_user_is_covered():
  122. """A new kind must get the check (and a test above)."""
  123. covered = {
  124. TokenType.WEBSOCKET.value,
  125. TokenType.CAMERA_STREAM.value,
  126. TokenType.MEDIA.value,
  127. TokenType.PRE_AUTH.value,
  128. TokenType.PASSWORD_RESET.value,
  129. TokenType.OIDC_EXCHANGE.value,
  130. TokenType.EMAIL_OTP_SETUP.value,
  131. TokenType.CONNECT_CODE.value,
  132. }
  133. named_by_nobody = {
  134. TokenType.OIDC_STATE.value, # before anyone is signed in
  135. TokenType.SLICER_DOWNLOAD.value, # bound to a resource, not a user
  136. TokenType.REVOKED_JTI.value, # the logout list, not a credential
  137. }
  138. assert {kind.value for kind in TokenType} - named_by_nobody <= covered
  139. async def test_every_token_kind_in_the_code_is_in_the_enum():
  140. """A kind written as a bare string would escape the check above."""
  141. import re
  142. from pathlib import Path
  143. import backend.app as app_pkg
  144. root = Path(app_pkg.__file__).parent
  145. kinds = set()
  146. for path in root.rglob("*.py"):
  147. kinds.update(re.findall(r'token_type\s*(?:==|=)\s*"([a-z_]+)"', path.read_text()))
  148. kinds.discard("bearer") # the OAuth token_type of a login response, not a stored kind
  149. assert kinds <= {kind.value for kind in TokenType}, kinds - {kind.value for kind in TokenType}
  150. async def test_a_token_of_a_user_that_does_not_exist_is_refused(carol, db_session):
  151. """Callers look the user up again on their own: a recreate in between would
  152. otherwise be served as the new user."""
  153. stale = await _token(db_session, "media", minted_ago_minutes=10, username="ghost")
  154. assert await auth.verify_media_token(stale) is None
  155. async def test_a_token_from_the_same_second_but_before_the_session_is_refused(carol, db_session):
  156. """Both timestamps keep microseconds; nothing writes a user and its first
  157. token in one flush, so there is no reason to round."""
  158. session_start = datetime.now(timezone.utc).replace(tzinfo=None, microsecond=600000)
  159. await db_session.execute(
  160. text("UPDATE users SET password_changed_at = :t WHERE id = :id"), {"t": session_start, "id": carol}
  161. )
  162. db_session.add(
  163. AuthEphemeralToken(
  164. token="same-second",
  165. token_type="media",
  166. username="carol",
  167. created_at=session_start.replace(microsecond=300000),
  168. expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
  169. )
  170. )
  171. await db_session.commit()
  172. assert await auth.verify_media_token("same-second") is None
  173. async def test_a_token_minted_as_the_session_ends_is_dated_by_its_session(carol, db_session):
  174. """A stolen JWT keeps minting while the owner changes the password: a mint
  175. checked before the change committed wrote its row after. The token counts
  176. from when the session that minted it began, so it is refused."""
  177. import jwt as pyjwt
  178. from backend.app.models.user import User
  179. issued = datetime.now(timezone.utc) - timedelta(minutes=20) # the session began 20 min ago
  180. session_jwt = pyjwt.encode(
  181. {"sub": "carol", "iat": issued, "exp": issued + timedelta(days=1), "jti": "stolen"},
  182. auth.SECRET_KEY,
  183. algorithm=auth.ALGORITHM,
  184. )
  185. user = await db_session.get(User, carol)
  186. marker = auth._authenticated_user.set((session_jwt, user))
  187. try:
  188. token = await auth.create_websocket_token("carol")
  189. finally:
  190. auth._authenticated_user.reset(marker)
  191. # The password change, which started before the row was written, commits now.
  192. await db_session.execute(
  193. text("UPDATE users SET password_changed_at = :t WHERE id = :id"), {"t": _ago(minutes=5), "id": carol}
  194. )
  195. await db_session.commit()
  196. assert await auth.verify_websocket_token_principal(token) is None
  197. async def test_a_keys_token_does_not_pass_to_the_next_key_given_its_id(carol, db_session):
  198. """Minted while the key was deleted: SQLite gives the id to the next key."""
  199. from backend.app.models.api_key import APIKey
  200. key = APIKey(name="old", key_hash="h", key_prefix="bb_old1")
  201. db_session.add(key)
  202. await db_session.commit()
  203. key_id = key.id
  204. ws = await auth.create_websocket_token(None, key_id, api_key_prefix="bb_old1")
  205. cam = await auth.create_camera_stream_token(api_key_id=key_id, api_key_prefix="bb_old1")
  206. await db_session.execute(text("DELETE FROM api_keys WHERE id = :id"), {"id": key_id})
  207. await db_session.commit()
  208. db_session.add(APIKey(id=key_id, name="new", key_hash="h2", key_prefix="bb_new1"))
  209. await db_session.commit()
  210. assert await auth.verify_websocket_token_principal(ws) is None
  211. assert await auth.camera_stream_access(cam) is None
  212. async def test_a_long_lived_token_does_not_pass_to_the_next_user_given_its_id(carol, db_session):
  213. """Minted while its user was deleted: SQLite gives the id to the next user,
  214. and the token would stream with their printers for up to a year."""
  215. from backend.app.services.long_lived_tokens import create_token
  216. created = await create_token(db_session, user_id=carol, name="wall", expires_in_days=30, scope="camwall")
  217. await db_session.execute(
  218. text("UPDATE long_lived_tokens SET created_at = :t WHERE id = :id"),
  219. {"t": _ago(minutes=60), "id": created.record.id},
  220. )
  221. await db_session.execute(
  222. text("UPDATE users SET created_at = :t WHERE id = :id"), {"t": _ago(minutes=1), "id": carol}
  223. )
  224. await db_session.commit() # carol now stands for the user created after the token
  225. assert await auth.verify_camwall_token(created.plaintext) is None
  226. assert await auth.camera_stream_access(created.plaintext) is None
  227. async def _session_then_password_change(client: AsyncClient, db, carol_id: int, mint) -> str:
  228. """Sign carol in, mint through the real route a second later, then commit a
  229. password change dated between the session's start and the token's row.
  230. Returns the token the route handed out."""
  231. import asyncio
  232. login = await client.post("/api/v1/auth/login", json={"username": "carol", "password": "Userpass1!"})
  233. headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
  234. await asyncio.sleep(1.1) # the row is written in a later second than the session began
  235. token = await mint(headers)
  236. # The change commits just before the request wrote its row.
  237. changed = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(milliseconds=50)
  238. await db.execute(text("UPDATE users SET password_changed_at = :t WHERE id = :id"), {"t": changed, "id": carol_id})
  239. await db.commit()
  240. return token
  241. @pytest.mark.parametrize(
  242. ("route", "verify"),
  243. [
  244. ("/api/v1/auth/ws-token", lambda t: auth.verify_websocket_token_principal(t)),
  245. ("/api/v1/auth/media-token", lambda t: auth.verify_media_token(t)),
  246. ("/api/v1/printers/camera/stream-token", lambda t: auth.camera_stream_access(t)),
  247. ],
  248. )
  249. async def test_a_token_minted_through_the_route_counts_from_its_session(
  250. async_client: AsyncClient, carol, db_session, route, verify
  251. ):
  252. async def mint(headers):
  253. response = await async_client.post(route, headers=headers)
  254. assert response.status_code == 200, response.text
  255. return response.json()["token"]
  256. token = await _session_then_password_change(async_client, db_session, carol, mint)
  257. assert await verify(token) is None
  258. async def test_a_connect_code_counts_from_its_session(async_client: AsyncClient, carol, db_session):
  259. from backend.tests.integration.test_connected_apps import _authorize, _exchange, _pkce, _register
  260. login = await async_client.post("/api/v1/auth/login", json={"username": "tokadmin", "password": "AdminPass1!"})
  261. app = await _register(async_client, login.json()["access_token"])
  262. verifier, challenge = _pkce()
  263. codes = []
  264. async def mint(headers):
  265. response = await _authorize(async_client, headers["Authorization"].split()[1], app, challenge)
  266. assert response.status_code == 200, response.text
  267. codes.append(response.json()["code"])
  268. return codes[-1]
  269. code = await _session_then_password_change(async_client, db_session, carol, mint)
  270. assert (await _exchange(async_client, app, code, verifier)).status_code == 400
  271. async def test_an_email_otp_setup_token_counts_from_its_session(async_client: AsyncClient, carol, db_session):
  272. from unittest.mock import AsyncMock, MagicMock, patch
  273. await db_session.execute(text("UPDATE users SET email = 'carol@example.com' WHERE id = :id"), {"id": carol})
  274. await db_session.commit()
  275. async def mint(headers):
  276. with (
  277. patch("backend.app.api.routes.mfa.get_smtp_settings", AsyncMock(return_value=MagicMock())),
  278. patch("backend.app.api.routes.mfa.send_email", MagicMock()),
  279. ):
  280. response = await async_client.post("/api/v1/auth/2fa/email/enable", headers=headers)
  281. assert response.status_code == 200, response.text
  282. return response.json()["setup_token"]
  283. setup = await _session_then_password_change(async_client, db_session, carol, mint)
  284. written = await db_session.execute(
  285. text("SELECT created_at FROM auth_ephemeral_tokens WHERE token = :t"), {"t": setup}
  286. )
  287. changed = await db_session.execute(text("SELECT password_changed_at FROM users WHERE id = :id"), {"id": carol})
  288. assert written.scalar_one() < changed.scalar_one() # dated before the change, so refused on use
  289. async def test_a_connect_code_from_the_grant_race_retry_counts_from_its_session(
  290. async_client: AsyncClient, carol, db_session
  291. ):
  292. """Two consents at once: the loser rolls back and mints the code again."""
  293. from unittest.mock import patch
  294. from sqlalchemy.exc import IntegrityError
  295. from sqlalchemy.ext.asyncio import AsyncSession
  296. from backend.tests.integration.test_connected_apps import _authorize, _exchange, _pkce, _register
  297. login = await async_client.post("/api/v1/auth/login", json={"username": "tokadmin", "password": "AdminPass1!"})
  298. app = await _register(async_client, login.json()["access_token"])
  299. verifier, challenge = _pkce()
  300. real_commit = AsyncSession.commit
  301. raised = []
  302. async def commit_losing_the_grant_race(self):
  303. if not raised and any(getattr(o, "token_type", None) == TokenType.CONNECT_CODE for o in self.new):
  304. raised.append(1)
  305. raise IntegrityError("INSERT", {}, Exception("grant exists"))
  306. return await real_commit(self)
  307. async def mint(headers):
  308. with patch.object(AsyncSession, "commit", commit_losing_the_grant_race):
  309. response = await _authorize(async_client, headers["Authorization"].split()[1], app, challenge)
  310. assert response.status_code == 200, response.text
  311. return response.json()["code"]
  312. code = await _session_then_password_change(async_client, db_session, carol, mint)
  313. assert raised, "the retry path was not taken"
  314. assert (await _exchange(async_client, app, code, verifier)).status_code == 400