feat(api): rate-limit /connect/token и /api/auth/signup (P0-3)
Sliding window на IP: 5/мин + 20/час (оба окна chained, оба должны пропустить). Отдельные бакеты на эндпоинт — регистрация не съедает лимит логинов. Глобальный лимитер с no-op для не-auth путей: двойное окно per-endpoint policy выразить не может. 429 + JSON-телом, X-Forwarded-For учитывается за прокси. Проверено curl'ом: 6-я попытка/мин → 429. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
a15100f3bc
commit
8048c44ee4
|
|
@ -0,0 +1,103 @@
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Infrastructure.RateLimiting;
|
||||||
|
|
||||||
|
/// <summary>Anti-brute-force на чувствительных auth-эндпоинтах: подбор пароля
|
||||||
|
/// на <c>/connect/token</c> и спам-регистрация на <c>/api/auth/signup</c>.
|
||||||
|
///
|
||||||
|
/// Два sliding-window лимита на IP, оба должны пропустить запрос (chained):
|
||||||
|
/// 5/мин (резкий всплеск) и 20/час (медленный перебор). Применяется
|
||||||
|
/// глобально, но no-op для всех путей кроме двух auth-эндпоинтов — так
|
||||||
|
/// нет нужды цеплять policy на каждый minimal-API/контроллер по отдельности,
|
||||||
|
/// а двойное окно (которое per-endpoint policy выразить не может) работает
|
||||||
|
/// через <see cref="PartitionedRateLimiter.CreateChained{TResource}"/>.</summary>
|
||||||
|
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<HttpContext> BuildWindow(int permitLimit, TimeSpan window) =>
|
||||||
|
PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
|
||||||
|
{
|
||||||
|
var bucket = ResolveAuthBucket(ctx);
|
||||||
|
if (bucket is null)
|
||||||
|
{
|
||||||
|
return RateLimitPartition.GetNoLimiter(NoLimitPartition);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отдельный бакет на каждый эндпоинт: успешная регистрация не должна
|
||||||
|
// съедать лимит логинов (и наоборот). Ключ = "<эндпоинт>:<IP>".
|
||||||
|
return RateLimitPartition.GetSlidingWindowLimiter(
|
||||||
|
$"{bucket}:{ResolveClientKey(ctx)}",
|
||||||
|
_ => new SlidingWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = permitLimit,
|
||||||
|
Window = window,
|
||||||
|
SegmentsPerWindow = 6,
|
||||||
|
QueueLimit = 0,
|
||||||
|
AutoReplenishment = true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>Возвращает имя бакета для лимитируемого auth-эндпоинта либо
|
||||||
|
/// null, если запрос не подлежит лимиту.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ключ партиции — IP клиента. За nginx-проксей реальный адрес
|
||||||
|
/// в X-Forwarded-For (берём левый — самый дальний клиент); напрямую —
|
||||||
|
/// RemoteIpAddress. Без идентификации IP лимит общий на всех (fallback
|
||||||
|
/// «unknown»), что безопаснее, чем не лимитировать вовсе.</summary>
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using foodmarket.Api.Infrastructure.RateLimiting;
|
||||||
using foodmarket.Api.Infrastructure.Tenancy;
|
using foodmarket.Api.Infrastructure.Tenancy;
|
||||||
using foodmarket.Api.Seed;
|
using foodmarket.Api.Seed;
|
||||||
using foodmarket.Application.Common.Tenancy;
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
|
@ -141,6 +142,9 @@
|
||||||
|
|
||||||
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
|
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
|
||||||
|
|
||||||
|
// Anti-brute-force на /connect/token и /api/auth/signup (5/мин + 20/час на IP).
|
||||||
|
builder.Services.AddAuthRateLimiting();
|
||||||
|
|
||||||
// Email-отправка через MailKit. Singleton — внутри открывает scope для
|
// Email-отправка через MailKit. Singleton — внутри открывает scope для
|
||||||
// свежего DbContext'а, поэтому конфиг (PlatformSettings) перечитывается
|
// свежего DbContext'а, поэтому конфиг (PlatformSettings) перечитывается
|
||||||
// на каждой отправке без рестарта приложения.
|
// на каждой отправке без рестарта приложения.
|
||||||
|
|
@ -189,6 +193,9 @@
|
||||||
|
|
||||||
app.UseSerilogRequestLogging();
|
app.UseSerilogRequestLogging();
|
||||||
app.UseCors(CorsPolicy);
|
app.UseCors(CorsPolicy);
|
||||||
|
// До аутентификации: лимитируем перебор пароля ещё на входе, не доводя
|
||||||
|
// до проверки credential'ов в БД.
|
||||||
|
app.UseRateLimiter();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
// SuperAdmin «открыть как…» — тот же tenant как у выбранной орги, но
|
// SuperAdmin «открыть как…» — тот же tenant как у выбранной орги, но
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue