ПО промежуточного слоя для ограничения скорости в ASP.NET Core

Арвин Кахбази, Маартен Бальяув, и Рик Андерсон

ПО промежуточного слоя Microsoft.AspNetCore.RateLimiting предоставляет ПО промежуточного слоя для ограничения скорости. Приложения настраивают политики ограничения скорости, а затем присоединяют политики к конечным точкам. Приложения, использующие ограничение скорости, должны тщательно тестироваться и проверяться перед развертыванием. Дополнительные сведения см. в разделе "Тестирование конечных точек с ограничением скорости " в этой статье.

Введение в ограничение скорости см. в разделе "Ограничение скорости" по промежуточного слоя.

Алгоритмы ограничения скорости

Класс RateLimiterOptionsExtensions предоставляет следующие методы расширения для ограничения скорости:

Исправлено ограничение окна

Метод AddFixedWindowLimiter использует фиксированное время для ограничения запросов. Когда истекает срок действия периода времени, запускается новое окно времени, а ограничение запроса сбрасывается.

Рассмотрим следующий код:

using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRateLimiter(_ => _
    .AddFixedWindowLimiter(policyName: "fixed", options =>
    {
        options.PermitLimit = 4;
        options.Window = TimeSpan.FromSeconds(12);
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = 2;
    }));

var app = builder.Build();

app.UseRateLimiter();

static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");

app.MapGet("/", () => Results.Ok($"Hello {GetTicks()}"))
                           .RequireRateLimiting("fixed");

app.Run();

Предыдущий код:

  • Вызовы AddRateLimiter для добавления службы ограничения скорости в коллекцию служб.
  • Вызовы AddFixedWindowLimiter для создания фиксированного ограничения окна с именем политики и наборами "fixed" :
  • PermitLimit до 4 и времени Window до 12. Допускается не более 4 запросов в каждом 12-секундном окне.
  • QueueProcessingOrder изменено на OldestFirst.
  • QueueLimit до 2.
  • Вызовы UseRateLimiter для включения ограничения скорости.

Приложения должны использовать конфигурацию для задания параметров ограничения. Следующий код обновляет предыдущий код, используя MyRateLimitOptions для настройки:

using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<MyRateLimitOptions>(
    builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit));

var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);
var fixedPolicy = "fixed";

builder.Services.AddRateLimiter(_ => _
    .AddFixedWindowLimiter(policyName: fixedPolicy, options =>
    {
        options.PermitLimit = myOptions.PermitLimit;
        options.Window = TimeSpan.FromSeconds(myOptions.Window);
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    }));

var app = builder.Build();

app.UseRateLimiter();

static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");

app.MapGet("/", () => Results.Ok($"Fixed Window Limiter {GetTicks()}"))
                           .RequireRateLimiting(fixedPolicy);

app.Run();

UseRateLimiter необходимо вызывать после того, UseRouting как используются api ограничения скорости ограничения скорости для конкретной конечной точки. Например, если [EnableRateLimiting] используется атрибут, UseRateLimiter необходимо вызвать после UseRouting. При вызове только глобальных ограничений UseRateLimiter можно вызвать раньше UseRouting.

Ограничение скользящего окна

Алгоритм скользящего окна:

  • Аналогично фиксированному ограничению окна, но добавляет сегменты в окно. Окно слайдирует по одному сегменту каждый интервал сегмента. Интервал сегмента — (время окна)/(сегменты на окно).
  • Ограничивает запросы окна запросами permitLimit .
  • Каждый период времени делится на сегменты на n окно.
  • Запросы, полученные из сегмента истекшим сроком действия, возвращаются в текущий сегмент (n сегменты до текущего сегмента). Мы называем наиболее просроченным сегментом времени в одном окне назад в качестве сегмента с истекшим сроком действия.

