using System.Threading.RateLimiting; using Microsoft.AspNetCore.RateLimiting; namespace foodmarket.Api.Infrastructure.RateLimiting; /// Anti-brute-force на чувствительных auth-эндпоинтах: подбор пароля /// на /connect/token и спам-регистрация на /api/auth/signup. /// /// Два sliding-window лимита на IP, оба должны пропустить запрос (chained): /// 5/мин (резкий всплеск) и 20/час (медленный перебор). Применяется /// глобально, но no-op для всех путей кроме двух auth-эндпоинтов — так /// нет нужды цеплять policy на каждый minimal-API/контроллер по отдельности, /// а двойное окно (которое per-endpoint policy выразить не может) работает /// через . public static class AuthRateLimiterExtensions { // Лимиты вынесены сюда, чтобы тест мог сослаться на те же значения. public const int PerMinutePermitLimit = 5; public const int PerHourPermitLimit = 20; private const string NoLimitPartition = "__not-an-auth-endpoint"; public static IServiceCollection AddAuthRateLimiting(this IServiceCollection services) { services.AddRateLimiter(options => { // По умолчанию RateLimiter отдаёт 503 — нам нужен честный 429. options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.GlobalLimiter = PartitionedRateLimiter.CreateChained( BuildWindow(PerMinutePermitLimit, TimeSpan.FromMinutes(1)), BuildWindow(PerHourPermitLimit, TimeSpan.FromHours(1))); options.OnRejected = async (context, token) => { if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) { context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString(); } context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; context.HttpContext.Response.ContentType = "application/json"; await context.HttpContext.Response.WriteAsJsonAsync(new { error = "too_many_requests", error_description = "Слишком много попыток. Повторите позже.", }, token); }; }); return services; } private static PartitionedRateLimiter BuildWindow(int permitLimit, TimeSpan window) => PartitionedRateLimiter.Create(ctx => { var bucket = ResolveAuthBucket(ctx); if (bucket is null) { return RateLimitPartition.GetNoLimiter(NoLimitPartition); } // Отдельный бакет на каждый эндпоинт: успешная регистрация не должна // съедать лимит логинов (и наоборот). Ключ = "<эндпоинт>:". return RateLimitPartition.GetSlidingWindowLimiter( $"{bucket}:{ResolveClientKey(ctx)}", _ => new SlidingWindowRateLimiterOptions { PermitLimit = permitLimit, Window = window, SegmentsPerWindow = 6, QueueLimit = 0, AutoReplenishment = true, }); }); /// Возвращает имя бакета для лимитируемого auth-эндпоинта либо /// null, если запрос не подлежит лимиту. private static string? ResolveAuthBucket(HttpContext ctx) { if (!HttpMethods.IsPost(ctx.Request.Method)) return null; var path = ctx.Request.Path; if (path.StartsWithSegments("/connect/token", StringComparison.OrdinalIgnoreCase)) return "token"; if (path.StartsWithSegments("/api/auth/signup", StringComparison.OrdinalIgnoreCase)) return "signup"; return null; } /// Ключ партиции — IP клиента. За nginx-проксей реальный адрес /// в X-Forwarded-For (берём левый — самый дальний клиент); напрямую — /// RemoteIpAddress. Без идентификации IP лимит общий на всех (fallback /// «unknown»), что безопаснее, чем не лимитировать вовсе. private static string ResolveClientKey(HttpContext ctx) { var forwarded = ctx.Request.Headers["X-Forwarded-For"].ToString(); if (!string.IsNullOrWhiteSpace(forwarded)) { var first = forwarded.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); if (first.Length > 0) return first[0]; } return ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; } }