Browse Source

Add Rate Limit Demo page.

pull/25025/head
maliming 7 months ago
parent
commit
deb4fde6aa
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 319
      templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Controllers/RateLimitDemoController.cs
  2. 10
      templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Menus/MyProjectNameMenuContributor.cs
  3. 1
      templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/MyCompanyName.MyProjectName.Web.csproj
  4. 128
      templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/MyProjectNameWebModule.cs
  5. 5
      templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Pages/Index.cshtml
  6. 666
      templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Pages/RateLimitDemo/Index.cshtml
  7. 8
      templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Pages/RateLimitDemo/Index.cshtml.cs

319
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;
}
/// <summary>
/// Demo 1: Public - PartitionByParameter (phone number)
/// </summary>
[HttpPost("send-sms-code")]
[AllowAnonymous]
public async Task<IActionResult> 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}" });
}
/// <summary>
/// Demo 2: Public - PartitionByClientIp
/// </summary>
[HttpPost("login-attempt")]
[AllowAnonymous]
public async Task<IActionResult> 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}" });
}
/// <summary>
/// Demo 3: Authenticated - PartitionByCurrentUser
/// </summary>
[HttpPost("generate-api-key")]
[Authorize]
public async Task<IActionResult> GenerateApiKey()
{
await _checker.CheckAsync("Demo_GenerateApiKey");
return Ok(new { success = true, message = "API key generated", key = "demo-key-" + Guid.NewGuid().ToString("N")[..8] });
}
/// <summary>
/// Demo 4: Authenticated - PartitionByEmail (auto from current user)
/// </summary>
[HttpPost("send-email-code")]
[Authorize]
public async Task<IActionResult> SendEmailCode()
{
await _checker.CheckAsync("Demo_SendEmailCode");
return Ok(new { success = true, message = "Email code sent" });
}
/// <summary>
/// Demo 5a: Authenticated - Composite (ByUser + ByClientIp)
/// </summary>
[HttpPost("composite-user-ip")]
[Authorize]
public async Task<IActionResult> CompositeUserIp()
{
await _checker.CheckAsync("Demo_Composite_UserIp");
return Ok(new { success = true, message = "Order created", orderId = "ORD-" + Guid.NewGuid().ToString("N")[..6] });
}
/// <summary>
/// Demo 5b: Authenticated - Composite (ByParameter + ByUser)
/// </summary>
[HttpPost("composite-param-user")]
[Authorize]
public async Task<IActionResult> 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}" });
}
/// <summary>
/// Demo 5c: Authenticated - Composite (ByParameter + ByUser + ByClientIp)
/// </summary>
[HttpPost("composite-triple")]
[Authorize]
public async Task<IActionResult> 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}" });
}
/// <summary>
/// Demo 6: Public - Custom error code
/// </summary>
[HttpPost("submit-feedback")]
[AllowAnonymous]
public async Task<IActionResult> 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" });
}
/// <summary>
/// Demo 7: Public - Long duration hours
/// </summary>
[HttpPost("long-hours")]
[AllowAnonymous]
public async Task<IActionResult> 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}" });
}
/// <summary>
/// Demo 8: Public - Long duration days
/// </summary>
[HttpPost("long-days")]
[AllowAnonymous]
public async Task<IActionResult> LongDays()
{
await _checker.CheckAsync("Demo_LongDays");
return Ok(new { success = true, message = "Daily operation completed" });
}
/// <summary>
/// Demo 9: Authenticated - Custom multi-key resolver (Parameter + UserId combined)
/// </summary>
[HttpPost("custom-multi-key")]
[Authorize]
public async Task<IActionResult> CustomMultiKey([FromBody] CustomMultiKeyInput input)
{
await _checker.CheckAsync("Demo_CustomMultiKey", new OperationRateLimitContext
{
Parameter = input.ResourceId
});
return Ok(new { success = true, message = $"Resource '{input.ResourceId}' processed" });
}
/// <summary>
/// Demo 10: Public - PartitionByParameter with WithMultiTenancy()
/// Same parameter value has independent counters per tenant.
/// </summary>
[HttpPost("demo-tenant-isolated")]
[AllowAnonymous]
public async Task<IActionResult> 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}" });
}
/// <summary>
/// Get status without consuming quota
/// </summary>
[HttpGet("status/{policyName}")]
[AllowAnonymous]
public async Task<IActionResult> 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)
});
}
/// <summary>
/// Reset a policy counter
/// </summary>
[HttpPost("reset/{policyName}")]
[AllowAnonymous]
public async Task<IActionResult> 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" });
}
/// <summary>
/// Reset all demo policies
/// </summary>
[HttpPost("reset-all")]
[AllowAnonymous]
public async Task<IActionResult> 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!;
}