Рассмотрим следующую таблицу с скользящим ограничением окна с 30-секундным окном, тремя сегментами в окне и ограничением в 100 запросов:

  • Верхняя строка и первый столбец показывают сегмент времени.
  • Вторая строка показывает оставшиеся доступные запросы. Остальные запросы вычисляются как доступные запросы минус обработанные запросы, а также переработанные запросы.
  • Запросы каждый раз перемещаются по диагонали синей линии.
  • По истечении 30 раз запрос, полученный из сегмента времени с истекшим сроком действия, добавляется обратно в предел запроса, как показано в красных строках.

Table showing requests, limits, and recycled slots

В следующей таблице показаны данные в предыдущем графе в другом формате. В столбце "Доступно" отображаются запросы, доступные из предыдущего сегмента (Перенос из предыдущей строки). В первой строке отображается 100 доступных запросов, так как предыдущий сегмент отсутствует.

Время На месте Приняты Истек срок действия повторного использования Переносить
0 100 20 0 80
10 80 30 0 50
20 50 40 0 10
30 10 30 20 0
40 0 10 30 20
50 20 10 40 50
60 50 35 30 45

В следующем коде используется ограничение скорости скользящего окна:

using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);

var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);
var slidingPolicy = "sliding";

builder.Services.AddRateLimiter(_ => _
    .AddSlidingWindowLimiter(policyName: slidingPolicy, options =>
    {
        options.PermitLimit = myOptions.PermitLimit;
        options.Window = TimeSpan.FromSeconds(myOptions.Window);
        options.SegmentsPerWindow = myOptions.SegmentsPerWindow;
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    }));

var app = builder.Build();

app.UseRateLimiter();

static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");

app.MapGet("/", () => Results.Ok($"Sliding Window Limiter {GetTicks()}"))
                           .RequireRateLimiting(slidingPolicy);

app.Run();

Ограничение сегментов маркеров

Ограничение контейнеров маркеров аналогично скользящему ограничению окна, но вместо добавления запросов, полученных из сегмента с истекшим сроком действия, добавляется фиксированное число маркеров каждый период пополнения. Маркеры, добавленные каждый сегмент, не могут увеличить доступные маркеры до числа выше предела контейнера маркеров. В следующей таблице показан ограничивающий контейнер маркеров с ограничением в 100 маркеров и периодом пополнения в 10 секунд.

Время На месте Приняты Добавлено Переносить
0 100 20 0 80
10 80 10 20 90
20 90 5 15 100
30 100 30 20 90
40 90 6 16 100
50 100 40 20 80
60 80 50 20 50

В следующем коде используется средство ограничения контейнера маркеров:

using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);

var tokenPolicy = "token";
var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);

builder.Services.AddRateLimiter(_ => _
    .AddTokenBucketLimiter(policyName: tokenPolicy, options =>
    {
        options.TokenLimit = myOptions.TokenLimit;
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
        options.ReplenishmentPeriod = TimeSpan.FromSeconds(myOptions.ReplenishmentPeriod);
        options.TokensPerPeriod = myOptions.TokensPerPeriod;
        options.AutoReplenishment = myOptions.AutoReplenishment;
    }));

var app = builder.Build();

app.UseRateLimiter();

static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");

app.MapGet("/", () => Results.Ok($"Token Limiter {GetTicks()}"))
                           .RequireRateLimiting(tokenPolicy);

app.Run();

Если AutoReplenishment задано значение true, внутренний таймер обновляет маркеры каждые ReplenishmentPeriod; при установке falseприложение должно вызывать TryReplenish ограничение.

Ограничение параллелизма

Ограничение параллелизма ограничивает количество одновременных запросов. Каждый запрос уменьшает ограничение параллелизма на один. По завершении запроса ограничение увеличивается на один. В отличие от других ограничений запросов, ограничивающих общее количество запросов в течение указанного периода, ограничение параллелизма ограничивает только количество одновременных запросов и не ограничивает количество запросов за определенный период времени.

В следующем коде используется ограничение параллелизма:

using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);

var concurrencyPolicy = "Concurrency";
var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);

builder.Services.AddRateLimiter(_ => _
    .AddConcurrencyLimiter(policyName: concurrencyPolicy, options =>
    {
        options.PermitLimit = myOptions.PermitLimit;
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    }));

var app = builder.Build();

app.UseRateLimiter();

