Skip to content

Commit c8c5fd1

Browse files
authored
Merge pull request #9 from baoduy/copilot/fix-8
Implement RateLimit feature for SlimBus.Api project
2 parents 3db4593 + 093d257 commit c8c5fd1

File tree

9 files changed

+411
-0
lines changed

9 files changed

+411
-0
lines changed

z_Templates/SlimBus.ApiEndpoints/SlimBus.Api/Configs/AppConfig.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using SlimBus.Api.Configs.Auth;
22
using SlimBus.Api.Configs.GlobalExceptions;
33
using SlimBus.Api.Configs.Idempotency;
4+
using SlimBus.Api.Configs.RateLimits;
45
using SlimBus.Api.Configs.Swagger;
56

67
namespace SlimBus.Api.Configs;
@@ -23,6 +24,9 @@ public static IServiceCollection AddAppConfig(this IServiceCollection services,
2324
if (features.EnableHttps)
2425
services.AddHttpsConfig();
2526

27+
if (features.EnableRateLimit)
28+
services.AddRateLimitConfig();
29+
2630
services.AddHttpContextAccessor()
2731
.AddFeatureManagement();
2832

@@ -41,6 +45,7 @@ public static Task UseAppConfig(this WebApplication app, Action<WebApplication>?
4145
{
4246
app.UseAntiforgeryConfig()
4347
.UseCrosConfig()
48+
.UseRateLimitConfig()
4449
.UseHttpsConfig()
4550
.UseHealthzConfig();
4651

z_Templates/SlimBus.ApiEndpoints/SlimBus.Api/Configs/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,14 @@ The configuration system is organized into focused modules, each handling a dist
118118
- Distributed and memory cache setup.
119119
- Cache profile management.
120120

121+
#### Rate Limiting (`RateLimits/`)
122+
123+
- Client IP and JWT-based rate limiting.
124+
- Configurable request limits and time windows.
125+
- Support for forwarded headers (X-Forwarded-For, X-Real-IP).
126+
- Automatic user identity extraction from JWT tokens.
127+
- Feature flag controlled via `FeatureOptions.EnableRateLimit`.
128+
121129
---
122130

123131
## Implementation Examples
@@ -129,6 +137,25 @@ services.AddAntiforgeryConfig(cookieName: "x-csrf-cookie", headerName: "x-csrf-h
129137
app.UseAntiforgeryConfig();
130138
```
131139

140+
**Rate Limiting**
141+
142+
```csharp
143+
// Enable in FeatureOptions
144+
services.Configure<FeatureOptions>(options => options.EnableRateLimit = true);
145+
146+
// Custom configuration (optional)
147+
services.AddRateLimitConfig(options => {
148+
options.DefaultRequestLimit = 5;
149+
options.TimeWindowInSeconds = 1;
150+
});
151+
152+
// Apply to specific endpoints
153+
app.MapPost("/api/resource", handler).RequireRateLimit();
154+
155+
// Apply to route groups
156+
var apiGroup = app.MapGroup("/api").RequireRateLimit();
157+
```
158+
132159
**Idempotency**
133160

134161
```csharp
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using System.Threading.RateLimiting;
3+
using Microsoft.AspNetCore.RateLimiting;
4+
using Microsoft.Extensions.Options;
5+
6+
namespace SlimBus.Api.Configs.RateLimits;
7+
8+
/// <summary>
9+
/// Rate limiting configuration for SlimBus API
10+
/// </summary>
11+
[ExcludeFromCodeCoverage]
12+
internal static class RateLimitConfig
13+
{
14+
private static bool _configAdded;
15+
private const string DefaultPolicyName = "DefaultRateLimit";
16+
17+
/// <summary>
18+
/// Adds rate limiting services to the service collection
19+
/// </summary>
20+
/// <param name="services">The service collection to configure</param>
21+
/// <param name="configAction">Optional configuration action for rate limit options</param>
22+
/// <returns>The service collection with rate limiting configured</returns>
23+
public static IServiceCollection AddRateLimitConfig(this IServiceCollection services, Action<RateLimitOptions>? configAction = null)
24+
{
25+
var options = new RateLimitOptions();
26+
configAction?.Invoke(options);
27+
28+
services.AddSingleton(Options.Create(options));
29+
services.AddSingleton<RateLimitPolicyProvider>();
30+
31+
services.AddRateLimiter(rateLimiterOptions =>
32+
{
33+
rateLimiterOptions.AddPolicy(DefaultPolicyName, httpContext =>
34+
{
35+
var policyProvider = httpContext.RequestServices.GetRequiredService<RateLimitPolicyProvider>();
36+
return RateLimitPartition.GetFixedWindowLimiter(
37+
partitionKey: policyProvider.GetPartitionKey(httpContext),
38+
factory: _ =>
39+
{
40+
var rateLimitOptions = httpContext.RequestServices.GetRequiredService<IOptions<RateLimitOptions>>().Value;
41+
return new FixedWindowRateLimiterOptions
42+
{
43+
AutoReplenishment = true,
44+
PermitLimit = rateLimitOptions.DefaultRequestLimit,
45+
Window = TimeSpan.FromSeconds(rateLimitOptions.TimeWindowInSeconds),
46+
QueueLimit = rateLimitOptions.QueueLimit,
47+
QueueProcessingOrder = (QueueProcessingOrder)rateLimitOptions.QueueProcessingOrder
48+
};
49+
});
50+
});
51+
52+
// Global settings
53+
rateLimiterOptions.GlobalLimiter = null; // We use partitioned limiter instead
54+
rateLimiterOptions.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
55+
});
56+
57+
_configAdded = true;
58+
Console.WriteLine("Rate Limiting enabled.");
59+
60+
return services;
61+
}
62+
63+
/// <summary>
64+
/// Applies rate limiting middleware to the application
65+
/// </summary>
66+
/// <param name="app">The web application to configure</param>
67+
/// <returns>The web application with rate limiting applied</returns>
68+
public static WebApplication UseRateLimitConfig(this WebApplication app)
69+
{
70+
if (!_configAdded) return app;
71+
72+
app.UseRateLimiter();
73+
74+
Console.WriteLine("Rate Limiting middleware enabled.");
75+
return app;
76+
}
77+
78+
/// <summary>
79+
/// Applies rate limiting to a route handler
80+
/// </summary>
81+
/// <param name="builder">The route handler builder</param>
82+
/// <returns>The route handler builder with rate limiting applied</returns>
83+
public static RouteHandlerBuilder RequireRateLimit(this RouteHandlerBuilder builder)
84+
{
85+
if (_configAdded)
86+
{
87+
builder.RequireRateLimiting(DefaultPolicyName);
88+
}
89+
return builder;
90+
}
91+
92+
/// <summary>
93+
/// Applies rate limiting to a route group
94+
/// </summary>
95+
/// <param name="group">The route group builder</param>
96+
/// <returns>The route group builder with rate limiting applied</returns>
97+
public static RouteGroupBuilder RequireRateLimit(this RouteGroupBuilder group)
98+
{
99+
if (_configAdded)
100+
{
101+
group.RequireRateLimiting(DefaultPolicyName);
102+
}
103+
return group;
104+
}
105+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
namespace SlimBus.Api.Configs.RateLimits;
2+
3+
/// <summary>
4+
/// Configuration options for rate limiting
5+
/// </summary>
6+
public class RateLimitOptions
7+
{
8+
/// <summary>
9+
/// Default number of requests allowed per time window
10+
/// </summary>
11+
public int DefaultRequestLimit { get; set; } = 2;
12+
13+
/// <summary>
14+
/// Time window for rate limiting in seconds
15+
/// </summary>
16+
public int TimeWindowInSeconds { get; set; } = 1;
17+
18+
/// <summary>
19+
/// Maximum number of queued requests when rate limit is reached
20+
/// </summary>
21+
public int QueueLimit { get; set; } = 0;
22+
23+
/// <summary>
24+
/// Queue processing order
25+
/// </summary>
26+
public RateLimitQueueProcessingOrder QueueProcessingOrder { get; set; } = RateLimitQueueProcessingOrder.OldestFirst;
27+
}
28+
29+
/// <summary>
30+
/// Defines the order in which queued requests are processed
31+
/// </summary>
32+
public enum RateLimitQueueProcessingOrder
33+
{
34+
OldestFirst,
35+
NewestFirst
36+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
using System.IdentityModel.Tokens.Jwt;
2+
using System.Net;
3+
using System.Security.Claims;
4+
using System.Threading.RateLimiting;
5+
using Microsoft.AspNetCore.RateLimiting;
6+
using Microsoft.Extensions.Options;
7+
8+
namespace SlimBus.Api.Configs.RateLimits;
9+
10+
/// <summary>
11+
/// Provides rate limiting policies based on client IP or JWT user identity
12+
/// </summary>
13+
internal class RateLimitPolicyProvider
14+
{
15+
private readonly RateLimitOptions _options;
16+
private readonly ILogger<RateLimitPolicyProvider> _logger;
17+
18+
public RateLimitPolicyProvider(IOptions<RateLimitOptions> options, ILogger<RateLimitPolicyProvider> logger)
19+
{
20+
_options = options.Value;
21+
_logger = logger;
22+
}
23+
24+
/// <summary>
25+
/// Gets the partition key for rate limiting based on authorization header or IP address
26+
/// </summary>
27+
public string GetPartitionKey(HttpContext context)
28+
{
29+
// Try to get user identity from JWT token first
30+
var userIdentity = GetUserIdentityFromJwt(context);
31+
if (!string.IsNullOrEmpty(userIdentity))
32+
{
33+
_logger.LogDebug("Using JWT user identity for rate limiting: {UserIdentity}", userIdentity);
34+
return $"user:{userIdentity}";
35+
}
36+
37+
// Fall back to client IP address
38+
var clientIp = GetClientIpAddress(context);
39+
_logger.LogDebug("Using client IP for rate limiting: {ClientIp}", clientIp);
40+
return $"ip:{clientIp}";
41+
}
42+
43+
/// <summary>
44+
/// Extracts user identity from JWT token in authorization header
45+
/// </summary>
46+
private string? GetUserIdentityFromJwt(HttpContext context)
47+
{
48+
try
49+
{
50+
var authHeader = context.Request.Headers.Authorization.FirstOrDefault();
51+
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
52+
{
53+
return null;
54+
}
55+
56+
var token = authHeader["Bearer ".Length..].Trim();
57+
if (string.IsNullOrEmpty(token))
58+
{
59+
return null;
60+
}
61+
62+
var jwtHandler = new JwtSecurityTokenHandler();
63+
if (!jwtHandler.CanReadToken(token))
64+
{
65+
return null;
66+
}
67+
68+
var jwtToken = jwtHandler.ReadJwtToken(token);
69+
70+
// Try to get user identity in order of preference: name, email, sub (ID)
71+
var userIdentity = jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value
72+
?? jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value
73+
?? jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value
74+
?? jwtToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value
75+
?? jwtToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value
76+
?? jwtToken.Claims.FirstOrDefault(c => c.Type == "email")?.Value;
77+
78+
return userIdentity;
79+
}
80+
catch (Exception ex)
81+
{
82+
_logger.LogWarning(ex, "Failed to extract user identity from JWT token");
83+
return null;
84+
}
85+
}
86+
87+
/// <summary>
88+
/// Gets the client IP address from the HTTP context
89+
/// </summary>
90+
private string GetClientIpAddress(HttpContext context)
91+
{
92+
// Check for forwarded headers first (in case of proxy/load balancer)
93+
var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
94+
if (!string.IsNullOrEmpty(forwardedFor))
95+
{
96+
var firstIp = forwardedFor.Split(',')[0].Trim();
97+
if (IPAddress.TryParse(firstIp, out _))
98+
{
99+
return firstIp;
100+
}
101+
}
102+
103+
var realIp = context.Request.Headers["X-Real-IP"].FirstOrDefault();
104+
if (!string.IsNullOrEmpty(realIp) && IPAddress.TryParse(realIp, out _))
105+
{
106+
return realIp;
107+
}
108+
109+
// Fall back to connection remote IP address
110+
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
111+
}
112+
}

z_Templates/SlimBus.ApiEndpoints/SlimBus.App.Tests/GlobalUsings.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
global using System.Net.Http.Json;
2+
global using System.Security.Claims;
23
global using Shouldly;
34
global using System.Text.Json;
45
global using FluentResults;

z_Templates/SlimBus.ApiEndpoints/SlimBus.App.Tests/SlimBus.App.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.3.1" />
1515
<PackageReference Include="Aspire.Hosting.Redis" Version="9.3.1" />
1616
<PackageReference Include="Aspire.Hosting.SqlServer" Version="9.3.2" />
17+
<PackageReference Include="Microsoft.AspNetCore.RateLimiting" Version="8.0.11" />
1718
<PackageReference Update="Meziantou.Analyzer" Version="2.0.202">
1819
<PrivateAssets>all</PrivateAssets>
1920
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

0 commit comments

Comments
 (0)