RateLimitMiddleware.cs 2.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
  1. using System.Net;
  2. using System.Security.Claims;
  3. using Application.Abstractions.RateLimit;
  4. namespace Web.Api.Middleware;
  5. /// <summary>
  6. /// /v1/* 공개 API endpoint 의 rate limit 미들웨어.
  7. /// 기본 60 req/min per token. 토큰이 없으면 IP 단위.
  8. /// </summary>
  9. public sealed class RateLimitMiddleware(RequestDelegate next, IRateLimiter limiter)
  10. {
  11. private const int DefaultLimitPerMinute = 60;
  12. private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
  13. public async Task InvokeAsync(HttpContext context)
  14. {
  15. if (!context.Request.Path.StartsWithSegments("/v1"))
  16. {
  17. await next(context);
  18. return;
  19. }
  20. var key = ResolveKey(context);
  21. var result = await limiter.CheckAsync(key, DefaultLimitPerMinute, Window, context.RequestAborted);
  22. context.Response.Headers["X-RateLimit-Limit"] = result.Limit.ToString();
  23. context.Response.Headers["X-RateLimit-Remaining"] = result.Remaining.ToString();
  24. context.Response.Headers["X-RateLimit-Reset"] = new DateTimeOffset(result.ResetAt, TimeSpan.Zero).ToUnixTimeSeconds().ToString();
  25. if (!result.Allowed)
  26. {
  27. context.Response.StatusCode = (int)HttpStatusCode.TooManyRequests;
  28. context.Response.ContentType = "application/problem+json";
  29. await context.Response.WriteAsJsonAsync(new
  30. {
  31. type = "https://tools.ietf.org/html/rfc6585#section-4",
  32. title = "Too Many Requests",
  33. status = 429,
  34. detail = "Rate limit exceeded. Try again later."
  35. }, context.RequestAborted);
  36. return;
  37. }
  38. await next(context);
  39. }
  40. private static string ResolveKey(HttpContext context)
  41. {
  42. if (context.User.Identity?.IsAuthenticated == true)
  43. {
  44. var tokenID = context.User.FindFirst("token_id")?.Value;
  45. if (!string.IsNullOrEmpty(tokenID))
  46. {
  47. return $"tok:{tokenID}";
  48. }
  49. var sub = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value
  50. ?? context.User.FindFirst("sub")?.Value;
  51. if (!string.IsNullOrEmpty(sub))
  52. {
  53. return $"sub:{sub}";
  54. }
  55. }
  56. var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
  57. return $"ip:{ip}";
  58. }
  59. }