static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");

app.MapGet("/", async () =>
{
    await Task.Delay(500);
    return Results.Ok($"Concurrency Limiter {GetTicks()}");
                              
}).RequireRateLimiting(concurrencyPolicy);

app.Run();

Создание ограничивающих элементов в цепочке

CreateChained API позволяет передавать несколькоPartitionedRateLimiter, которые объединяются в одинPartitionedRateLimiter. Объединенный ограничитель выполняет все входные ограничения в последовательности.

В приведенном ниже коде используется CreateChained:

using System.Globalization;
using System.Threading.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRateLimiter(_ =>
{
    _.OnRejected = (context, _) =>
    {
        if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
        {
            context.HttpContext.Response.Headers.RetryAfter =
                ((int) retryAfter.TotalSeconds).ToString(NumberFormatInfo.InvariantInfo);
        }

        context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
        context.HttpContext.Response.WriteAsync("Too many requests. Please try again later.");

        return new ValueTask();
    };
    _.GlobalLimiter = PartitionedRateLimiter.CreateChained(
        PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
        {
            var userAgent = httpContext.Request.Headers.UserAgent.ToString();

            return RateLimitPartition.GetFixedWindowLimiter
            (userAgent, _ =>
                new FixedWindowRateLimiterOptions
                {
                    AutoReplenishment = true,
                    PermitLimit = 4,
                    Window = TimeSpan.FromSeconds(2)
                });
        }),
        PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
        {
            var userAgent = httpContext.Request.Headers.UserAgent.ToString();
            
            return RateLimitPartition.GetFixedWindowLimiter
            (userAgent, _ =>
                new FixedWindowRateLimiterOptions
                {
                    AutoReplenishment = true,
                    PermitLimit = 20,    
                    Window = TimeSpan.FromSeconds(30)
                });
        }));
});

var app = builder.Build();
app.UseRateLimiter();

static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");

app.MapGet("/", () => Results.Ok($"Hello {GetTicks()}"));

app.Run();

Дополнительные сведения см. в исходном коде CreateChained

Атрибуты EnableRateLimiting и DisableRateLimiting

[DisableRateLimiting] Атрибуты [EnableRateLimiting] могут применяться к контроллеру, методу действия или Razor странице. Для Razor Pages атрибут должен применяться к Razor странице, а не к обработчикам страниц. Например, [EnableRateLimiting] нельзя применять OnGetк обработчику OnPostстраниц или к любому другому обработчику страницы.

Атрибут [DisableRateLimiting]отключает ограничение скорости контроллера, метода действия или Razor страницы независимо от примененных ограничений именованных ограничений скорости или глобальных ограничений. Например, рассмотрим следующий код, который вызывает RequireRateLimiting применение fixedPolicy ограничения скорости ко всем конечным точкам контроллера:

using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages();
builder.Services.AddControllersWithViews();

builder.Services.Configure<MyRateLimitOptions>(
    builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit));

var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);
var fixedPolicy = "fixed";

builder.Services.AddRateLimiter(_ => _
    .AddFixedWindowLimiter(policyName: fixedPolicy, options =>
    {
        options.PermitLimit = myOptions.PermitLimit;
        options.Window = TimeSpan.FromSeconds(myOptions.Window);
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    }));

var slidingPolicy = "sliding";

builder.Services.AddRateLimiter(_ => _
    .AddSlidingWindowLimiter(policyName: slidingPolicy, options =>
    {
        options.PermitLimit = myOptions.SlidingPermitLimit;
        options.Window = TimeSpan.FromSeconds(myOptions.Window);
        options.SegmentsPerWindow = myOptions.SegmentsPerWindow;
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    }));

var app = builder.Build();
app.UseRateLimiter();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.MapRazorPages().RequireRateLimiting(slidingPolicy);
app.MapDefaultControllerRoute().RequireRateLimiting(fixedPolicy);

app.Run();

В следующем коде [DisableRateLimiting] отключает ограничение скорости и переопределяет, примененные [EnableRateLimiting("fixed")] к вызываемой Home2ControllerProgram.csфункцииapp.MapDefaultControllerRoute().RequireRateLimiting(fixedPolicy):

