using System.Threading.RateLimiting; using Deal.Api.Configuration; using Deal.Api.Http; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.DependencyInjection; using Deal.Api.Services; using Deal.Api.Dtos; namespace Deal.Api.Middleware; /// /// Регистрация встроенного rate limiter ASP.NET Core (план Task 11, Ruling 5; этап 12, пакет B — хранилище /// на Postgres) и его политики: "auth" — фиксированное окно на IP клиента (ручки входа /api/auth/login и /// /api/operator/auth/login), "api" — на тенанта из либо IP анонима /// (Session/OperatorSessionMiddleware отрабатывают раньше — порядок Session → Operator → RateLimiter). /// API-партиция выставляется и глобальным лимитером: все /api-эндпоинты без собственной политики /// ограничены 600/мин на тенанта/IP. Отказ любого лимитера — HTTP 429 с телом {"detail": "…"}. /// /// /// Регистрируется только при (в dev/тестах middleware и политики /// не создаются — Ruling 5). gRPC-ингресс (:5082) HTTP-лимитером освобождён (DisableRateLimiting на /// MapGrpcService): лимит по tenant-id там считает IngressRateLimitInterceptor — иначе входящий поток /// telegram-service резался бы общим окном на IP. /// /// Счётчики окон живут в Postgres ( через scoped /// ) — лимиты общие для всех инстансов core. Пороги/окна /// не менялись: фиксированные 1 минута, PermitLimit из . /// /// public static class RateLimitPolicies { /// /// Имя политики входа: фиксированное окно по IP (RateLimit:AuthPerMinute). /// public const string AuthPolicy = "auth"; /// /// Имя API-политики: фиксированное окно по CurrentUser.TenantId либо IP анонима (RateLimit:ApiPerMinute). /// public const string ApiPolicy = "api"; /// /// Текст 429 rate limiter (Ruling 5): все отказы лимитов запросов — единый detail. /// public const string RejectedDetail = "Слишком много запросов. Повторите позже"; // Префикс ключа хранилища для HTTP-политик: изолирует счётчики от gRPC-ингресса и login-guard. private const string HttpKeyPrefix = "http:"; // Пространство ключа глобального лимитера: отдельные счётчики от именованной "api"-политики // (как и раньше — независимые FixedWindowRateLimiter'ы, лимиты не схлопываются в один бакет). private const string GlobalScope = "global"; // Префикс партиции API-политики для анонима (IP не разрешён в сессию). private const string IpKeyPrefix = "ip:"; // Префикс партиции API-политики для аутентифицированного пользователя (по тенанту). private const string TenantKeyPrefix = "tenant:"; // Ключ партиции, когда IP клиента недоступен (служебные/тестовые вызовы). private const string UnknownClientKey = "unknown"; // Длина окна политик — 1 минута (имена настроек — «PerMinute»). private static readonly TimeSpan Window = TimeSpan.FromMinutes(1); /// /// Регистрирует AddRateLimiter с политиками "auth"/"api" и глобальным лимитером API-партиции. /// /// Коллекция сервисов хоста. /// Настройки rate limiting (секция RateLimit). /// Коллекция сервисов для цепочки вызовов. public static IServiceCollection AddDealRateLimiter(this IServiceCollection services, RateLimitOptions options) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(options); int apiPerMinute = options.ApiPerMinute; services.AddRateLimiter(limiter => { limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests; limiter.OnRejected = OnRejectedAsync; limiter.AddPolicy(AuthPolicy, context => AuthPartition(context, options.AuthPerMinute)); limiter.AddPolicy(ApiPolicy, context => ApiPartition(context, ApiPolicy, apiPerMinute)); // Глобальный лимитер — та же API-партиция (своё пространство ключей): эндпоинты без // RequireRateLimiting ограничены по тенанту/IP (весь /api, кроме ручек входа и gRPC). limiter.GlobalLimiter = PartitionedRateLimiter.Create( context => ApiPartition(context, GlobalScope, apiPerMinute)); }); return services; } // Партиция политики "auth": фиксированное окно в минуту на IP клиента. // context: Контекст запроса (адрес клиента). // permitsPerMinute: Разрешено запросов в минуту на партицию (RateLimit:AuthPerMinute). // Возвращает: Партиция лимитера с ключом IP. private static RateLimitPartition AuthPartition(HttpContext context, int permitsPerMinute) => StorePartition(context, AuthPolicy, ClientKey(context), permitsPerMinute); // Партиция политики "api": ключ — тенант сессии (CurrentUser) либо IP анонима. // context: Контекст запроса (CurrentUser кладёт SessionMiddleware до UseRateLimiter). // scope: Пространство ключей (имя политики или "global" для глобального лимитера). // permitsPerMinute: Разрешено запросов в минуту на партицию (RateLimit:ApiPerMinute). // Возвращает: Партиция лимитера с ключом tenant:{id} либо IP. private static RateLimitPartition ApiPartition(HttpContext context, string scope, int permitsPerMinute) { string partitionKey = context.GetCurrentUser() is { } user ? TenantKeyPrefix + user.TenantId.ToString("N") : ClientKey(context); return StorePartition(context, scope, partitionKey, permitsPerMinute); } // Строит партицию на хранилище Postgres: ключ = http:{scope}:{partitionKey}, лимитер — лениво. // context: Контекст запроса (источник IServiceScopeFactory для лениво создаваемого лимитера). // scope: Пространство ключей (политика/global). // partitionKey: Ключ партиции (IP/тенант). // permitsPerMinute: Разрешено запросов в минуту. // Возвращает: Партиция store-backed лимитера (ленивое создание и кеш — менеджер партиций). private static RateLimitPartition StorePartition( HttpContext context, string scope, string partitionKey, int permitsPerMinute) { string storeKey = HttpKeyPrefix + scope + ":" + partitionKey; return RateLimitPartition.Get(storeKey, key => new StoreBackedFixedWindowRateLimiter( context.RequestServices.GetRequiredService(), key, permitsPerMinute, Window, () => DateTimeOffset.UtcNow)); } // Ключ клиента: строка IP (или unknown, если адрес недоступен). // context: Контекст запроса. // Возвращает: Ключ партиции по IP. private static string ClientKey(HttpContext context) { string? ip = context.Connection.RemoteIpAddress?.ToString(); return string.IsNullOrEmpty(ip) ? UnknownClientKey : IpKeyPrefix + ip; } // Пишет ответ 429 формата прототипа: {"detail": "…"} (Ruling 10/5). // context: Контекст отклонённого запроса. // cancellationToken: Токен отмены ответа. private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken) { context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; await context.HttpContext.Response.WriteAsJsonAsync(new { detail = RejectedDetail }, cancellationToken); } }