using System.Net; using System.Security.Claims; using Application.Abstractions.RateLimit; namespace Web.Api.Middleware; /// /// /v1/* 공개 API endpoint 의 rate limit 미들웨어. /// 기본 60 req/min per token. 토큰이 없으면 IP 단위. /// public sealed class RateLimitMiddleware(RequestDelegate next, IRateLimiter limiter) { private const int DefaultLimitPerMinute = 60; private static readonly TimeSpan Window = TimeSpan.FromMinutes(1); public async Task InvokeAsync(HttpContext context) { if (!context.Request.Path.StartsWithSegments("/v1")) { await next(context); return; } var key = ResolveKey(context); var result = await limiter.CheckAsync(key, DefaultLimitPerMinute, Window, context.RequestAborted); context.Response.Headers["X-RateLimit-Limit"] = result.Limit.ToString(); context.Response.Headers["X-RateLimit-Remaining"] = result.Remaining.ToString(); context.Response.Headers["X-RateLimit-Reset"] = new DateTimeOffset(result.ResetAt, TimeSpan.Zero).ToUnixTimeSeconds().ToString(); if (!result.Allowed) { context.Response.StatusCode = (int)HttpStatusCode.TooManyRequests; context.Response.ContentType = "application/problem+json"; await context.Response.WriteAsJsonAsync(new { type = "https://tools.ietf.org/html/rfc6585#section-4", title = "Too Many Requests", status = 429, detail = "Rate limit exceeded. Try again later." }, context.RequestAborted); return; } await next(context); } private static string ResolveKey(HttpContext context) { if (context.User.Identity?.IsAuthenticated == true) { var tokenID = context.User.FindFirst("token_id")?.Value; if (!string.IsNullOrEmpty(tokenID)) { return $"tok:{tokenID}"; } var sub = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? context.User.FindFirst("sub")?.Value; if (!string.IsNullOrEmpty(sub)) { return $"sub:{sub}"; } } var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; return $"ip:{ip}"; } }