[EnableRateLimiting("fixed")]
public class Home2Controller : Controller
{
    private readonly ILogger<Home2Controller> _logger;

    public Home2Controller(ILogger<Home2Controller> logger)
    {
        _logger = logger;
    }

    public ActionResult Index()
    {
        return View();
    }

    [EnableRateLimiting("sliding")]
    public ActionResult Privacy()
    {
        return View();
    }

    [DisableRateLimiting]
    public ActionResult NoLimit()
    {
        return View();
    }

    [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
    public IActionResult Error()
    {
        return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
    }
}

В приведенном выше коде не применяется к методу Privacy действия, [EnableRateLimiting("sliding")] так как Program.cs вызываетсяapp.MapDefaultControllerRoute().RequireRateLimiting(fixedPolicy).

Рассмотрим следующий код, который не вызывается RequireRateLimitingMapRazorPages или MapDefaultControllerRoute:

using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages();
builder.Services.AddControllersWithViews();

builder.Services.Configure<MyRateLimitOptions>(
    builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit));

var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);
var fixedPolicy = "fixed";

builder.Services.AddRateLimiter(_ => _
    .AddFixedWindowLimiter(policyName: fixedPolicy, options =>
    {
        options.PermitLimit = myOptions.PermitLimit;
        options.Window = TimeSpan.FromSeconds(myOptions.Window);
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    }));

var slidingPolicy = "sliding";

builder.Services.AddRateLimiter(_ => _
    .AddSlidingWindowLimiter(policyName: slidingPolicy, options =>
    {
        options.PermitLimit = myOptions.SlidingPermitLimit;
        options.Window = TimeSpan.FromSeconds(myOptions.Window);
        options.SegmentsPerWindow = myOptions.SegmentsPerWindow;
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    }));

var app = builder.Build();

app.UseRateLimiter();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.MapRazorPages();
app.MapDefaultControllerRoute();  // RequireRateLimiting not called

app.Run();

Рассмотрим следующий контроллер:

[EnableRateLimiting("fixed")]
public class Home2Controller : Controller
{
    private readonly ILogger<Home2Controller> _logger;

    public Home2Controller(ILogger<Home2Controller> logger)
    {
        _logger = logger;
    }

    public ActionResult Index()
    {
        return View();
    }

    [EnableRateLimiting("sliding")]
    public ActionResult Privacy()
    {
        return View();
    }

    [DisableRateLimiting]
    public ActionResult NoLimit()
    {
        return View();
    }

    [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
    public IActionResult Error()
    {
        return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
    }
}

На предыдущем контроллере:

  • Ограничение "fixed" скорости политики применяется ко всем методам действий, которые не имеют EnableRateLimiting и DisableRateLimiting атрибуты.
  • Ограничение "sliding" скорости политики применяется к Privacy действию.
  • Ограничение скорости отключено в методе NoLimit действия.

Применение атрибутов к страницам Razor

Для Razor Pages атрибут должен применяться к Razor странице, а не к обработчикам страниц. Например, [EnableRateLimiting] нельзя применять OnGetк обработчику OnPostстраниц или к любому другому обработчику страницы.

Атрибут DisableRateLimiting отключает ограничение скорости на Razor странице. EnableRateLimiting применяется только к Razor странице, если MapRazorPages().RequireRateLimiting(Policy)не был вызван.

Сравнение алгоритмов ограничения

Фиксированные, скользящие и маркерные ограничения ограничивают максимальное количество запросов за определенный период времени. Ограничение параллелизма ограничивает только количество одновременных запросов и не ограничивает количество запросов за период времени. При выборе ограничения следует учитывать стоимость конечной точки. Стоимость конечной точки включает ресурсы, используемые, например, время, доступ к данным, ЦП и ввода-вывода.

Примеры ограничения скорости

Следующие примеры не предназначены для рабочего кода, но являются примерами использования ограничивающих элементов.

Предельный с OnRejected, RetryAfterи GlobalLimiter

Следующий пример:

