Files
Deal/src/core/Deal.Api/Middleware/RateLimitPolicies.cs
T
Rustam Khalimov 410194b0cb Разбить Infrastructure и корень Deal.Api по назначению
Integrations -> Abstractions/Exceptions/Extensions/Models/Options/
Services (включая Storage); Persistence-конфигурации -> Configurations;
корень Deal.Api (оркестратор/планировщики/DTO) -> Services/Dtos.
namespace приведён к путям, using добавлены/дедуплицированы, FQN
обновлены.
2026-09-11 13:20:10 +03:00

149 lines
9.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Регистрация встроенного rate limiter ASP.NET Core (план Task 11, Ruling 5; этап 12, пакет B — хранилище
/// на Postgres) и его политики: "auth" — фиксированное окно на IP клиента (ручки входа /api/auth/login и
/// /api/operator/auth/login), "api" — на тенанта из <see cref="CurrentUser"/> либо IP анонима
/// (Session/OperatorSessionMiddleware отрабатывают раньше — порядок Session → Operator → RateLimiter).
/// API-партиция выставляется и глобальным лимитером: все /api-эндпоинты без собственной политики
/// ограничены 600/мин на тенанта/IP. Отказ любого лимитера — HTTP 429 с телом <c>{"detail": "…"}</c>.
/// </summary>
/// <remarks>
/// Регистрируется только при <see cref="RateLimitOptions.Enabled"/> (в dev/тестах middleware и политики
/// не создаются — Ruling 5). gRPC-ингресс (:5082) HTTP-лимитером освобождён (DisableRateLimiting на
/// MapGrpcService): лимит по tenant-id там считает IngressRateLimitInterceptor — иначе входящий поток
/// telegram-service резался бы общим окном на IP.
/// <para>
/// Счётчики окон живут в Postgres (<see cref="IRateLimitCounterStore"/> через scoped
/// <see cref="StoreBackedFixedWindowRateLimiter"/>) — лимиты общие для всех инстансов core. Пороги/окна
/// не менялись: фиксированные 1 минута, PermitLimit из <see cref="RateLimitOptions"/>.
/// </para>
/// </remarks>
public static class RateLimitPolicies
{
/// <summary>
/// Имя политики входа: фиксированное окно по IP (RateLimit:AuthPerMinute).
/// </summary>
public const string AuthPolicy = "auth";
/// <summary>
/// Имя API-политики: фиксированное окно по CurrentUser.TenantId либо IP анонима (RateLimit:ApiPerMinute).
/// </summary>
public const string ApiPolicy = "api";
/// <summary>
/// Текст 429 rate limiter (Ruling 5): все отказы лимитов запросов — единый detail.
/// </summary>
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);
/// <summary>
/// Регистрирует AddRateLimiter с политиками "auth"/"api" и глобальным лимитером API-партиции.
/// </summary>
/// <param name="services">Коллекция сервисов хоста.</param>
/// <param name="options">Настройки rate limiting (секция RateLimit).</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
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<HttpContext, string>(
context => ApiPartition(context, GlobalScope, apiPerMinute));
});
return services;
}
// Партиция политики "auth": фиксированное окно в минуту на IP клиента.
// context: Контекст запроса (адрес клиента).
// permitsPerMinute: Разрешено запросов в минуту на партицию (RateLimit:AuthPerMinute).
// Возвращает: Партиция лимитера с ключом IP.
private static RateLimitPartition<string> 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<string> 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<string> StorePartition(
HttpContext context, string scope, string partitionKey, int permitsPerMinute)
{
string storeKey = HttpKeyPrefix + scope + ":" + partitionKey;
return RateLimitPartition.Get(storeKey, key => new StoreBackedFixedWindowRateLimiter(
context.RequestServices.GetRequiredService<IServiceScopeFactory>(),
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);
}
}