auth.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. import logging
  2. import secrets
  3. from urllib.parse import parse_qs, quote
  4. from fastapi import HTTPException, Request, status
  5. from fastapi.responses import JSONResponse, RedirectResponse, Response
  6. from starlette.middleware.base import BaseHTTPMiddleware
  7. from .config import settings
  8. from .db import SessionLocal
  9. from .ldap import DirectoryUnavailable, lookup_directory_user
  10. from .navigation import normalize_page_url
  11. from .oauth_token import (
  12. InvalidSessionToken,
  13. create_access_token,
  14. decode_access_token,
  15. renew_access_token,
  16. token_needs_renewal,
  17. )
  18. from .permissions import Principal, highest_role, load_entitlements
  19. logger = logging.getLogger(__name__)
  20. PUBLIC_PREFIXES = ("/static/",)
  21. PUBLIC_PATHS = {"/login", "/health"}
  22. UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
  23. def set_session_cookie(response, token: str) -> None:
  24. response.set_cookie(
  25. settings.SESSION_COOKIE_NAME,
  26. token,
  27. secure=settings.COOKIE_SECURE,
  28. httponly=True,
  29. samesite="lax",
  30. path="/",
  31. )
  32. def clear_session_cookie(response) -> None:
  33. response.delete_cookie(
  34. settings.SESSION_COOKIE_NAME,
  35. secure=settings.COOKIE_SECURE,
  36. httponly=True,
  37. samesite="lax",
  38. path="/",
  39. )
  40. def safe_next_url(value: str | None, default: str = "/select") -> str:
  41. return normalize_page_url(value, default)
  42. def _principal(payload: dict, entitlements) -> Principal:
  43. return Principal(
  44. username=payload["sub"],
  45. name=payload["name"],
  46. email=payload["email"],
  47. role=highest_role(entitlements),
  48. auth_source=payload["auth_source"],
  49. csrf=payload["csrf"],
  50. entitlements=entitlements,
  51. )
  52. async def _submitted_csrf(request: Request) -> str:
  53. header = request.headers.get("X-CSRF-Token", "")
  54. if header:
  55. return header
  56. content_type = request.headers.get("content-type", "")
  57. if content_type.startswith("application/x-www-form-urlencoded"):
  58. body = await request.body()
  59. return parse_qs(body.decode("utf-8", errors="replace")).get("_csrf", [""])[0]
  60. return ""
  61. class AuthenticationMiddleware(BaseHTTPMiddleware):
  62. async def dispatch(self, request: Request, call_next):
  63. is_public = request.url.path in PUBLIC_PATHS or request.url.path.startswith(PUBLIC_PREFIXES)
  64. token = request.cookies.get(settings.SESSION_COOKIE_NAME)
  65. payload = None
  66. new_token = None
  67. if token:
  68. try:
  69. payload = decode_access_token(token)
  70. except InvalidSessionToken:
  71. payload = None
  72. proxy_user = request.headers.get(settings.AUTH_PROXY_HEADER)
  73. proxy_host = request.client.host if request.client else None
  74. trusted_proxy_identity = (
  75. proxy_user if not settings.debug_auth_enabled and settings.is_trusted_proxy(proxy_host) else None
  76. )
  77. if proxy_user and not trusted_proxy_identity and not settings.debug_auth_enabled:
  78. logger.warning("Nicht vertrauenswürdiger Proxy-Identitätsheader von %s ignoriert", proxy_host)
  79. try:
  80. with SessionLocal() as db:
  81. if payload:
  82. entitlements = load_entitlements(db, payload["sub"])
  83. principal = _principal(payload, entitlements)
  84. request.state.principal = principal
  85. if token_needs_renewal(payload, principal.role):
  86. new_token = renew_access_token(payload, principal.role)
  87. elif trusted_proxy_identity:
  88. directory_user = lookup_directory_user(trusted_proxy_identity)
  89. if not directory_user or directory_user.disabled:
  90. return JSONResponse({"detail": "Windows-Benutzer ist nicht gültig"}, status_code=401)
  91. entitlements = load_entitlements(db, directory_user.username)
  92. role = highest_role(entitlements)
  93. new_token = create_access_token(
  94. username=directory_user.username,
  95. name=directory_user.name,
  96. email=directory_user.email,
  97. role=role,
  98. auth_source="iis",
  99. )
  100. payload = decode_access_token(new_token)
  101. request.state.principal = _principal(payload, entitlements)
  102. except HTTPException as exc:
  103. return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
  104. except ValueError:
  105. return JSONResponse({"detail": "Windows-Benutzer ist nicht gültig"}, status_code=401)
  106. except DirectoryUnavailable:
  107. logger.exception("IIS-Benutzer konnte nicht im AD aufgelöst werden")
  108. return JSONResponse({"detail": "Anmeldedienst ist derzeit nicht verfügbar"}, status_code=503)
  109. except Exception:
  110. logger.exception("Berechtigungen konnten nicht geladen werden")
  111. return JSONResponse({"detail": "Berechtigungssystem ist derzeit nicht verfügbar"}, status_code=503)
  112. principal = getattr(request.state, "principal", None)
  113. if not principal and not is_public:
  114. logger.info(
  115. "Anmeldung erforderlich",
  116. extra={
  117. "event": "auth_login_required",
  118. "reason": "invalid_or_expired_session" if token else "missing_session",
  119. },
  120. )
  121. next_url = request.url.path
  122. if request.url.query:
  123. next_url += "?" + request.url.query
  124. next_url = normalize_page_url(next_url, current_url=request.headers.get("HX-Current-URL"))
  125. login_url = f"/login?next={quote(next_url, safe='')}"
  126. if request.headers.get("HX-Request", "").lower() == "true":
  127. response = Response(status_code=200, headers={"HX-Redirect": login_url})
  128. else:
  129. response = RedirectResponse(login_url, status_code=303)
  130. if token:
  131. clear_session_cookie(response)
  132. return response
  133. if principal and request.method in UNSAFE_METHODS and request.url.path != "/login":
  134. submitted = await _submitted_csrf(request)
  135. if not submitted or not secrets.compare_digest(submitted, principal.csrf):
  136. return JSONResponse({"detail": "Ungültiger CSRF-Token"}, status_code=status.HTTP_403_FORBIDDEN)
  137. response = await call_next(request)
  138. if new_token and not getattr(request.state, "suppress_session_refresh", False):
  139. set_session_cookie(response, new_token)
  140. return response