  • Создает обратный вызов RateLimiterOptions.OnRejected , который вызывается, когда запрос превышает указанное ограничение. retryAfter можно использовать с параметром TokenBucketRateLimiter, FixedWindowLimiterи SlidingWindowLimiter так как эти алгоритмы могут оценить, когда будут добавлены дополнительные разрешения. Нет ConcurrencyLimiter способа вычисления, когда разрешения будут доступны.

  • Добавляет следующие ограничения:

    • Объект, SampleRateLimiterPolicy реализующий IRateLimiterPolicy<TPartitionKey> интерфейс. Класс SampleRateLimiterPolicy показан далее в этой статье.
    • A SlidingWindowLimiter:
      • С секцией для каждого пользователя, прошедшего проверку подлинности.
      • Одна общая секция для всех анонимных пользователей.
    • Применяется GlobalLimiter ко всем запросам. Глобальный ограничивающий объект будет выполняться сначала, за которым следует ограничивающий объект, зависящий от конечной точки, если он существует. Создает GlobalLimiter секцию для каждого IPAddress.
// Preceding code removed for brevity.
using System.Globalization;
using System.Net;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using WebRateLimitAuth;
using WebRateLimitAuth.Data;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ??
    throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>();

builder.Services.Configure<MyRateLimitOptions>(
    builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit));

builder.Services.AddRazorPages();
builder.Services.AddControllersWithViews();

var userPolicyName = "user";
var helloPolicy = "hello";
var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);

builder.Services.AddRateLimiter(limiterOptions =>
{
    limiterOptions.OnRejected = (context, cancellationToken) =>
    {
        if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
        {
            context.HttpContext.Response.Headers.RetryAfter =
                ((int) retryAfter.TotalSeconds).ToString(NumberFormatInfo.InvariantInfo);
        }

        context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
        context.HttpContext.RequestServices.GetService<ILoggerFactory>()?
            .CreateLogger("Microsoft.AspNetCore.RateLimitingMiddleware")
            .LogWarning("OnRejected: {GetUserEndPoint}", GetUserEndPoint(context.HttpContext));

        return new ValueTask();
    };

    limiterOptions.AddPolicy<string, SampleRateLimiterPolicy>(helloPolicy);
    limiterOptions.AddPolicy(userPolicyName, context =>
    {
        var username = "anonymous user";
        if (context.User.Identity?.IsAuthenticated is true)
        {
            username = context.User.ToString()!;
        }

        return RateLimitPartition.GetSlidingWindowLimiter(username,
            _ => new SlidingWindowRateLimiterOptions
            {
                PermitLimit = myOptions.PermitLimit,
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                QueueLimit = myOptions.QueueLimit,
                Window = TimeSpan.FromSeconds(myOptions.Window),
                SegmentsPerWindow = myOptions.SegmentsPerWindow
            });

    });
    
    limiterOptions.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, IPAddress>(context =>
    {
        IPAddress? remoteIpAddress = context.Connection.RemoteIpAddress;

        if (!IPAddress.IsLoopback(remoteIpAddress!))
        {
            return RateLimitPartition.GetTokenBucketLimiter
            (remoteIpAddress!, _ =>
                new TokenBucketRateLimiterOptions
                {
                    TokenLimit = myOptions.TokenLimit2,
                    QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                    QueueLimit = myOptions.QueueLimit,
                    ReplenishmentPeriod = TimeSpan.FromSeconds(myOptions.ReplenishmentPeriod),
                    TokensPerPeriod = myOptions.TokensPerPeriod,
                    AutoReplenishment = myOptions.AutoReplenishment
                });
        }

        return RateLimitPartition.GetNoLimiter(IPAddress.Loopback);
    });
});

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseMigrationsEndPoint();
}
else
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();
app.UseRateLimiter();

app.UseAuthentication();
app.UseAuthorization();

app.MapRazorPages().RequireRateLimiting(userPolicyName);
app.MapDefaultControllerRoute();

