authentication.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. import math
  2. import random
  3. from typing import Literal
  4. from uuid import UUID
  5. from django.conf import settings
  6. from django.core.cache import cache
  7. from django.http import HttpRequest
  8. from ninja.errors import AuthenticationError, HttpError, ValidationError
  9. from apps.organizations_ext.tasks import check_organization_throttle
  10. from apps.projects.models import Project
  11. from glitchtip.api.exceptions import ThrottleException
  12. from glitchtip.utils import async_call_celery_task
  13. from sentry.utils.auth import parse_auth_header
  14. from .constants import EVENT_BLOCK_CACHE_KEY
  15. class EventAuthHttpRequest(HttpRequest):
  16. """Django HttpRequest that is known to be authenticated by a project DSN"""
  17. auth: Project
  18. def auth_from_request(request: HttpRequest):
  19. """
  20. Get DSN (sentry_key) from request header
  21. Accept both sentry or glitchtip prefix
  22. Do not read request body when possible. This may result in uncompression which is slow.
  23. """
  24. for k in request.GET.keys():
  25. if k in ["sentry_key", "glitchtip_key"]:
  26. return request.GET[k]
  27. if auth_header := request.META.get(
  28. "HTTP_X_SENTRY_AUTH", request.META.get("HTTP_AUTHORIZATION")
  29. ):
  30. result = parse_auth_header(auth_header)
  31. return result.get("sentry_key", result.get("glitchtip_key"))
  32. raise AuthenticationError("Unable to find authentication information")
  33. # One letter codes to save cache memory and map to various event rejection type exceptions
  34. REJECTION_MAP: dict[Literal["v", "t"], Exception] = {
  35. "v": AuthenticationError([{"message": "Invalid DSN"}]),
  36. "t": ThrottleException(),
  37. }
  38. REJECTION_WAIT = 30
  39. def serialize_throttle(org_throttle: int, project_throttle: int) -> str:
  40. """
  41. Format example "t:30:0" means throttle with 30% org throttle and 0% (disabled)
  42. project throttle
  43. """
  44. return f"t:{org_throttle}:{project_throttle}"
  45. def deserialize_throttle(input: str) -> None | tuple[int, int]:
  46. """Return (org_throttle, project_throttle) as integer %"""
  47. if input == "t":
  48. return 0, 0
  49. if input.startswith("t:"):
  50. parts = input.split(":", 2)
  51. if len(parts) == 3:
  52. return int(parts[1]), int(parts[2])
  53. return None
  54. def is_accepting_events(throttle_rate: int) -> bool:
  55. """Consider throttle to determine if event are being accepted"""
  56. if throttle_rate == 0:
  57. return True
  58. return random.randint(0, 100) > throttle_rate
  59. def calculate_retry_after(throttle: int):
  60. """Calculates Retry-After using a power function."""
  61. return math.ceil(0.02 * throttle**2.3)
  62. async def get_project(request: HttpRequest) -> Project | None:
  63. """
  64. Return the valid and accepting events project based on a request.
  65. Throttle unwanted requests using cache to mitigate repeat attempts
  66. """
  67. if not request.resolver_match:
  68. raise ValidationError([{"message": "Invalid project ID"}])
  69. project_id: int = request.resolver_match.captured_kwargs.get("project_id")
  70. try:
  71. sentry_key = UUID(auth_from_request(request))
  72. except ValueError as err:
  73. raise ValidationError(
  74. [{"message": "dsn key badly formed hexadecimal UUID string"}]
  75. ) from err
  76. # block cache check should be right before database call
  77. block_cache_key = EVENT_BLOCK_CACHE_KEY + str(project_id)
  78. if block_value := cache.get(block_cache_key):
  79. if block_value.startswith("t"):
  80. if throttle := deserialize_throttle(block_value):
  81. org_throttle, project_throttle = throttle
  82. if not is_accepting_events(org_throttle) or not is_accepting_events(
  83. project_throttle
  84. ):
  85. raise ThrottleException(calculate_retry_after(max(throttle)))
  86. else:
  87. # Repeat the original message until cache expires
  88. raise REJECTION_MAP[block_value]
  89. project = (
  90. await Project.objects.filter(
  91. id=project_id,
  92. projectkey__public_key=sentry_key,
  93. )
  94. .select_related("organization")
  95. .only(
  96. "id",
  97. "scrub_ip_addresses",
  98. "organization_id",
  99. "organization__is_accepting_events",
  100. "organization__event_throttle_rate",
  101. "organization__scrub_ip_addresses",
  102. "event_throttle_rate",
  103. )
  104. .afirst()
  105. )
  106. if not project:
  107. cache.set(block_cache_key, "v", REJECTION_WAIT)
  108. raise REJECTION_MAP["v"]
  109. if (
  110. not project.organization.is_accepting_events
  111. or project.organization.event_throttle_rate == 100
  112. or project.event_throttle_rate == 100
  113. ):
  114. cache.set(block_cache_key, "t", REJECTION_WAIT)
  115. raise ThrottleException(600)
  116. if project.organization.event_throttle_rate or project.event_throttle_rate:
  117. cache.set(
  118. block_cache_key,
  119. serialize_throttle(
  120. project.organization.event_throttle_rate, project.event_throttle_rate
  121. ),
  122. REJECTION_WAIT,
  123. )
  124. if not is_accepting_events(
  125. project.organization.event_throttle_rate
  126. ) or not is_accepting_events(project.event_throttle_rate):
  127. raise ThrottleException(
  128. calculate_retry_after(
  129. max(project.organization.event_throttle_rate, project.event_throttle_rate)
  130. )
  131. )
  132. # Check throttle needs every 1 out of X requests
  133. if (
  134. settings.BILLING_ENABLED
  135. and random.random() < 1 / settings.GLITCHTIP_THROTTLE_CHECK_INTERVAL
  136. ):
  137. await async_call_celery_task(
  138. check_organization_throttle, project.organization_id
  139. )
  140. return project
  141. async def event_auth(request: HttpRequest) -> Project | None:
  142. """
  143. Event Ingest authentication means validating the DSN (sentry_key).
  144. Throttling is also handled here.
  145. It does not include user authentication.
  146. """
  147. if settings.MAINTENANCE_EVENT_FREEZE:
  148. raise HttpError(
  149. 503, "Events are not currently being accepted due to maintenance."
  150. )
  151. return await get_project(request)