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 как у выбранной орги, но