static string GetUserEndPoint(HttpContext context) =>
   $"User {context.User.Identity?.Name ?? "Anonymous"} endpoint:{context.Request.Path}"
   + $" {context.Connection.RemoteIpAddress}";
static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");

app.MapGet("/a", (HttpContext context) => $"{GetUserEndPoint(context)} {GetTicks()}")
    .RequireRateLimiting(userPolicyName);

app.MapGet("/b", (HttpContext context) => $"{GetUserEndPoint(context)} {GetTicks()}")
    .RequireRateLimiting(helloPolicy);

app.MapGet("/c", (HttpContext context) => $"{GetUserEndPoint(context)} {GetTicks()}");

app.Run();

Предупреждение

Создание секций на IP-адресах клиента делает приложение уязвимым к атакам типа "отказ в обслуживании", которые используют спуфинго IP-адрес. Дополнительные сведения см. в статье BCP 38 RFC 2827 Network Ingress Filtering: поражение атак типа "отказ в обслуживании", использующих подпуфинирование IP-адресов.

Полный файл см . в репозитории Program.cs примеров.

Класс SampleRateLimiterPolicy

using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using WebRateLimitAuth.Models;

namespace WebRateLimitAuth;

public class SampleRateLimiterPolicy : IRateLimiterPolicy<string>
{
    private Func<OnRejectedContext, CancellationToken, ValueTask>? _onRejected;
    private readonly MyRateLimitOptions _options;

    public SampleRateLimiterPolicy(ILogger<SampleRateLimiterPolicy> logger,
                                   IOptions<MyRateLimitOptions> options)
    {
        _onRejected = (ctx, token) =>
        {
            ctx.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
            logger.LogWarning($"Request rejected by {nameof(SampleRateLimiterPolicy)}");
            return ValueTask.CompletedTask;
        };
        _options = options.Value;
    }

    public Func<OnRejectedContext, CancellationToken, ValueTask>? OnRejected => _onRejected;

    public RateLimitPartition<string> GetPartition(HttpContext httpContext)
    {
        return RateLimitPartition.GetSlidingWindowLimiter(string.Empty,
            _ => new SlidingWindowRateLimiterOptions
            {
                PermitLimit = _options.PermitLimit,
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                QueueLimit = _options.QueueLimit,
                Window = TimeSpan.FromSeconds(_options.Window),
                SegmentsPerWindow = _options.SegmentsPerWindow
            });
    }
}

В приведенном выше коде OnRejected используется OnRejectedContext для задания состояния ответа 429 слишком много запросов. Состояние отклонений по умолчанию — 503 служба недоступна.

Ограничение с авторизацией

В следующем примере используются JSвеб-токены ON (JWT) и создается секция с маркером доступа JWT. В рабочем приложении JWT обычно предоставляется сервером, выполняющим роль службы маркеров безопасности (STS). Для локальной разработки средство командной строки dotnet user-jwts можно использовать для создания локальных JWTs для конкретного приложения и управления ими.

using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Primitives;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthorization();
builder.Services.AddAuthentication("Bearer").AddJwtBearer();

var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);
var jwtPolicyName = "jwt";

builder.Services.AddRateLimiter(limiterOptions =>
{
    limiterOptions.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    limiterOptions.AddPolicy(policyName: jwtPolicyName, partitioner: httpContext =>
    {
        var accessToken = httpContext.Features.Get<IAuthenticateResultFeature>()?
                              .AuthenticateResult?.Properties?.GetTokenValue("access_token")?.ToString()
                          ?? string.Empty;

        if (!StringValues.IsNullOrEmpty(accessToken))
        {
            return RateLimitPartition.GetTokenBucketLimiter(accessToken, _ =>
                new TokenBucketRateLimiterOptions
                {
                    TokenLimit = myOptions.TokenLimit2,
                    QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                    QueueLimit = myOptions.QueueLimit,
                    ReplenishmentPeriod = TimeSpan.FromSeconds(myOptions.ReplenishmentPeriod),
                    TokensPerPeriod = myOptions.TokensPerPeriod,
                    AutoReplenishment = myOptions.AutoReplenishment
                });
        }

        return RateLimitPartition.GetTokenBucketLimiter("Anon", _ =>
            new TokenBucketRateLimiterOptions
            {
                TokenLimit = myOptions.TokenLimit,
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                QueueLimit = myOptions.QueueLimit,
                ReplenishmentPeriod = TimeSpan.FromSeconds(myOptions.ReplenishmentPeriod),
                TokensPerPeriod = myOptions.TokensPerPeriod,
                AutoReplenishment = true
            });
    });
});

