test_session_policy.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. """Integration tests for the admin-set session-lifetime ceiling (#1706).
  2. Covers the four token-issuance sites that read ``session_max_hours``:
  3. plain login, 2FA backup-code login, 2FA TOTP/email login, OIDC login.
  4. Only the first is exercised end-to-end via ``async_client``; the helper
  5. ``resolve_session_max_minutes`` itself is unit-tested below so the MFA
  6. and OIDC paths inherit the same clamping behaviour by construction.
  7. """
  8. import time
  9. import jwt
  10. import pytest
  11. from httpx import AsyncClient
  12. from sqlalchemy import select
  13. from sqlalchemy.ext.asyncio import AsyncSession
  14. from backend.app.core.auth import (
  15. ACCESS_TOKEN_EXPIRE_MINUTES,
  16. ALGORITHM,
  17. SECRET_KEY,
  18. SESSION_MAX_HOURS_HARD_CEILING,
  19. resolve_session_max_minutes,
  20. )
  21. from backend.app.models.settings import Settings
  22. async def _set_session_max_hours(db: AsyncSession, value: str | None) -> None:
  23. """Upsert the session_max_hours setting row (value=None deletes it)."""
  24. result = await db.execute(select(Settings).where(Settings.key == "session_max_hours"))
  25. existing = result.scalar_one_or_none()
  26. if value is None:
  27. if existing is not None:
  28. await db.delete(existing)
  29. await db.commit()
  30. return
  31. if existing is None:
  32. db.add(Settings(key="session_max_hours", value=value))
  33. else:
  34. existing.value = value
  35. await db.commit()
  36. class TestResolveSessionMaxMinutes:
  37. """Unit-style tests for the clamping resolver."""
  38. @pytest.mark.asyncio
  39. @pytest.mark.integration
  40. async def test_missing_row_returns_24h_default(self, db_session: AsyncSession):
  41. await _set_session_max_hours(db_session, None)
  42. assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES
  43. assert ACCESS_TOKEN_EXPIRE_MINUTES == 60 * 24
  44. @pytest.mark.asyncio
  45. @pytest.mark.integration
  46. async def test_empty_string_returns_24h_default(self, db_session: AsyncSession):
  47. await _set_session_max_hours(db_session, "")
  48. assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES
  49. @pytest.mark.asyncio
  50. @pytest.mark.integration
  51. async def test_unparseable_value_returns_24h_default(self, db_session: AsyncSession):
  52. await _set_session_max_hours(db_session, "not-a-number")
  53. assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES
  54. @pytest.mark.asyncio
  55. @pytest.mark.integration
  56. async def test_zero_or_negative_returns_24h_default(self, db_session: AsyncSession):
  57. await _set_session_max_hours(db_session, "0")
  58. assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES
  59. await _set_session_max_hours(db_session, "-5")
  60. assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES
  61. @pytest.mark.asyncio
  62. @pytest.mark.integration
  63. async def test_one_hour_minimum(self, db_session: AsyncSession):
  64. await _set_session_max_hours(db_session, "1")
  65. assert await resolve_session_max_minutes(db_session) == 60
  66. @pytest.mark.asyncio
  67. @pytest.mark.integration
  68. async def test_seven_days_passes_through(self, db_session: AsyncSession):
  69. await _set_session_max_hours(db_session, "168")
  70. assert await resolve_session_max_minutes(db_session) == 168 * 60
  71. @pytest.mark.asyncio
  72. @pytest.mark.integration
  73. async def test_thirty_days_passes_through(self, db_session: AsyncSession):
  74. await _set_session_max_hours(db_session, str(SESSION_MAX_HOURS_HARD_CEILING))
  75. assert await resolve_session_max_minutes(db_session) == SESSION_MAX_HOURS_HARD_CEILING * 60
  76. @pytest.mark.asyncio
  77. @pytest.mark.integration
  78. async def test_above_ceiling_is_clamped_to_30_days(self, db_session: AsyncSession):
  79. """Defense-in-depth: a tampered settings row above 720h must be clamped."""
  80. await _set_session_max_hours(db_session, "99999")
  81. assert await resolve_session_max_minutes(db_session) == SESSION_MAX_HOURS_HARD_CEILING * 60
  82. class TestLoginRespectsSessionPolicy:
  83. """The /auth/login route must honour the resolved ceiling."""
  84. @pytest.mark.asyncio
  85. @pytest.mark.integration
  86. async def test_login_uses_default_24h_when_unset(self, async_client: AsyncClient, db_session: AsyncSession):
  87. await async_client.post(
  88. "/api/v1/auth/setup",
  89. json={
  90. "auth_enabled": True,
  91. "admin_username": "sessiontest1",
  92. "admin_password": "SessionPass1!",
  93. },
  94. )
  95. await _set_session_max_hours(db_session, None)
  96. before = int(time.time())
  97. response = await async_client.post(
  98. "/api/v1/auth/login",
  99. json={"username": "sessiontest1", "password": "SessionPass1!"},
  100. )
  101. after = int(time.time())
  102. assert response.status_code == 200
  103. token = response.json()["access_token"]
  104. decoded = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
  105. # exp should be ~24h ahead. Allow generous bounds for clock drift.
  106. expected_min = before + 24 * 3600 - 60
  107. expected_max = after + 24 * 3600 + 60
  108. assert expected_min <= decoded["exp"] <= expected_max
  109. @pytest.mark.asyncio
  110. @pytest.mark.integration
  111. async def test_login_uses_configured_7d_ceiling(self, async_client: AsyncClient, db_session: AsyncSession):
  112. await async_client.post(
  113. "/api/v1/auth/setup",
  114. json={
  115. "auth_enabled": True,
  116. "admin_username": "sessiontest2",
  117. "admin_password": "SessionPass2!",
  118. },
  119. )
  120. await _set_session_max_hours(db_session, "168") # 7 days
  121. before = int(time.time())
  122. response = await async_client.post(
  123. "/api/v1/auth/login",
  124. json={"username": "sessiontest2", "password": "SessionPass2!"},
  125. )
  126. after = int(time.time())
  127. assert response.status_code == 200
  128. token = response.json()["access_token"]
  129. decoded = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
  130. expected_min = before + 168 * 3600 - 60
  131. expected_max = after + 168 * 3600 + 60
  132. assert expected_min <= decoded["exp"] <= expected_max
  133. @pytest.mark.asyncio
  134. @pytest.mark.integration
  135. async def test_login_clamps_above_ceiling(self, async_client: AsyncClient, db_session: AsyncSession):
  136. """A settings row above the 720h ceiling must be clamped at login time."""
  137. await async_client.post(
  138. "/api/v1/auth/setup",
  139. json={
  140. "auth_enabled": True,
  141. "admin_username": "sessiontest3",
  142. "admin_password": "SessionPass3!",
  143. },
  144. )
  145. await _set_session_max_hours(db_session, "5000") # would be ~208 days
  146. before = int(time.time())
  147. response = await async_client.post(
  148. "/api/v1/auth/login",
  149. json={"username": "sessiontest3", "password": "SessionPass3!"},
  150. )
  151. after = int(time.time())
  152. assert response.status_code == 200
  153. token = response.json()["access_token"]
  154. decoded = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
  155. # Clamped to 30 days, not 5000 hours.
  156. expected_min = before + SESSION_MAX_HOURS_HARD_CEILING * 3600 - 60
  157. expected_max = after + SESSION_MAX_HOURS_HARD_CEILING * 3600 + 60
  158. assert expected_min <= decoded["exp"] <= expected_max
  159. class TestSettingsAPIExposesSessionMaxHours:
  160. """The /settings API must round-trip session_max_hours as an int."""
  161. @pytest.mark.asyncio
  162. @pytest.mark.integration
  163. async def test_default_is_24(self, async_client: AsyncClient, db_session: AsyncSession):
  164. await _set_session_max_hours(db_session, None)
  165. response = await async_client.get("/api/v1/settings/")
  166. assert response.status_code == 200
  167. assert response.json()["session_max_hours"] == 24
  168. @pytest.mark.asyncio
  169. @pytest.mark.integration
  170. async def test_update_accepts_valid_value(self, async_client: AsyncClient, db_session: AsyncSession):
  171. response = await async_client.patch(
  172. "/api/v1/settings/",
  173. json={"session_max_hours": 168},
  174. )
  175. assert response.status_code == 200
  176. assert response.json()["session_max_hours"] == 168
  177. # Persisted as the int's string form so the resolver round-trips.
  178. result = await db_session.execute(select(Settings).where(Settings.key == "session_max_hours"))
  179. row = result.scalar_one()
  180. assert row.value == "168"
  181. @pytest.mark.asyncio
  182. @pytest.mark.integration
  183. async def test_update_rejects_zero(self, async_client: AsyncClient):
  184. response = await async_client.patch(
  185. "/api/v1/settings/",
  186. json={"session_max_hours": 0},
  187. )
  188. assert response.status_code == 422
  189. @pytest.mark.asyncio
  190. @pytest.mark.integration
  191. async def test_update_rejects_above_ceiling(self, async_client: AsyncClient):
  192. response = await async_client.patch(
  193. "/api/v1/settings/",
  194. json={"session_max_hours": SESSION_MAX_HOURS_HARD_CEILING + 1},
  195. )
  196. assert response.status_code == 422