diff --git a/docs/en/docs-nav.json b/docs/en/docs-nav.json index 51b3c3e889..f84892243b 100644 --- a/docs/en/docs-nav.json +++ b/docs/en/docs-nav.json @@ -721,6 +721,10 @@ "text": "Distributed Locking", "path": "framework/infrastructure/distributed-locking.md" }, + { + "text": "Operation Rate Limiting", + "path": "framework/infrastructure/operation-rate-limiting.md" + }, { "text": "Email Sending", "items": [ diff --git a/docs/en/framework/infrastructure/operation-rate-limiting.md b/docs/en/framework/infrastructure/operation-rate-limiting.md new file mode 100644 index 0000000000..8859d4a0a1 --- /dev/null +++ b/docs/en/framework/infrastructure/operation-rate-limiting.md @@ -0,0 +1,480 @@ +````json +//[doc-seo] +{ + "Description": "Learn how to use the Operation Rate Limiting module in ABP Framework to control the frequency of specific operations like SMS sending, login attempts, and resource-intensive tasks." +} +```` + +# Operation Rate Limiting + +ABP provides an operation rate limiting system that allows you to control the frequency of specific operations in your application. You may need operation rate limiting for several reasons: + +* Do not allow sending an SMS verification code to the same phone number more than 3 times in an hour. +* Do not allow generating a "monthly sales report" more than 2 times per day for each user (if generating the report is resource-intensive). +* Restrict login attempts per IP address to prevent brute-force attacks. + +> This is not for [ASP.NET Core's built-in rate limiting middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) which works at the HTTP request pipeline level. This module works at the **application/domain code level** and is called explicitly from your services. See the [Combining with ASP.NET Core Rate Limiting](#combining-with-aspnet-core-rate-limiting) section for a comparison. + +## Installation + +You can open a command-line terminal and type the following command to install the [Volo.Abp.OperationRateLimiting](https://www.nuget.org/packages/Volo.Abp.OperationRateLimiting) package into your project: + +````bash +abp add-package Volo.Abp.OperationRateLimiting +```` + +> If you haven't done it yet, you first need to install the [ABP CLI](../../../cli). + +## Quick Start + +This section shows the basic usage of the operation rate limiting system with a simple example. + +### Defining a Policy + +First, define a rate limiting policy in the `ConfigureServices` method of your [module class](../../architecture/modularity/basics.md): + +````csharp +Configure(options => +{ + options.AddPolicy("SendSmsCode", policy => + { + policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) + .PartitionByParameter(); + }); +}); +```` + +* `"SendSmsCode"` is a unique name for this policy. +* `WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1)` means at most **1 request per minute**. +* `PartitionByParameter()` means the counter is keyed by the parameter you pass at check time (e.g., a phone number), so different phone numbers have independent counters. + +### Checking the Limit + +Then inject `IOperationRateLimitingChecker` and call `CheckAsync` in your service: + +````csharp +public class SmsAppService : ApplicationService +{ + private readonly IOperationRateLimitingChecker _rateLimitChecker; + + public SmsAppService(IOperationRateLimitingChecker rateLimitChecker) + { + _rateLimitChecker = rateLimitChecker; + } + + public async Task SendCodeAsync(string phoneNumber) + { + await _rateLimitChecker.CheckAsync("SendSmsCode", + new OperationRateLimitingContext { Parameter = phoneNumber }); + + // If we reach here, the limit was not exceeded. + // Send the SMS code... + } +} +```` + +* `CheckAsync` increments the counter and throws `AbpOperationRateLimitingException` (HTTP 429) if the limit is exceeded. +* Each phone number has its own counter because we used `PartitionByParameter()`. + +That's the basic usage. The following sections explain each concept in detail. + +## Defining Policies + +Policies are defined using `AbpOperationRateLimitingOptions` in the `ConfigureServices` method of your [module class](../../architecture/modularity/basics.md). Each policy has a unique name, one or more rules, and a partition strategy. + +### Single-Rule Policies + +For simple scenarios, use the `WithFixedWindow` shortcut directly on the policy builder: + +````csharp +options.AddPolicy("SendSmsCode", policy => +{ + policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) + .PartitionByParameter(); +}); +```` + +### Multi-Rule Policies + +Use `AddRule` to combine multiple rules. All rules are checked together (**AND** logic) — a request is allowed only when **all** rules pass: + +````csharp +options.AddPolicy("Login", policy => +{ + // Rule 1: Max 5 attempts per 5 minutes per username + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 5) + .PartitionByParameter()); + + // Rule 2: Max 20 attempts per hour per IP + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 20) + .PartitionByClientIp()); +}); +```` + +> When multiple rules are present, the module uses a **two-phase check**: it first verifies all rules without incrementing counters, then increments only if all rules pass. This prevents wasted quota when one rule would block the request. + +### Custom Error Code + +By default, the exception uses the error code `Volo.Abp.OperationRateLimiting:010001`. You can override it per policy: + +````csharp +options.AddPolicy("SendSmsCode", policy => +{ + policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) + .PartitionByParameter() + .WithErrorCode("App:SmsCodeLimit"); +}); +```` + +## Partition Types + +Each rule must specify a **partition type** that determines how requests are grouped. Requests with different partition keys have independent counters. + +### PartitionByParameter + +Uses the `Parameter` value from the context you pass to `CheckAsync`: + +````csharp +policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) + .PartitionByParameter(); + +// Each phone number has its own counter +await checker.CheckAsync("SendSmsCode", + new OperationRateLimitingContext { Parameter = phoneNumber }); +```` + +### PartitionByCurrentUser + +Uses `ICurrentUser.Id` as the partition key. The user must be authenticated: + +````csharp +policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 10) + .PartitionByCurrentUser(); +```` + +> If you need to check rate limits for a specific user (e.g., admin checking another user's limit), use `PartitionByParameter()` and pass the user ID as the `Parameter`. + +### PartitionByCurrentTenant + +Uses `ICurrentTenant.Id` as the partition key. Uses `"host"` for the host side when no tenant is active: + +````csharp +policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100) + .PartitionByCurrentTenant(); +```` + +### PartitionByClientIp + +Uses `IWebClientInfoProvider.ClientIpAddress` as the partition key: + +````csharp +policy.WithFixedWindow(TimeSpan.FromMinutes(15), maxCount: 10) + .PartitionByClientIp(); +```` + +> This requires an ASP.NET Core environment. In non-web scenarios, the IP address cannot be determined and an exception will be thrown. Use `PartitionByParameter()` if you need to pass the IP explicitly. + +### PartitionByEmail + +Resolves from `context.Parameter` first, then falls back to `ICurrentUser.Email`: + +````csharp +policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) + .PartitionByEmail(); + +// For unauthenticated users, pass the email explicitly: +await checker.CheckAsync("SendEmailCode", + new OperationRateLimitingContext { Parameter = email }); +```` + +### PartitionByPhoneNumber + +Works the same way as `PartitionByEmail`: resolves from `context.Parameter` first, then falls back to `ICurrentUser.PhoneNumber`. + +### Custom Partition (PartitionBy) + +You can provide a custom async function to generate the partition key. The async signature allows you to perform database queries or other I/O operations: + +````csharp +policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100) + .PartitionBy(ctx => Task.FromResult( + $"{ctx.Parameter}:{ctx.ExtraProperties["DeviceId"]}")); +```` + +## Multi-Tenancy + +By default, rate limit counters are **shared across all tenants**. You can enable tenant isolation for a rule by calling `WithMultiTenancy()`: + +````csharp +policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) + .WithMultiTenancy() + .PartitionByParameter()); +```` + +When multi-tenancy is enabled, the cache key includes the tenant ID, so each tenant has independent counters: + +* **Global key format:** `orl:{PolicyName}:{RuleKey}:{PartitionKey}` +* **Tenant-isolated key format:** `orl:t:{TenantId}:{PolicyName}:{RuleKey}:{PartitionKey}` + +## Checking the Limit + +Inject `IOperationRateLimitingChecker` to interact with rate limits. It provides four methods: + +### CheckAsync + +The primary method. It checks the rate limit and **increments the counter** if allowed. Throws `AbpOperationRateLimitingException` (HTTP 429) if the limit is exceeded: + +````csharp +await checker.CheckAsync("SendSmsCode", + new OperationRateLimitingContext { Parameter = phoneNumber }); +```` + +### IsAllowedAsync + +A read-only check that returns `true` or `false` **without incrementing** the counter. Useful for UI pre-checks (e.g., disabling a button before the user clicks): + +````csharp +var isAllowed = await checker.IsAllowedAsync("SendSmsCode", + new OperationRateLimitingContext { Parameter = phoneNumber }); +```` + +### GetStatusAsync + +Returns detailed status information **without incrementing** the counter: + +````csharp +var status = await checker.GetStatusAsync("SendSmsCode", + new OperationRateLimitingContext { Parameter = phoneNumber }); + +// status.IsAllowed - whether the next request would be allowed +// status.RemainingCount - how many requests are left in this window +// status.RetryAfter - time until the window resets +// status.MaxCount - maximum allowed count +// status.CurrentCount - current usage count +```` + +### ResetAsync + +Resets the counter for a specific policy and context. This can be useful for administrative operations: + +````csharp +await checker.ResetAsync("SendSmsCode", + new OperationRateLimitingContext { Parameter = phoneNumber }); +```` + +## The Exception + +When a rate limit is exceeded, `CheckAsync` throws `AbpOperationRateLimitingException`. This exception: + +* Extends `BusinessException` and implements `IHasHttpStatusCode` with status code **429** (Too Many Requests). +* Is automatically handled by ABP's exception handling pipeline and serialized into the HTTP response. + +The exception includes the following data properties: + +| Key | Type | Description | +|-----|------|-------------| +| `PolicyName` | string | Name of the triggered policy | +| `MaxCount` | int | Maximum allowed count | +| `CurrentCount` | int | Current usage count | +| `RemainingCount` | int | Remaining allowed count | +| `RetryAfterSeconds` | int | Seconds until the window resets | +| `RetryAfter` | string | Localized retry-after description (e.g., "5 minutes") | +| `WindowDurationSeconds` | int | Total window duration in seconds | +| `WindowDescription` | string | Localized window description | +| `RuleDetails` | List | Per-rule details (for multi-rule policies) | + +## Configuration + +### AbpOperationRateLimitingOptions + +`AbpOperationRateLimitingOptions` is the main options class for the operation rate limiting system: + +````csharp +Configure(options => +{ + options.IsEnabled = true; + options.LockTimeout = TimeSpan.FromSeconds(5); +}); +```` + +* **`IsEnabled`** (`bool`, default: `true`): Global switch to enable or disable rate limiting. When set to `false`, all `CheckAsync` calls pass through without checking. This is useful for disabling rate limiting in development (see [below](#disabling-in-development)). +* **`LockTimeout`** (`TimeSpan`, default: `5 seconds`): Timeout for acquiring the distributed lock during counter increment operations. + +## Advanced Usage + +### Disabling in Development + +You may want to disable rate limiting during development to avoid being blocked while testing: + +````csharp +public override void ConfigureServices(ServiceConfigurationContext context) +{ + var hostEnvironment = context.Services.GetHostingEnvironment(); + + Configure(options => + { + if (hostEnvironment.IsDevelopment()) + { + options.IsEnabled = false; + } + }); +} +```` + +### Ban Policy (maxCount: 0) + +Setting `maxCount` to `0` creates a ban policy that blocks all requests for the specified duration: + +````csharp +options.AddPolicy("BlockedUser", policy => +{ + policy.WithFixedWindow(TimeSpan.FromHours(24), maxCount: 0) + .PartitionByParameter(); +}); +```` + +### Passing Extra Properties + +Use `ExtraProperties` on `OperationRateLimitingContext` to pass additional context data. These values are available in custom partition resolvers and are included in the exception data when the limit is exceeded: + +````csharp +await checker.CheckAsync("ApiCall", new OperationRateLimitingContext +{ + Parameter = apiEndpoint, + ExtraProperties = + { + ["DeviceId"] = deviceId, + ["ClientVersion"] = clientVersion + } +}); +```` + +### Pre-checking Before Expensive Operations + +Use `IsAllowedAsync` or `GetStatusAsync` to check the limit **before** performing expensive work (e.g., validating input or querying the database): + +````csharp +public async Task SendCodeAsync(string phoneNumber) +{ + var context = new OperationRateLimitingContext { Parameter = phoneNumber }; + + // Check limit before doing any work + var status = await _rateLimitChecker.GetStatusAsync("SendSmsCode", context); + + if (!status.IsAllowed) + { + return new SendCodeResultDto + { + Success = false, + RetryAfterSeconds = (int)(status.RetryAfter?.TotalSeconds ?? 0) + }; + } + + // Now do the actual work and increment the counter + await _rateLimitChecker.CheckAsync("SendSmsCode", context); + + await _smsSender.SendAsync(phoneNumber, GenerateCode()); + return new SendCodeResultDto { Success = true }; +} +```` + +> `IsAllowedAsync` and `GetStatusAsync` are read-only — they do not increment the counter. Only `CheckAsync` increments. + +### Checking on Behalf of Another User + +`PartitionByCurrentUser()`, `PartitionByCurrentTenant()`, and `PartitionByClientIp()` always resolve from their respective services (`ICurrentUser`, `ICurrentTenant`, `IWebClientInfoProvider`) and do not accept explicit overrides. This design avoids partition key conflicts in [composite policies](#multi-rule-policies) where `Parameter` is shared across all rules. + +If you need to check or enforce rate limits for a **specific user, tenant, or IP**, define the policy with `PartitionByParameter()` and pass the value explicitly: + +````csharp +// Policy definition: use PartitionByParameter for explicit control +options.AddPolicy("UserApiLimit", policy => +{ + policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100) + .PartitionByParameter(); +}); +```` + +````csharp +// Check current user's limit +await checker.CheckAsync("UserApiLimit", + new OperationRateLimitingContext { Parameter = CurrentUser.Id.ToString() }); + +// Admin checking another user's limit +await checker.CheckAsync("UserApiLimit", + new OperationRateLimitingContext { Parameter = targetUserId.ToString() }); + +// Check a specific IP in a background job +await checker.CheckAsync("UserApiLimit", + new OperationRateLimitingContext { Parameter = ipAddress }); +```` + +This approach gives you full flexibility while keeping the API simple — `PartitionByCurrentUser()` is a convenience shortcut for "always use the current authenticated user", and `PartitionByParameter()` is for "I want to specify the value explicitly". + +### Combining with ASP.NET Core Rate Limiting + +This module and ASP.NET Core's built-in [rate limiting middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) serve different purposes and can be used together: + +| | ASP.NET Core Rate Limiting | Operation Rate Limiting | +|---|---|---| +| **Level** | HTTP request pipeline | Application/domain code | +| **Scope** | All incoming requests | Specific business operations | +| **Usage** | Middleware (automatic) | Explicit `CheckAsync` calls | +| **Typical use** | API throttling, DDoS protection | Business logic limits (SMS, reports) | + +A common pattern is to use ASP.NET Core middleware for broad API protection and this module for fine-grained business operation limits. + +## Extensibility + +### Custom Store + +The default store uses ABP's `IDistributedCache`. You can replace it by implementing `IOperationRateLimitingStore`: + +````csharp +public class MyCustomStore : IOperationRateLimitingStore, ITransientDependency +{ + public Task IncrementAsync( + string key, TimeSpan duration, int maxCount) + { + // Your custom implementation (e.g., Redis Lua script for atomicity) + } + + public Task GetAsync( + string key, TimeSpan duration, int maxCount) + { + // Read-only check + } + + public Task ResetAsync(string key) + { + // Reset the counter + } +} +```` + +ABP's [dependency injection](../../fundamentals/dependency-injection.md) system will automatically use your implementation since it replaces the default one. + +### Custom Rule + +You can implement custom rate limiting algorithms (e.g., sliding window, token bucket) by implementing `IOperationRateLimitingRule` and registering it with `AddRule()`: + +````csharp +policy.AddRule(); +```` + +### Custom Formatter + +Replace `IOperationRateLimitingFormatter` to customize how time durations are displayed in error messages (e.g., "5 minutes", "2 hours 30 minutes"). + +### Custom Policy Provider + +Replace `IOperationRateLimitingPolicyProvider` to load policies from a database or external configuration source instead of the in-memory options. + +## See Also + +* [ASP.NET Core Rate Limiting Middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) +* [Distributed Caching](../../fundamentals/caching.md) +* [Exception Handling](../../fundamentals/exception-handling.md)