var app = builder.Build();

app.UseAuthorization();
app.UseRateLimiter();

app.MapGet("/", () => "Hello, World!");

app.MapGet("/jwt", (HttpContext context) => $"Hello {GetUserEndPointMethod(context)}")
    .RequireRateLimiting(jwtPolicyName)
    .RequireAuthorization();

app.MapPost("/post", (HttpContext context) => $"Hello {GetUserEndPointMethod(context)}")
    .RequireRateLimiting(jwtPolicyName)
    .RequireAuthorization();

app.Run();

static string GetUserEndPointMethod(HttpContext context) =>
    $"Hello {context.User.Identity?.Name ?? "Anonymous"} " +
    $"Endpoint:{context.Request.Path} Method: {context.Request.Method}";

Ограничение с ConcurrencyLimiterпараметром , TokenBucketRateLimiterа также авторизацией

Следующий пример:

  • ConcurrencyLimiter Добавляет имя политики, используемое "get" на Razor страницах.
  • TokenBucketRateLimiter Добавляет раздел для каждого авторизованного пользователя и секции для всех анонимных пользователей.
  • Задает RateLimiterOptions.RejectionStatusCode значение 429 слишком много запросов.
var getPolicyName = "get";
var postPolicyName = "post";
var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);

builder.Services.AddRateLimiter(_ => _
    .AddConcurrencyLimiter(policyName: getPolicyName, options =>
    {
        options.PermitLimit = myOptions.PermitLimit;
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    })
    .AddPolicy(policyName: postPolicyName, partitioner: httpContext =>
    {
        string userName = httpContext.User.Identity?.Name ?? string.Empty;

        if (!StringValues.IsNullOrEmpty(userName))
        {
            return RateLimitPartition.GetTokenBucketLimiter(userName, _ =>
                new TokenBucketRateLimiterOptions
                {
                    TokenLimit = myOptions.TokenLimit2,
                    QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                    QueueLimit = myOptions.QueueLimit,
                    ReplenishmentPeriod = TimeSpan.FromSeconds(myOptions.ReplenishmentPeriod),
                    TokensPerPeriod = myOptions.TokensPerPeriod,
                    AutoReplenishment = myOptions.AutoReplenishment
                });
        }

        return RateLimitPartition.GetTokenBucketLimiter("Anon", _ =>
            new TokenBucketRateLimiterOptions
            {
                TokenLimit = myOptions.TokenLimit,
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                QueueLimit = myOptions.QueueLimit,
                ReplenishmentPeriod = TimeSpan.FromSeconds(myOptions.ReplenishmentPeriod),
                TokensPerPeriod = myOptions.TokensPerPeriod,
                AutoReplenishment = true
            });
    }));

Полный файл см . в репозитории Program.cs примеров.

Тестирование конечных точек с ограничением скорости

Прежде чем развертывать приложение с использованием ограничения скорости в рабочей среде, протестируйте приложение для проверки ограничений скорости и используемых параметров. Например, создайте сценарий JMeter с помощью средства, например BlazeMeter или Apache JMeter HTTP(S) Test Script Recorder и загрузите скрипт в Azure Load Testing.

Создание секций с вводом пользователем делает приложение уязвимым к атакам типа "отказ в обслуживании " (DoS). Например, создание секций на IP-адресах клиента делает приложение уязвимым к атакам типа "отказ в обслуживании", которые используют подпуфинирование IP-адресов. Дополнительные сведения см. в статье BCP 38 RFC 2827 Network Ingress Filtering: поражение атак типа "отказ в обслуживании", использующих подпуфинирование IP-адресов.

Дополнительные ресурсы