food-market/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs
nns 8048c44ee4 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>
2026-05-27 02:20:02 +05:00

104 lines
5.3 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 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";
}
}