10
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;
}
}

1
templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/MyCompanyName.MyProjectName.Web.csproj

@ -58,6 +58,7 @@
<ProjectReference Include="..\MyCompanyName.MyProjectName.EntityFrameworkCore\MyCompanyName.MyProjectName.EntityFrameworkCore.csproj" />
<ProjectReference Include="..\..\..\..\..\framework\src\Volo.Abp.Autofac\Volo.Abp.Autofac.csproj" />
<ProjectReference Include="..\..\..\..\..\framework\src\Volo.Abp.Swashbuckle\Volo.Abp.Swashbuckle.csproj" />
<ProjectReference Include="..\..\..\..\..\framework\src\Volo.Abp.OperationRateLimit\Volo.Abp.OperationRateLimit.csproj" />
<ProjectReference Include="..\..\..\..\..\framework\src\Volo.Abp.AspNetCore.Serilog\Volo.Abp.AspNetCore.Serilog.csproj" />
<ProjectReference Include="..\..\..\..\..\modules\identity\src\Volo.Abp.Identity.Web\Volo.Abp.Identity.Web.csproj" />
<ProjectReference Include="..\..\..\..\..\modules\account\src\Volo.Abp.Account.Web.OpenIddict\Volo.Abp.Account.Web.OpenIddict.csproj" />

