diff --git a/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs b/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs new file mode 100644 index 0000000..adbeb3d --- /dev/null +++ b/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs @@ -0,0 +1,103 @@ +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"; + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index acc33f8..d2c1c4e 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using foodmarket.Api.Infrastructure.RateLimiting; using foodmarket.Api.Infrastructure.Tenancy; using foodmarket.Api.Seed; using foodmarket.Application.Common.Tenancy; @@ -141,6 +142,9 @@ builder.Services.AddScoped(); + // Anti-brute-force на /connect/token и /api/auth/signup (5/мин + 20/час на IP). + builder.Services.AddAuthRateLimiting(); + // Email-отправка через MailKit. Singleton — внутри открывает scope для // свежего DbContext'а, поэтому конфиг (PlatformSettings) перечитывается // на каждой отправке без рестарта приложения. @@ -189,6 +193,9 @@ app.UseSerilogRequestLogging(); app.UseCors(CorsPolicy); + // До аутентификации: лимитируем перебор пароля ещё на входе, не доводя + // до проверки credential'ов в БД. + app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); // SuperAdmin «открыть как…» — тот же tenant как у выбранной орги, но