diff --git a/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Controllers/RateLimitDemoController.cs b/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Controllers/RateLimitDemoController.cs new file mode 100644 index 0000000000..ce2e903dcb --- /dev/null +++ b/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Controllers/RateLimitDemoController.cs @@ -0,0 +1,319 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Volo.Abp.AspNetCore.Mvc; +using Volo.Abp.OperationRateLimit; + +namespace MyCompanyName.MyProjectName.Web.Controllers; + +[Route("api/rate-limit-demo")] +public class RateLimitDemoController : AbpController +{ + private readonly IOperationRateLimitChecker _checker; + + public RateLimitDemoController(IOperationRateLimitChecker checker) + { + _checker = checker; + } + + /// + /// Demo 1: Public - PartitionByParameter (phone number) + /// + [HttpPost("send-sms-code")] + [AllowAnonymous] + public async Task SendSmsCode([FromBody] SendSmsCodeInput input) + { + await _checker.CheckAsync("Demo_SendSmsCode", new OperationRateLimitContext + { + Parameter = input.PhoneNumber, + ExtraProperties = + { + ["PhoneNumber"] = input.PhoneNumber + } + }); + + return Ok(new { success = true, message = $"SMS code sent to {input.PhoneNumber}" }); + } + + /// + /// Demo 2: Public - PartitionByClientIp + /// + [HttpPost("login-attempt")] + [AllowAnonymous] + public async Task LoginAttempt([FromBody] LoginAttemptInput input) + { + await _checker.CheckAsync("Demo_LoginAttempt", new OperationRateLimitContext + { + ExtraProperties = + { + ["Username"] = input.Username + } + }); + + return Ok(new { success = true, message = $"Login attempt for {input.Username}" }); + } + + /// + /// Demo 3: Authenticated - PartitionByCurrentUser + /// + [HttpPost("generate-api-key")] + [Authorize] + public async Task GenerateApiKey() + { + await _checker.CheckAsync("Demo_GenerateApiKey"); + + return Ok(new { success = true, message = "API key generated", key = "demo-key-" + Guid.NewGuid().ToString("N")[..8] }); + } + + /// + /// Demo 4: Authenticated - PartitionByEmail (auto from current user) + /// + [HttpPost("send-email-code")] + [Authorize] + public async Task SendEmailCode() + { + await _checker.CheckAsync("Demo_SendEmailCode"); + + return Ok(new { success = true, message = "Email code sent" }); + } + + /// + /// Demo 5a: Authenticated - Composite (ByUser + ByClientIp) + /// + [HttpPost("composite-user-ip")] + [Authorize] + public async Task CompositeUserIp() + { + await _checker.CheckAsync("Demo_Composite_UserIp"); + + return Ok(new { success = true, message = "Order created", orderId = "ORD-" + Guid.NewGuid().ToString("N")[..6] }); + } + + /// + /// Demo 5b: Authenticated - Composite (ByParameter + ByUser) + /// + [HttpPost("composite-param-user")] + [Authorize] + public async Task CompositeParamUser([FromBody] CompositeParamUserInput input) + { + await _checker.CheckAsync("Demo_Composite_ParamUser", new OperationRateLimitContext + { + Parameter = input.Key + }); + + return Ok(new { success = true, message = $"Operation completed for key: {input.Key}" }); + } + + /// + /// Demo 5c: Authenticated - Composite (ByParameter + ByUser + ByClientIp) + /// + [HttpPost("composite-triple")] + [Authorize] + public async Task CompositeTriple([FromBody] CompositeTripleInput input) + { + await _checker.CheckAsync("Demo_Composite_Triple", new OperationRateLimitContext + { + Parameter = input.Key + }); + + return Ok(new { success = true, message = $"Triple composite OK for key: {input.Key}" }); + } + + /// + /// Demo 6: Public - Custom error code + /// + [HttpPost("submit-feedback")] + [AllowAnonymous] + public async Task SubmitFeedback([FromBody] SubmitFeedbackInput input) + { + await _checker.CheckAsync("Demo_SubmitFeedback", new OperationRateLimitContext + { + Parameter = input.Email, + ExtraProperties = + { + ["Email"] = input.Email, + ["Category"] = input.Category + } + }); + + return Ok(new { success = true, message = "Feedback submitted" }); + } + + /// + /// Demo 7: Public - Long duration hours + /// + [HttpPost("long-hours")] + [AllowAnonymous] + public async Task LongHours([FromBody] LongHoursInput input) + { + await _checker.CheckAsync("Demo_LongHours", new OperationRateLimitContext + { + Parameter = input.Key + }); + + return Ok(new { success = true, message = $"Operation completed for key: {input.Key}" }); + } + + /// + /// Demo 8: Public - Long duration days + /// + [HttpPost("long-days")] + [AllowAnonymous] + public async Task LongDays() + { + await _checker.CheckAsync("Demo_LongDays"); + + return Ok(new { success = true, message = "Daily operation completed" }); + } + + /// + /// Demo 9: Authenticated - Custom multi-key resolver (Parameter + UserId combined) + /// + [HttpPost("custom-multi-key")] + [Authorize] + public async Task CustomMultiKey([FromBody] CustomMultiKeyInput input) + { + await _checker.CheckAsync("Demo_CustomMultiKey", new OperationRateLimitContext + { + Parameter = input.ResourceId + }); + + return Ok(new { success = true, message = $"Resource '{input.ResourceId}' processed" }); + } + + /// + /// Demo 10: Public - PartitionByParameter with WithMultiTenancy() + /// Same parameter value has independent counters per tenant. + /// + [HttpPost("demo-tenant-isolated")] + [AllowAnonymous] + public async Task DemoTenantIsolated([FromBody] DemoTenantIsolatedInput input) + { + await _checker.CheckAsync("Demo_TenantIsolated", new OperationRateLimitContext + { + Parameter = input.Key + }); + + return Ok(new { success = true, message = $"Tenant-isolated operation completed for key: {input.Key}" }); + } + + /// + /// Get status without consuming quota + /// + [HttpGet("status/{policyName}")] + [AllowAnonymous] + public async Task GetStatus(string policyName, [FromQuery] string? parameter = null) + { + var context = new OperationRateLimitContext { Parameter = parameter }; + var status = await _checker.GetStatusAsync(policyName, context); + + return Ok(new + { + status.IsAllowed, + status.RemainingCount, + status.MaxCount, + status.CurrentCount, + RetryAfterSeconds = (int)(status.RetryAfter?.TotalSeconds ?? 0) + }); + } + + /// + /// Reset a policy counter + /// + [HttpPost("reset/{policyName}")] + [AllowAnonymous] + public async Task Reset(string policyName, [FromQuery] string? parameter = null) + { + var context = new OperationRateLimitContext { Parameter = parameter }; + await _checker.ResetAsync(policyName, context); + + return Ok(new { success = true, message = $"Policy '{policyName}' reset" }); + } + + /// + /// Reset all demo policies + /// + [HttpPost("reset-all")] + [AllowAnonymous] + public async Task ResetAll( + [FromQuery] string? smsPhone = null, + [FromQuery] string? feedbackEmail = null, + [FromQuery] string? longHoursKey = null, + [FromQuery] string? compositeParamKey = null, + [FromQuery] string? compositeTripleKey = null, + [FromQuery] string? customMultiKeyResourceId = null, + [FromQuery] string? tenantIsolatedKey = null) + { + var policies = new[] + { + ("Demo_SendSmsCode", smsPhone), + ("Demo_LoginAttempt", (string?)null), + ("Demo_GenerateApiKey", (string?)null), + ("Demo_SendEmailCode", (string?)null), + ("Demo_Composite_UserIp", (string?)null), + ("Demo_Composite_ParamUser", compositeParamKey), + ("Demo_Composite_Triple", compositeTripleKey), + ("Demo_SubmitFeedback", feedbackEmail), + ("Demo_LongHours", longHoursKey), + ("Demo_LongDays", (string?)null), + ("Demo_CustomMultiKey", customMultiKeyResourceId), + ("Demo_TenantIsolated", tenantIsolatedKey), + }; + + foreach (var (policyName, parameter) in policies) + { + try + { + await _checker.ResetAsync(policyName, new OperationRateLimitContext { Parameter = parameter }); + } + catch + { + // Ignore errors for individual resets (e.g. not logged in for auth policies) + } + } + + return Ok(new { success = true, message = "All policies reset" }); + } +} + +public class SendSmsCodeInput +{ + public string PhoneNumber { get; set; } = default!; +} + +public class LoginAttemptInput +{ + public string Username { get; set; } = default!; +} + +public class SubmitFeedbackInput +{ + public string Email { get; set; } = default!; + public string Category { get; set; } = default!; +} + +public class LongHoursInput +{ + public string Key { get; set; } = default!; +} + +public class CompositeParamUserInput +{ + public string Key { get; set; } = default!; +} + +public class CompositeTripleInput +{ + public string Key { get; set; } = default!; +} + +public class CustomMultiKeyInput +{ + public string ResourceId { get; set; } = default!; +} + +public class DemoTenantIsolatedInput +{ + public string Key { get; set; } = default!; +} diff --git a/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Menus/MyProjectNameMenuContributor.cs b/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Menus/MyProjectNameMenuContributor.cs index 84f6b391b7..0229d24fba 100644 --- a/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Menus/MyProjectNameMenuContributor.cs +++ b/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Menus/MyProjectNameMenuContributor.cs @@ -46,6 +46,16 @@ public class MyProjectNameMenuContributor : IMenuContributor administration.SetSubItemOrder(IdentityMenuNames.GroupName, 2); administration.SetSubItemOrder(SettingManagementMenuNames.GroupName, 3); + context.Menu.AddItem( + new ApplicationMenuItem( + "RateLimitDemo", + "Rate Limit Demo", + "~/RateLimitDemo", + icon: "fas fa-tachometer-alt", + order: 100 + ) + ); + return Task.CompletedTask; } } diff --git a/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/MyCompanyName.MyProjectName.Web.csproj b/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/MyCompanyName.MyProjectName.Web.csproj index cf8dd3d500..303ebcc66f 100644 --- a/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/MyCompanyName.MyProjectName.Web.csproj +++ b/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/MyCompanyName.MyProjectName.Web.csproj @@ -58,6 +58,7 @@ + diff --git a/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/MyProjectNameWebModule.cs b/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/MyProjectNameWebModule.cs index 155f08b603..8b69951927 100644 --- a/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/MyProjectNameWebModule.cs +++ b/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/MyProjectNameWebModule.cs @@ -39,6 +39,7 @@ using Volo.Abp.OpenIddict; using Volo.Abp.UI.Navigation.Urls; using Volo.Abp.UI; using Volo.Abp.UI.Navigation; +using Volo.Abp.OperationRateLimit; using Volo.Abp.VirtualFileSystem; namespace MyCompanyName.MyProjectName.Web; @@ -54,7 +55,8 @@ namespace MyCompanyName.MyProjectName.Web; typeof(AbpAspNetCoreMvcUiLeptonXLiteThemeModule), typeof(AbpTenantManagementWebModule), typeof(AbpAspNetCoreSerilogModule), - typeof(AbpSwashbuckleModule) + typeof(AbpSwashbuckleModule), + typeof(AbpOperationRateLimitModule) )] public class MyProjectNameWebModule : AbpModule { @@ -113,6 +115,130 @@ public class MyProjectNameWebModule : AbpModule ConfigureSwaggerServices(context.Services); context.Services.AddMapperlyObjectMapper(); + + ConfigureOperationRateLimit(); + } + + private void ConfigureOperationRateLimit() + { + Configure(options => + { + // Demo 1: Public - rate limit by parameter (e.g. phone/email), no auth required + options.AddPolicy("Demo_SendSmsCode", policy => + { + policy.WithFixedWindow(TimeSpan.FromSeconds(20), maxCount: 3) + .PartitionByParameter(); + }); + + // Demo 2: Public - rate limit by client IP + options.AddPolicy("Demo_LoginAttempt", policy => + { + policy.WithFixedWindow(TimeSpan.FromSeconds(20), maxCount: 5) + .PartitionByClientIp(); + }); + + // Demo 3: Authenticated - rate limit by current user + options.AddPolicy("Demo_GenerateApiKey", policy => + { + policy.WithFixedWindow(TimeSpan.FromSeconds(20), maxCount: 3) + .PartitionByCurrentUser(); + }); + + // Demo 4: Authenticated - rate limit by email (auto from current user) + options.AddPolicy("Demo_SendEmailCode", policy => + { + policy.WithFixedWindow(TimeSpan.FromSeconds(20), maxCount: 2) + .PartitionByEmail(); + }); + + // Demo 5a: Composite - ByCurrentUser + ByClientIp + // IP triggers first (3/20s). User window is 2min so it won't expire during testing. + // After IP resets (20s), 2 more requests trigger user rule (5/2min). + options.AddPolicy("Demo_Composite_UserIp", policy => + { + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromMinutes(2), maxCount: 5) + .PartitionByCurrentUser()); + + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromSeconds(20), maxCount: 3) + .PartitionByClientIp()); + }); + + // Demo 5b: Composite - ByParameter + ByCurrentUser + // User triggers first (2/20s). Parameter has higher limit (5/2min). + // After user window resets, parameter counter remains. + options.AddPolicy("Demo_Composite_ParamUser", policy => + { + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromMinutes(2), maxCount: 5) + .PartitionByParameter()); + + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromSeconds(20), maxCount: 2) + .PartitionByCurrentUser()); + }); + + // Demo 5c: Composite - ByParameter + ByCurrentUser + ByClientIp (triple) + // IP (3/20s) triggers first, then user (4/2min), then parameter (5/2min). + options.AddPolicy("Demo_Composite_Triple", policy => + { + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromMinutes(2), maxCount: 5) + .PartitionByParameter()); + + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromMinutes(2), maxCount: 4) + .PartitionByCurrentUser()); + + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromSeconds(20), maxCount: 3) + .PartitionByClientIp()); + }); + + // Demo 6: Custom error code + options.AddPolicy("Demo_SubmitFeedback", policy => + { + policy.WithFixedWindow(TimeSpan.FromSeconds(20), maxCount: 2) + .PartitionByParameter() + .WithErrorCode("App:Feedback:RateLimited"); + }); + + // Demo 7: Long duration - hours (test "X hours Y minutes" formatting) + options.AddPolicy("Demo_LongHours", policy => + { + policy.WithFixedWindow(TimeSpan.FromHours(3), maxCount: 2) + .PartitionByParameter(); + }); + + // Demo 8: Long duration - days (test "X days Y hours" formatting) + options.AddPolicy("Demo_LongDays", policy => + { + policy.WithFixedWindow(TimeSpan.FromDays(3), maxCount: 1) + .PartitionByClientIp(); + }); + + // Demo 9: Custom multi-key resolver - combines resource ID (Parameter) + user ID + // into a single partition key so each user has an independent quota per resource. + options.AddPolicy("Demo_CustomMultiKey", policy => + { + policy.WithFixedWindow(TimeSpan.FromSeconds(20), maxCount: 2) + .PartitionBy(ctx => + { + var userId = ctx.GetRequiredService().Id?.ToString() ?? "anonymous"; + return $"{ctx.Parameter}:{userId}"; + }); + }); + + // Demo 10: Multi-tenancy - same parameter value has independent counters per tenant. + // Without .WithMultiTenancy(), all tenants share the same counter. + options.AddPolicy("Demo_TenantIsolated", policy => + { + policy.WithFixedWindow(TimeSpan.FromSeconds(20), maxCount: 3) + .WithMultiTenancy() + .PartitionByParameter(); + }); + }); } private void ConfigureAuthentication(ServiceConfigurationContext context) diff --git a/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Pages/Index.cshtml b/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Pages/Index.cshtml index 76297c61f3..d3cf52dc5d 100644 --- a/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Pages/Index.cshtml +++ b/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Pages/Index.cshtml @@ -4,10 +4,7 @@
-

Getting Started

-

Learn how to create and run a new web application using the application startup template.

- Getting Started - + Login

Web Application Development Tutorial

Learn how to build an ABP based web application named Acme.BookStore.

Explore Tutorial diff --git a/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Pages/RateLimitDemo/Index.cshtml b/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Pages/RateLimitDemo/Index.cshtml new file mode 100644 index 0000000000..5c5aa167ce --- /dev/null +++ b/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Pages/RateLimitDemo/Index.cshtml @@ -0,0 +1,666 @@ +@page +@model MyCompanyName.MyProjectName.Web.Pages.RateLimitDemo.IndexModel + +@section styles { + +} + +
+
+
+

Operation Rate Limit Demo

+

+ Test various rate limiting policies. Each card shows a different partition strategy and window configuration. + When a limit is triggered, observe the localized error message and countdown timer. +

+
+ Public = no login required + Auth = login required + +
+
+
+ +
+ +
+
+
+ 1. Send SMS Code + Public +
+
+
+ Partition: PartitionByParameter
+ Rule: 3 requests per phone number within 20 seconds
+ Note: Each phone number has an independent counter. The parameter is passed explicitly by the caller. +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+
+ 2. Login Attempt + Public +
+
+
+ Partition: PartitionByClientIp
+ Rule: 5 requests per IP within 20 seconds
+ Note: Client IP is resolved automatically. All requests from the same IP share one counter; no parameter needed. +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+
+ 3. Generate API Key + Auth +
+
+
+ Partition: PartitionByCurrentUser
+ Rule: 3 requests per user within 20 seconds
+ Note: Requires login. Current user ID is used as the partition key automatically; each user has an independent counter. +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+
+ 4. Send Email Code + Auth +
+
+
+ Partition: PartitionByEmail
+ Rule: 2 requests per email within 20 seconds
+ Note: Requires login. Email is resolved from the current user's email claim automatically; no manual parameter needed. +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+
+ 5a. Composite: User + IP + Auth +
+
+
+ Partition: ByCurrentUser + ByClientIp
+ Rule 1: 5 requests per user within 2 minutes
+ Rule 2: 3 requests per IP within 20 seconds
+ Note: IP window (20s) triggers first; user window (2min) does not expire during the wait. Two-phase check prevents wasted quota on blocked requests.
+ How to test: Click 3 times quickly → IP blocked (3/3); wait 20s then click 2 more → user rule triggers (5/5). +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+
+ 5b. Composite: Param + User + Auth +
+
+
+ Partition: ByParameter + ByCurrentUser
+ Rule 1: 5 requests per key within 2 minutes
+ Rule 2: 2 requests per user within 20 seconds
+ Note: User limit (2/20s) triggers first; parameter rule (5/2min) only counts successful requests. Parameter counter persists after user window resets.
+ How to test: Click 2 times → user blocked (2/2); wait 20s, click 2 more → user blocked again; wait 20s, click 1 more → parameter rule triggers (5/5). +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+
+ 5c. Composite: Triple + Auth +
+
+
+ Partition: ByParameter + ByCurrentUser + ByClientIp
+ Rule 1: 5 requests per key within 2 minutes
+ Rule 2: 4 requests per user within 2 minutes
+ Rule 3: 3 requests per IP within 20 seconds
+ Note: All three rules apply (AND logic). IP (3/20s) triggers first; after 20s reset, user (4/2min) becomes the bottleneck. +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+
+ 6. Submit Feedback (Custom Error Code) + Public +
+
+
+ Partition: PartitionByParameter
+ Rule: 2 requests per email within 20 seconds
+ Custom error code: App:Feedback:RateLimited
+ Note: Default error code is Volo.Abp.OperationRateLimit:010001. This policy uses WithErrorCode() to set a custom code so the frontend can handle it differently. +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+
+ 7. Long Window - Hours + Public +
+
+
+ Partition: PartitionByParameter
+ Rule: 2 requests per key within 3 hours
+ Note: Tests RetryAfter formatting for long windows. When triggered, the message should display something like "X hours Y minutes". +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+
+ 8. Long Window - Days + Public +
+
+
+ Partition: PartitionByClientIp
+ Rule: 1 request per IP within 3 days
+ Note: Tests RetryAfter formatting for very long windows. When triggered, the message should display something like "X days Y hours". Only 1 request is needed to trigger it. +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+
+ 9. Custom Multi-Key Resolver + Auth +
+
+
+ Partition: PartitionBy(ctx => $"{resourceId}:{userId}")
+ Rule: 2 requests per user per resource within 20 seconds
+ Note: A custom resolver combines multiple values into one partition key. Each user has an independent quota per resource — different users on the same resource don't affect each other, and the same user on different resources doesn't either.
+ How to test: Click 2 times with the same Resource ID → limit triggered; change the Resource ID → quota resets. +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+
+ 10. Multi-Tenancy Isolation + Public +
+
+
+ Partition: PartitionByParameter + .WithMultiTenancy()
+ Rule: 3 requests per parameter per tenant within 20 seconds
+ Note: The same parameter value has independent counters in different tenants. Without .WithMultiTenancy(), all tenants share the same counter. +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+ + +
+
+
+
+ Last Error Response (Raw JSON) + +
+
+

+                
+
+
+
+
+ +@section scripts { + +} diff --git a/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Pages/RateLimitDemo/Index.cshtml.cs b/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Pages/RateLimitDemo/Index.cshtml.cs new file mode 100644 index 0000000000..cbf9aaec24 --- /dev/null +++ b/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Pages/RateLimitDemo/Index.cshtml.cs @@ -0,0 +1,8 @@ +namespace MyCompanyName.MyProjectName.Web.Pages.RateLimitDemo; + +public class IndexModel : MyProjectNamePageModel +{ + public void OnGet() + { + } +}