128
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<MyProjectNameWebModule>();
ConfigureOperationRateLimit();
}
private void ConfigureOperationRateLimit()
{
Configure<AbpOperationRateLimitOptions>(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<Volo.Abp.Users.ICurrentUser>().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)

5
templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Web/Pages/Index.cshtml

@ -4,10 +4,7 @@
<div class="card h-lg-100 w-100 overflow-hidden">
<div class="card-body">
<div class="starting-content">
<h3>Getting Started</h3>
<p class="lead text-muted mb-2">Learn how to create and run a new web application using the application startup template.</p>
<a href="https://docs.abp.io/en/commercial/latest/getting-started" class="btn btn-brand mb-4" target="_blank">Getting Started</a>
<a href="Account/Login" class="btn btn-brand mb-4" target="_blank">Login</a>
<h4>Web Application Development Tutorial</h4>
<p class="text-muted mb-2">Learn how to build an ABP based web application named Acme.BookStore.</p>
<a href="https://docs.abp.io/en/commercial/latest/tutorials/book-store/part-1?UI=MVC&DB=EF" class="btn btn-primary soft mb-4" target="_blank">Explore Tutorial</a>

666
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 {
<style>
.demo-card { margin-bottom: 1.5rem; }
.demo-card .card-header { font-weight: 600; }
.result-area {
min-height: 80px;
max-height: 300px;
overflow-y: auto;
font-family: monospace;
font-size: 0.85rem;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
padding: 0.75rem;
}
.result-entry { margin-bottom: 0.5rem; padding-bottom: 0.5rem; border-bottom: 1px solid #eee; }
.result-entry:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
.result-success { color: #198754; }
.result-error { color: #dc3545; }
.result-info { color: #0d6efd; }
.countdown-badge {
font-size: 1.1rem;
padding: 0.5rem 1rem;
}
.status-bar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 0;
}
.quota-progress { flex: 1; }
.badge-auth { background-color: #198754; }
.badge-public { background-color: #0d6efd; }
.rule-desc { font-size: 0.82rem; color: #6c757d; margin-bottom: 0.75rem; }
.rule-desc code { font-size: 0.8rem; }
</style>
}
<div class="container-fluid">
<div class="row mb-4">
<div class="col">
<h2>Operation Rate Limit Demo</h2>
<p class="text-muted">
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.
</p>
<div class="d-flex gap-2 mb-3 align-items-center">
<span class="badge badge-public">Public</span> = no login required
<span class="badge badge-auth ms-2">Auth</span> = login required
<button class="btn btn-danger btn-sm ms-auto" onclick="demoResetAll()">
<i class="fas fa-redo"></i> Reset All Counters
</button>
</div>
</div>
</div>
<div class="row">
<!-- Demo 1: Send SMS Code (PartitionByParameter) -->
<div class="col-lg-6 col-xl-4">
<div class="card demo-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>1. Send SMS Code</span>
<span class="badge badge-public">Public</span>
</div>
<div class="card-body">
<div class="rule-desc">
<strong>Partition:</strong> <code>PartitionByParameter</code><br/>
<strong>Rule:</strong> 3 requests per phone number within <code>20 seconds</code><br/>
<strong>Note:</strong> Each phone number has an independent counter. The parameter is passed explicitly by the caller.
</div>
<div class="mb-3">
<label class="form-label">Phone Number</label>
<input type="text" id="smsPhone" class="form-control" value="123456789" />
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary" onclick="demoSendSms()">Send SMS</button>
<button class="btn btn-outline-secondary btn-sm" onclick="demoReset('Demo_SendSmsCode', document.getElementById('smsPhone').value, 'smsResult')">Reset</button>
</div>
<div class="status-bar mt-2">
<div id="smsCountdown" class="text-danger fw-bold" style="display:none;"></div>
</div>
<div id="smsResult" class="result-area mt-3"></div>
</div>
</div>
</div>
<!-- Demo 2: Login Attempt (PartitionByClientIp) -->
<div class="col-lg-6 col-xl-4">
<div class="card demo-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>2. Login Attempt</span>
<span class="badge badge-public">Public</span>
</div>
<div class="card-body">
<div class="rule-desc">
<strong>Partition:</strong> <code>PartitionByClientIp</code><br/>
<strong>Rule:</strong> 5 requests per IP within <code>20 seconds</code><br/>
<strong>Note:</strong> Client IP is resolved automatically. All requests from the same IP share one counter; no parameter needed.
</div>
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" id="loginUser" class="form-control" value="admin" />
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary" onclick="demoLogin()">Login</button>
<button class="btn btn-outline-secondary btn-sm" onclick="demoReset('Demo_LoginAttempt', null, 'loginResult')">Reset</button>
</div>
<div class="status-bar mt-2">
<div id="loginCountdown" class="text-danger fw-bold" style="display:none;"></div>
</div>
<div id="loginResult" class="result-area mt-3"></div>
</div>
</div>
</div>
<!-- Demo 3: Generate API Key (PartitionByCurrentUser) -->
<div class="col-lg-6 col-xl-4">
<div class="card demo-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>3. Generate API Key</span>
<span class="badge badge-auth">Auth</span>
</div>
<div class="card-body">
<div class="rule-desc">
<strong>Partition:</strong> <code>PartitionByCurrentUser</code><br/>
<strong>Rule:</strong> 3 requests per user within <code>20 seconds</code><br/>
<strong>Note:</strong> Requires login. Current user ID is used as the partition key automatically; each user has an independent counter.
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary" onclick="demoGenerateKey()">Generate Key</button>
<button class="btn btn-outline-secondary btn-sm" onclick="demoReset('Demo_GenerateApiKey', null, 'apiKeyResult')">Reset</button>
</div>
<div class="status-bar mt-2">
<div id="apiKeyCountdown" class="text-danger fw-bold" style="display:none;"></div>
</div>
<div id="apiKeyResult" class="result-area mt-3"></div>
</div>
</div>
</div>
<!-- Demo 4: Send Email Code (PartitionByEmail) -->
<div class="col-lg-6 col-xl-4">
<div class="card demo-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>4. Send Email Code</span>
<span class="badge badge-auth">Auth</span>
</div>
<div class="card-body">
<div class="rule-desc">
<strong>Partition:</strong> <code>PartitionByEmail</code><br/>
<strong>Rule:</strong> 2 requests per email within <code>20 seconds</code><br/>
<strong>Note:</strong> Requires login. Email is resolved from the current user's email claim automatically; no manual parameter needed.
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary" onclick="demoSendEmail()">Send Email Code</button>
<button class="btn btn-outline-secondary btn-sm" onclick="demoReset('Demo_SendEmailCode', null, 'emailResult')">Reset</button>
</div>
<div class="status-bar mt-2">
<div id="emailCountdown" class="text-danger fw-bold" style="display:none;"></div>
</div>
<div id="emailResult" class="result-area mt-3"></div>
</div>
</div>
</div>
<!-- Demo 5a: Composite - ByCurrentUser + ByClientIp -->
<div class="col-lg-6 col-xl-4">
<div class="card demo-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>5a. Composite: User + IP</span>
<span class="badge badge-auth">Auth</span>
</div>
<div class="card-body">
<div class="rule-desc">
<strong>Partition:</strong> <code>ByCurrentUser + ByClientIp</code><br/>
<strong>Rule 1:</strong> 5 requests per user within <code>2 minutes</code><br/>
<strong>Rule 2:</strong> 3 requests per IP within <code>20 seconds</code><br/>
<strong>Note:</strong> IP window (20s) triggers first; user window (2min) does not expire during the wait. Two-phase check prevents wasted quota on blocked requests.<br/>
<strong>How to test:</strong> Click 3 times quickly → IP blocked (3/3); wait 20s then click 2 more → user rule triggers (5/5).
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary" onclick="demoCompositeUserIp()">Create Order</button>
<button class="btn btn-outline-secondary btn-sm" onclick="demoReset('Demo_Composite_UserIp', null, 'compositeUserIpResult')">Reset</button>
</div>
<div class="status-bar mt-2">
<div id="compositeUserIpCountdown" class="text-danger fw-bold" style="display:none;"></div>
</div>
<div id="compositeUserIpResult" class="result-area mt-3"></div>
</div>
</div>
</div>
<!-- Demo 5b: Composite - ByParameter + ByCurrentUser -->
<div class="col-lg-6 col-xl-4">
<div class="card demo-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>5b. Composite: Param + User</span>
<span class="badge badge-auth">Auth</span>
</div>
<div class="card-body">
<div class="rule-desc">
<strong>Partition:</strong> <code>ByParameter + ByCurrentUser</code><br/>
<strong>Rule 1:</strong> 5 requests per key within <code>2 minutes</code><br/>
<strong>Rule 2:</strong> 2 requests per user within <code>20 seconds</code><br/>
<strong>Note:</strong> User limit (2/20s) triggers first; parameter rule (5/2min) only counts successful requests. Parameter counter persists after user window resets.<br/>
<strong>How to test:</strong> 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).
</div>
<div class="mb-3">
<label class="form-label">Key</label>
<input type="text" id="compositeParamKey" class="form-control" value="order-001" />
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary" onclick="demoCompositeParamUser()">Execute</button>
<button class="btn btn-outline-secondary btn-sm" onclick="demoReset('Demo_Composite_ParamUser', document.getElementById('compositeParamKey').value, 'compositeParamUserResult')">Reset</button>
</div>
<div class="status-bar mt-2">
<div id="compositeParamUserCountdown" class="text-danger fw-bold" style="display:none;"></div>
</div>
<div id="compositeParamUserResult" class="result-area mt-3"></div>
</div>
</div>
</div>
<!-- Demo 5c: Composite - Triple -->
<div class="col-lg-6 col-xl-4">
<div class="card demo-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>5c. Composite: Triple</span>
<span class="badge badge-auth">Auth</span>
</div>
<div class="card-body">
<div class="rule-desc">
<strong>Partition:</strong> <code>ByParameter + ByCurrentUser + ByClientIp</code><br/>
<strong>Rule 1:</strong> 5 requests per key within <code>2 minutes</code><br/>
<strong>Rule 2:</strong> 4 requests per user within <code>2 minutes</code><br/>
<strong>Rule 3:</strong> 3 requests per IP within <code>20 seconds</code><br/>
<strong>Note:</strong> All three rules apply (AND logic). IP (3/20s) triggers first; after 20s reset, user (4/2min) becomes the bottleneck.
</div>
<div class="mb-3">
<label class="form-label">Key</label>
<input type="text" id="compositeTripleKey" class="form-control" value="triple-001" />
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary" onclick="demoCompositeTriple()">Execute</button>
<button class="btn btn-outline-secondary btn-sm" onclick="demoReset('Demo_Composite_Triple', document.getElementById('compositeTripleKey').value, 'compositeTripleResult')">Reset</button>
</div>
<div class="status-bar mt-2">
<div id="compositeTripleCountdown" class="text-danger fw-bold" style="display:none;"></div>
</div>
<div id="compositeTripleResult" class="result-area mt-3"></div>
</div>
</div>
</div>
<!-- Demo 6: Submit Feedback (Custom ErrorCode) -->
<div class="col-lg-6 col-xl-4">
<div class="card demo-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>6. Submit Feedback (Custom Error Code)</span>
<span class="badge badge-public">Public</span>
</div>
<div class="card-body">
<div class="rule-desc">
<strong>Partition:</strong> <code>PartitionByParameter</code><br/>
<strong>Rule:</strong> 2 requests per email within <code>20 seconds</code><br/>
<strong>Custom error code:</strong> <code>App:Feedback:RateLimited</code><br/>
<strong>Note:</strong> Default error code is <code>Volo.Abp.OperationRateLimit:010001</code>. This policy uses <code>WithErrorCode()</code> to set a custom code so the frontend can handle it differently.
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="text" id="feedbackEmail" class="form-control" value="test@example.com" />
</div>
<div class="mb-3">
<label class="form-label">Category</label>
<select id="feedbackCategory" class="form-select">
<option value="Bug">Bug Report</option>
<option value="Feature">Feature Request</option>
<option value="Other">Other</option>
</select>
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary" onclick="demoFeedback()">Submit Feedback</button>
<button class="btn btn-outline-secondary btn-sm" onclick="demoReset('Demo_SubmitFeedback', document.getElementById('feedbackEmail').value, 'feedbackResult')">Reset</button>
</div>
<div class="status-bar mt-2">
<div id="feedbackCountdown" class="text-danger fw-bold" style="display:none;"></div>
</div>
<div id="feedbackResult" class="result-area mt-3"></div>
</div>
</div>
</div>
<!-- Demo 7: Long Hours (test hours formatting) -->
<div class="col-lg-6 col-xl-4">
<div class="card demo-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>7. Long Window - Hours</span>
<span class="badge badge-public">Public</span>
</div>
<div class="card-body">
<div class="rule-desc">
<strong>Partition:</strong> <code>PartitionByParameter</code><br/>
<strong>Rule:</strong> 2 requests per key within <code>3 hours</code><br/>
<strong>Note:</strong> Tests <code>RetryAfter</code> formatting for long windows. When triggered, the message should display something like "X hours Y minutes".
</div>
<div class="mb-3">
<label class="form-label">Key</label>
<input type="text" id="longHoursKey" class="form-control" value="test-key-1" />
</div>
<div class="d-flex gap-2">
<button class="btn btn-warning" onclick="demoLongHours()">Execute</button>
<button class="btn btn-outline-secondary btn-sm" onclick="demoReset('Demo_LongHours', document.getElementById('longHoursKey').value, 'longHoursResult')">Reset</button>
</div>
<div class="status-bar mt-2">
<div id="longHoursCountdown" class="text-danger fw-bold" style="display:none;"></div>
</div>
<div id="longHoursResult" class="result-area mt-3"></div>
</div>
</div>
</div>
<!-- Demo 8: Long Days (test days formatting) -->
<div class="col-lg-6 col-xl-4">
<div class="card demo-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>8. Long Window - Days</span>
<span class="badge badge-public">Public</span>
</div>
<div class="card-body">
<div class="rule-desc">
<strong>Partition:</strong> <code>PartitionByClientIp</code><br/>
<strong>Rule:</strong> 1 request per IP within <code>3 days</code><br/>
<strong>Note:</strong> Tests <code>RetryAfter</code> 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.
</div>
<div class="d-flex gap-2">
<button class="btn btn-warning" onclick="demoLongDays()">Execute</button>
<button class="btn btn-outline-secondary btn-sm" onclick="demoReset('Demo_LongDays', null, 'longDaysResult')">Reset</button>
</div>
<div class="status-bar mt-2">
<div id="longDaysCountdown" class="text-danger fw-bold" style="display:none;"></div>
</div>
<div id="longDaysResult" class="result-area mt-3"></div>
</div>
</div>
</div>
<!-- Demo 9: Custom Multi-Key Resolver (PartitionBy custom resolver) -->
<div class="col-lg-6 col-xl-4">
<div class="card demo-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>9. Custom Multi-Key Resolver</span>
<span class="badge badge-auth">Auth</span>
</div>
<div class="card-body">
<div class="rule-desc">
<strong>Partition:</strong> <code>PartitionBy(ctx => $"{resourceId}:{userId}")</code><br/>
<strong>Rule:</strong> 2 requests per user per resource within <code>20 seconds</code><br/>
<strong>Note:</strong> 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.<br/>
<strong>How to test:</strong> Click 2 times with the same Resource ID → limit triggered; change the Resource ID → quota resets.
</div>
<div class="mb-3">
<label class="form-label">Resource ID</label>
<input type="text" id="customMultiKeyResourceId" class="form-control" value="report-001" />
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary" onclick="demoCustomMultiKey()">Process Resource</button>
<button class="btn btn-outline-secondary btn-sm" onclick="demoReset('Demo_CustomMultiKey', document.getElementById('customMultiKeyResourceId').value, 'customMultiKeyResult')">Reset</button>
</div>
<div class="status-bar mt-2">
<div id="customMultiKeyCountdown" class="text-danger fw-bold" style="display:none;"></div>
</div>
<div id="customMultiKeyResult" class="result-area mt-3"></div>
</div>
</div>
</div>
<!-- Demo 10: Tenant Isolated (WithMultiTenancy + PartitionByParameter) -->
<div class="col-lg-6 col-xl-4">
<div class="card demo-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>10. Multi-Tenancy Isolation</span>
<span class="badge badge-public">Public</span>
</div>
<div class="card-body">
<div class="rule-desc">
<strong>Partition:</strong> <code>PartitionByParameter</code> + <code>.WithMultiTenancy()</code><br/>
<strong>Rule:</strong> 3 requests per parameter per tenant within <code>20 seconds</code><br/>
<strong>Note:</strong> The same parameter value has independent counters in different tenants. Without <code>.WithMultiTenancy()</code>, all tenants share the same counter.
</div>
<div class="mb-3">
<label class="form-label">Key</label>
<input type="text" id="tenantIsolatedKey" class="form-control" value="shared-key" />
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary" onclick="demoTenantIsolated()">Execute</button>
<button class="btn btn-outline-secondary btn-sm" onclick="demoReset('Demo_TenantIsolated', document.getElementById('tenantIsolatedKey').value, 'tenantIsolatedResult')">Reset</button>
</div>
<div class="status-bar mt-2">
<div id="tenantIsolatedCountdown" class="text-danger fw-bold" style="display:none;"></div>
</div>
<div id="tenantIsolatedResult" class="result-area mt-3"></div>
</div>
</div>
</div>
</div>
<!-- Raw Error Response Viewer -->
<div class="row mt-3">
<div class="col">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Last Error Response (Raw JSON)</span>
<button class="btn btn-outline-secondary btn-sm" onclick="document.getElementById('rawError').textContent = ''">Clear</button>
</div>
<div class="card-body">
<pre id="rawError" class="mb-0" style="max-height:400px;overflow:auto;white-space:pre-wrap;"></pre>
</div>
</div>
</div>
</div>
</div>
@section scripts {
<script>
var countdownTimers = {};
function now() {
return new Date().toLocaleTimeString();
}
function appendResult(elementId, html) {
var el = document.getElementById(elementId);
el.innerHTML = '<div class="result-entry">' + html + '</div>' + el.innerHTML;
}
function startCountdown(elementId, seconds) {
var el = document.getElementById(elementId);
el.style.display = 'block';
if (countdownTimers[elementId]) {
clearInterval(countdownTimers[elementId]);
}
var remaining = seconds;
el.textContent = 'Retry in: ' + remaining + 's';
countdownTimers[elementId] = setInterval(function () {
remaining--;
if (remaining <= 0) {
clearInterval(countdownTimers[elementId]);
el.style.display = 'none';
} else {
el.textContent = 'Retry in: ' + remaining + 's';
}
}, 1000);
}
function handleError(jqXHR, resultId, countdownId) {
if (jqXHR.status === 429) {
var responseData = jqXHR.responseJSON;
var data = responseData.error.data;
var msg = responseData.error.message;
var code = responseData.error.code;
appendResult(resultId,
'<span class="result-error">[' + now() + '] Blocked (HTTP 429)</span><br/>' +
'<strong>Message:</strong> ' + msg + '<br/>' +
'<strong>Error Code:</strong> <code>' + code + '</code><br/>' +
'<strong>Retry After:</strong> ' + (data.RetryAfter || 'N/A') +
' (' + data.RetryAfterSeconds + 's)<br/>' +
'<strong>Window:</strong> ' + (data.WindowDescription || 'N/A') +
' (' + data.WindowDurationSeconds + 's)<br/>' +
'<strong>Count:</strong> ' + data.CurrentCount + '/' + data.MaxCount +
' (remaining: ' + data.RemainingCount + ')'
);
if (countdownId && data.RetryAfterSeconds > 0) {
startCountdown(countdownId, data.RetryAfterSeconds);
}
document.getElementById('rawError').textContent =
JSON.stringify(responseData, null, 2);
} else {
var errMsg = 'Unknown error';
if (jqXHR.responseJSON && jqXHR.responseJSON.error) {
errMsg = jqXHR.responseJSON.error.message || errMsg;
}
appendResult(resultId,
'<span class="result-error">[' + now() + '] Error: ' + errMsg + '</span>'
);
}
}
function apiCall(url, method, body, resultId, countdownId) {
var ajaxOptions = {
url: url,
type: method,
contentType: 'application/json',
dataType: 'json'
};
if (body) {
ajaxOptions.data = JSON.stringify(body);
}
return $.ajax(ajaxOptions)
.done(function (response) {
appendResult(resultId,
'<span class="result-success">[' + now() + '] OK: ' +
(response.message || JSON.stringify(response)) + '</span>'
);
})
.fail(function (jqXHR) {
handleError(jqXHR, resultId, countdownId);
});
}
// Demo 1
function demoSendSms() {
var phone = document.getElementById('smsPhone').value;
apiCall('/api/rate-limit-demo/send-sms-code', 'POST',
{ phoneNumber: phone }, 'smsResult', 'smsCountdown');
}
// Demo 2
function demoLogin() {
var username = document.getElementById('loginUser').value;
apiCall('/api/rate-limit-demo/login-attempt', 'POST',
{ username: username }, 'loginResult', 'loginCountdown');
}
// Demo 3
function demoGenerateKey() {
apiCall('/api/rate-limit-demo/generate-api-key', 'POST',
null, 'apiKeyResult', 'apiKeyCountdown');
}
// Demo 4
function demoSendEmail() {
apiCall('/api/rate-limit-demo/send-email-code', 'POST',
null, 'emailResult', 'emailCountdown');
}
// Demo 5a
function demoCompositeUserIp() {
apiCall('/api/rate-limit-demo/composite-user-ip', 'POST',
null, 'compositeUserIpResult', 'compositeUserIpCountdown');
}
// Demo 5b
function demoCompositeParamUser() {
var key = document.getElementById('compositeParamKey').value;
apiCall('/api/rate-limit-demo/composite-param-user', 'POST',
{ key: key }, 'compositeParamUserResult', 'compositeParamUserCountdown');
}
// Demo 5c
function demoCompositeTriple() {
var key = document.getElementById('compositeTripleKey').value;
apiCall('/api/rate-limit-demo/composite-triple', 'POST',
{ key: key }, 'compositeTripleResult', 'compositeTripleCountdown');
}
// Demo 6
function demoFeedback() {
var email = document.getElementById('feedbackEmail').value;
var category = document.getElementById('feedbackCategory').value;
apiCall('/api/rate-limit-demo/submit-feedback', 'POST',
{ email: email, category: category }, 'feedbackResult', 'feedbackCountdown');
}
// Demo 7
function demoLongHours() {
var key = document.getElementById('longHoursKey').value;
apiCall('/api/rate-limit-demo/long-hours', 'POST',
{ key: key }, 'longHoursResult', 'longHoursCountdown');
}
// Demo 8
function demoLongDays() {
apiCall('/api/rate-limit-demo/long-days', 'POST',
null, 'longDaysResult', 'longDaysCountdown');
}
// Demo 9
function demoCustomMultiKey() {
var resourceId = document.getElementById('customMultiKeyResourceId').value;
apiCall('/api/rate-limit-demo/custom-multi-key', 'POST',
{ resourceId: resourceId }, 'customMultiKeyResult', 'customMultiKeyCountdown');
}
// Demo 10
function demoTenantIsolated() {
var key = document.getElementById('tenantIsolatedKey').value;
apiCall('/api/rate-limit-demo/demo-tenant-isolated', 'POST',
{ key: key }, 'tenantIsolatedResult', 'tenantIsolatedCountdown');
}
// Reset single policy
function demoReset(policyName, parameter, resultId) {
var url = '/api/rate-limit-demo/reset/' + policyName;
if (parameter) {
url += '?parameter=' + encodeURIComponent(parameter);
}
$.ajax({ url: url, type: 'POST', dataType: 'json' })
.done(function() {
appendResult(resultId,
'<span class="result-info">[' + now() + '] Counter reset</span>'
);
})
.fail(function() {
appendResult(resultId,
'<span class="result-error">[' + now() + '] Reset failed</span>'
);
});
}
// Reset all policies
function demoResetAll() {
var smsPhone = document.getElementById('smsPhone').value;
var feedbackEmail = document.getElementById('feedbackEmail').value;
var longHoursKey = document.getElementById('longHoursKey').value;
var compositeParamKey = document.getElementById('compositeParamKey').value;
var compositeTripleKey = document.getElementById('compositeTripleKey').value;
var customMultiKeyResourceId = document.getElementById('customMultiKeyResourceId').value;
var tenantIsolatedKey = document.getElementById('tenantIsolatedKey').value;
var url = '/api/rate-limit-demo/reset-all?' +
'smsPhone=' + encodeURIComponent(smsPhone) +
'&feedbackEmail=' + encodeURIComponent(feedbackEmail) +
'&longHoursKey=' + encodeURIComponent(longHoursKey) +
'&compositeParamKey=' + encodeURIComponent(compositeParamKey) +
'&compositeTripleKey=' + encodeURIComponent(compositeTripleKey) +
'&customMultiKeyResourceId=' + encodeURIComponent(customMultiKeyResourceId) +
'&tenantIsolatedKey=' + encodeURIComponent(tenantIsolatedKey);
$.ajax({ url: url, type: 'POST', dataType: 'json' })
.done(function() {
// Clear all result areas
var resultAreas = ['smsResult', 'loginResult', 'apiKeyResult', 'emailResult',
'compositeUserIpResult', 'compositeParamUserResult', 'compositeTripleResult',
'feedbackResult', 'longHoursResult', 'longDaysResult', 'customMultiKeyResult',
'tenantIsolatedResult'];
resultAreas.forEach(function(id) {
var el = document.getElementById(id);
if (el) el.innerHTML = '<div class="result-entry"><span class="result-info">[' + now() + '] All counters reset</span></div>';
});
// Clear all countdowns
Object.keys(countdownTimers).forEach(function(key) {
clearInterval(countdownTimers[key]);
var el = document.getElementById(key);
if (el) el.style.display = 'none';
});
countdownTimers = {};
// Clear raw error
document.getElementById('rawError').textContent = '';
})
.fail(function() {
alert('Reset failed');
});
}
</script>
}

8
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()
{
}
}
Loading…
Cancel
Save