test_auth.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. import tests # Initialize temporary logging before importing the application.
  2. import unittest
  3. from datetime import timedelta
  4. from unittest.mock import patch
  5. from fastapi.testclient import TestClient
  6. from app.config import settings
  7. from app.ldap import DirectoryUser, normalize_username
  8. from app.main import app
  9. from app.oauth_token import InvalidSessionToken, create_access_token, decode_access_token, token_needs_renewal, utcnow
  10. from app.permissions import Entitlement, highest_role
  11. def rights(role: str = "Mitarbeiter", priority: int = 100):
  12. return (
  13. Entitlement("1", "10", None, role, priority),
  14. Entitlement("1", "20", "Service", "Mitarbeiter", 100),
  15. )
  16. class UsernameTests(unittest.TestCase):
  17. def test_supported_ad_name_formats_are_normalized(self):
  18. self.assertEqual(normalize_username(r"DOMAIN\Max.Mustermann"), "max.mustermann")
  19. self.assertEqual(normalize_username("Max.Mustermann@example.local"), "max.mustermann")
  20. self.assertEqual(normalize_username("Max.Mustermann"), "max.mustermann")
  21. def test_invalid_username_is_rejected(self):
  22. with self.assertRaises(ValueError):
  23. normalize_username("../../admin")
  24. class TokenTests(unittest.TestCase):
  25. def test_token_contains_required_identity_and_session_claims(self):
  26. token = create_access_token(
  27. username="max.mustermann",
  28. name="Max Mustermann",
  29. email="max@example.local",
  30. role="Mitarbeiter",
  31. auth_source="ldap",
  32. )
  33. payload = decode_access_token(token)
  34. self.assertEqual(payload["sub"], "max.mustermann")
  35. self.assertEqual(payload["role"], "Mitarbeiter")
  36. self.assertEqual(payload["auth_source"], "ldap")
  37. self.assertLessEqual(payload["exp"], payload["session_exp"])
  38. self.assertTrue(payload["csrf"])
  39. def test_role_change_requires_immediate_renewal(self):
  40. token = create_access_token(
  41. username="max.mustermann",
  42. name="Max Mustermann",
  43. email="",
  44. role="Mitarbeiter",
  45. auth_source="iis",
  46. )
  47. self.assertTrue(token_needs_renewal(decode_access_token(token), "Buchhaltung"))
  48. def test_active_token_is_not_renewed_too_early(self):
  49. now = utcnow()
  50. token = create_access_token(
  51. username="max.mustermann",
  52. name="Max Mustermann",
  53. email="",
  54. role="Mitarbeiter",
  55. auth_source="iis",
  56. now=now,
  57. )
  58. self.assertFalse(token_needs_renewal(decode_access_token(token), "Mitarbeiter", now + timedelta(minutes=1)))
  59. def test_absolute_session_expiry_is_enforced(self):
  60. token = create_access_token(
  61. username="max.mustermann",
  62. name="Max Mustermann",
  63. email="",
  64. role="Mitarbeiter",
  65. auth_source="iis",
  66. now=utcnow() - timedelta(hours=9),
  67. )
  68. with self.assertRaises(InvalidSessionToken):
  69. decode_access_token(token)
  70. class PermissionTests(unittest.TestCase):
  71. def test_highest_role_uses_configured_priority(self):
  72. entitlements = (
  73. Entitlement("1", "10", None, "Mitarbeiter", 100),
  74. Entitlement("1", "20", None, "Buchhaltung", 400),
  75. Entitlement("1", "30", None, "Standortleitung", 300),
  76. )
  77. self.assertEqual(highest_role(entitlements), "Buchhaltung")
  78. def test_no_explicit_rights_are_rejected(self):
  79. with self.assertRaises(Exception) as context:
  80. highest_role(())
  81. self.assertEqual(context.exception.status_code, 403)
  82. class AuthenticationFlowTests(unittest.TestCase):
  83. def setUp(self):
  84. debug_config = patch.object(settings, "DEBUG_AUTH", False)
  85. debug_config.start()
  86. self.addCleanup(debug_config.stop)
  87. self.old_proxies = list(settings.TRUSTED_PROXY_IPS)
  88. self.old_secure = settings.COOKIE_SECURE
  89. settings.TRUSTED_PROXY_IPS = ["testclient"]
  90. settings.COOKIE_SECURE = True
  91. self.directory_user = DirectoryUser(
  92. username="max.mustermann",
  93. distinguished_name="CN=Max Mustermann,DC=example,DC=local",
  94. name="Max Mustermann",
  95. email="max@example.local",
  96. )
  97. def tearDown(self):
  98. settings.TRUSTED_PROXY_IPS = self.old_proxies
  99. settings.COOKIE_SECURE = self.old_secure
  100. def test_missing_identity_redirects_to_login(self):
  101. with TestClient(app, base_url="https://testserver") as client:
  102. response = client.get("/select?from=test", follow_redirects=False)
  103. self.assertEqual(response.status_code, 303)
  104. self.assertIn("/login?next=", response.headers["location"])
  105. def test_htmx_request_receives_full_page_redirect_header(self):
  106. with TestClient(app, base_url="https://testserver") as client:
  107. response = client.get("/select", headers={"HX-Request": "true"}, follow_redirects=False)
  108. self.assertEqual(response.status_code, 200)
  109. self.assertIn("/login?next=", response.headers["HX-Redirect"])
  110. def test_untrusted_identity_header_is_ignored(self):
  111. settings.TRUSTED_PROXY_IPS = ["127.0.0.1"]
  112. with TestClient(app, base_url="https://testserver") as client:
  113. response = client.get(
  114. "/select", headers={"X-Remote-User": r"DOMAIN\Max.Mustermann"}, follow_redirects=False
  115. )
  116. self.assertEqual(response.status_code, 303)
  117. @patch("app.auth.load_entitlements", return_value=rights())
  118. @patch("app.auth.lookup_directory_user")
  119. def test_trusted_iis_identity_creates_secure_session(self, lookup, _load):
  120. lookup.return_value = self.directory_user
  121. with TestClient(app, base_url="https://testserver") as client:
  122. response = client.get("/select", headers={"X-Remote-User": r"DOMAIN\Max.Mustermann"})
  123. self.assertEqual(response.status_code, 200)
  124. cookie = response.headers.get("set-cookie", "")
  125. self.assertIn("gcepic_session=", cookie)
  126. self.assertIn("HttpOnly", cookie)
  127. self.assertIn("Secure", cookie)
  128. self.assertIn("SameSite=lax", cookie)
  129. @patch("app.auth.load_entitlements", return_value=())
  130. @patch("app.auth.lookup_directory_user")
  131. def test_iis_user_without_explicit_rights_is_forbidden(self, lookup, _load):
  132. lookup.return_value = self.directory_user
  133. with TestClient(app, base_url="https://testserver") as client:
  134. response = client.get("/select", headers={"X-Remote-User": r"DOMAIN\Max.Mustermann"})
  135. self.assertEqual(response.status_code, 403)
  136. @patch("app.routes.load_entitlements", return_value=rights())
  137. @patch("app.routes.ldap_authenticate")
  138. def test_ldap_fallback_creates_session(self, authenticate, _load):
  139. authenticate.return_value = self.directory_user
  140. with TestClient(app, base_url="https://testserver") as client:
  141. login_page = client.get("/login")
  142. csrf = client.cookies[settings.LOGIN_CSRF_COOKIE_NAME]
  143. response = client.post(
  144. "/login",
  145. data={"username": "max.mustermann", "password": "secret", "_csrf": csrf, "next": "/select"},
  146. follow_redirects=False,
  147. )
  148. self.assertEqual(login_page.status_code, 200)
  149. self.assertEqual(response.status_code, 303)
  150. self.assertEqual(response.headers["location"], "/select")
  151. self.assertIn("gcepic_session=", response.headers.get("set-cookie", ""))
  152. @patch("app.auth.load_entitlements", return_value=rights())
  153. @patch("app.auth.lookup_directory_user")
  154. def test_logout_requires_csrf_and_deletes_session(self, lookup, _load):
  155. lookup.return_value = self.directory_user
  156. with TestClient(app, base_url="https://testserver") as client:
  157. login = client.get("/select", headers={"X-Remote-User": r"DOMAIN\Max.Mustermann"})
  158. token = login.cookies[settings.SESSION_COOKIE_NAME]
  159. csrf = decode_access_token(token)["csrf"]
  160. rejected = client.post("/logout")
  161. accepted = client.post("/logout", data={"_csrf": csrf})
  162. self.assertEqual(rejected.status_code, 403)
  163. self.assertEqual(accepted.status_code, 200)
  164. self.assertIn("Max-Age=0", accepted.headers.get("set-cookie", ""))
  165. if __name__ == "__main__":
  166. unittest.main()