test_token_outlived_session.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  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. "websocket",
  125. "camera_stream",
  126. "media",
  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. }
  137. assert {kind.value for kind in TokenType} - named_by_nobody <= covered