Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions SimpleModule.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@
<Project Path="modules/Localization/src/SimpleModule.Localization/SimpleModule.Localization.csproj" />
<Project Path="modules/Localization/tests/SimpleModule.Localization.Tests/SimpleModule.Localization.Tests.csproj" />
</Folder>
<Folder Name="/modules/RateLimiting/">
<Project Path="modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/SimpleModule.RateLimiting.Contracts.csproj" />
<Project Path="modules/RateLimiting/src/SimpleModule.RateLimiting/SimpleModule.RateLimiting.csproj" />
<Project Path="modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/SimpleModule.RateLimiting.Tests.csproj" />
</Folder>
<Folder Name="/modules/Email/">
<Project Path="modules/Email/src/SimpleModule.Email.Contracts/SimpleModule.Email.Contracts.csproj" />
<Project Path="modules/Email/src/SimpleModule.Email/SimpleModule.Email.csproj" />
Expand Down
2 changes: 2 additions & 0 deletions framework/SimpleModule.Core/IModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using SimpleModule.Core.Authorization;
using SimpleModule.Core.FeatureFlags;
using SimpleModule.Core.Menu;
using SimpleModule.Core.RateLimiting;
using SimpleModule.Core.Settings;

namespace SimpleModule.Core;
Expand All @@ -21,6 +22,7 @@ virtual void ConfigurePermissions(PermissionRegistryBuilder builder) { }
virtual void ConfigureSettings(ISettingsBuilder settings) { }
virtual void ConfigureFeatureFlags(IFeatureFlagBuilder builder) { }
virtual void ConfigureAgents(IAgentBuilder builder) { }
virtual void ConfigureRateLimits(IRateLimitBuilder builder) { }

/// <summary>
/// Called once during application startup after all services are built but before
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Builder;

namespace SimpleModule.Core.RateLimiting;

public static class EndpointRateLimitExtensions
{
public static TBuilder RateLimit<TBuilder>(this TBuilder builder, string policyName)
where TBuilder : IEndpointConventionBuilder
{
builder.RequireRateLimiting(policyName);
return builder;
}
}
6 changes: 6 additions & 0 deletions framework/SimpleModule.Core/RateLimiting/IRateLimitBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace SimpleModule.Core.RateLimiting;

public interface IRateLimitBuilder
{
IRateLimitBuilder Add(RateLimitPolicyDefinition policy);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace SimpleModule.Core.RateLimiting;

public interface IRateLimitPolicyRegistry
{
IReadOnlyList<RateLimitPolicyDefinition> GetPolicies();
RateLimitPolicyDefinition? GetPolicy(string name);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace SimpleModule.Core.RateLimiting;

public sealed class RateLimitPolicyDefinition
{
public required string Name { get; init; }
public RateLimitPolicyType PolicyType { get; init; } = RateLimitPolicyType.FixedWindow;
public RateLimitTarget Target { get; init; } = RateLimitTarget.Ip;
public int PermitLimit { get; init; } = 60;
public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(1);
public int SegmentsPerWindow { get; init; } = 4;
public int TokenLimit { get; init; } = 100;
public int TokensPerPeriod { get; init; } = 10;
public TimeSpan ReplenishmentPeriod { get; init; } = TimeSpan.FromSeconds(10);
public int QueueLimit { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace SimpleModule.Core.RateLimiting;

public sealed class RateLimitPolicyRegistry : IRateLimitBuilder, IRateLimitPolicyRegistry
{
private readonly Dictionary<string, RateLimitPolicyDefinition> _policies = new(
StringComparer.OrdinalIgnoreCase
);

public IRateLimitBuilder Add(RateLimitPolicyDefinition policy)
{
_policies[policy.Name] = policy;
return this;
}

public IReadOnlyList<RateLimitPolicyDefinition> GetPolicies() =>
_policies.Values.ToList().AsReadOnly();

public RateLimitPolicyDefinition? GetPolicy(string name) => _policies.GetValueOrDefault(name);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace SimpleModule.Core.RateLimiting;

public enum RateLimitPolicyType
{
FixedWindow,
SlidingWindow,
TokenBucket,
}
9 changes: 9 additions & 0 deletions framework/SimpleModule.Core/RateLimiting/RateLimitTarget.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace SimpleModule.Core.RateLimiting;

public enum RateLimitTarget
{
Ip,
User,
IpAndUser,
Global,
}
4 changes: 4 additions & 0 deletions framework/SimpleModule.Generator/Discovery/DiscoveryData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ internal readonly record struct ModuleInfoRecord(
bool HasConfigureSettings,
bool HasConfigureFeatureFlags,
bool HasConfigureAgents,
bool HasConfigureRateLimits,
bool HasRazorComponents,
string RoutePrefix,
string ViewPrefix,
Expand All @@ -146,6 +147,7 @@ public bool Equals(ModuleInfoRecord other)
&& HasConfigureSettings == other.HasConfigureSettings
&& HasConfigureFeatureFlags == other.HasConfigureFeatureFlags
&& HasConfigureAgents == other.HasConfigureAgents
&& HasConfigureRateLimits == other.HasConfigureRateLimits
&& HasRazorComponents == other.HasRazorComponents
&& RoutePrefix == other.RoutePrefix
&& ViewPrefix == other.ViewPrefix
Expand All @@ -167,6 +169,7 @@ public override int GetHashCode()
hash = HashHelper.Combine(hash, HasConfigureSettings.GetHashCode());
hash = HashHelper.Combine(hash, HasConfigureFeatureFlags.GetHashCode());
hash = HashHelper.Combine(hash, HasConfigureAgents.GetHashCode());
hash = HashHelper.Combine(hash, HasConfigureRateLimits.GetHashCode());
hash = HashHelper.Combine(hash, HasRazorComponents.GetHashCode());
hash = HashHelper.Combine(hash, (RoutePrefix ?? "").GetHashCode());
hash = HashHelper.Combine(hash, (ViewPrefix ?? "").GetHashCode());
Expand Down Expand Up @@ -462,6 +465,7 @@ internal sealed class ModuleInfo
public bool HasConfigureSettings { get; set; }
public bool HasConfigureFeatureFlags { get; set; }
public bool HasConfigureAgents { get; set; }
public bool HasConfigureRateLimits { get; set; }
public bool HasRazorComponents { get; set; }
public string RoutePrefix { get; set; } = "";
public string ViewPrefix { get; set; } = "";
Expand Down
5 changes: 5 additions & 0 deletions framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,7 @@ is not IAssemblySymbol assemblySymbol
m.HasConfigureSettings,
m.HasConfigureFeatureFlags,
m.HasConfigureAgents,
m.HasConfigureRateLimits,
m.HasRazorComponents,
m.RoutePrefix,
m.ViewPrefix,
Expand Down Expand Up @@ -910,6 +911,10 @@ moduleSettingsSymbol is not null
"ConfigureFeatureFlags"
),
HasConfigureAgents = DeclaresMethod(typeSymbol, "ConfigureAgents"),
HasConfigureRateLimits = DeclaresMethod(
typeSymbol,
"ConfigureRateLimits"
),
RoutePrefix = routePrefix,
ViewPrefix = viewPrefix,
Location = GetSourceLocation(typeSymbol),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,21 @@ public void Emit(SourceProductionContext context, DiscoveryData data)
" services.AddSingleton<global::SimpleModule.Core.FeatureFlags.IFeatureFlagRegistry>(featureFlagBuilder.Build());"
);

// Collect rate limit policy definitions from modules
sb.AppendLine(
" var rateLimitRegistry = new SimpleModule.Core.RateLimiting.RateLimitPolicyRegistry();"
);
foreach (var module in sortedModules.Where(m => m.HasConfigureRateLimits))
{
var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName);
sb.AppendLine(
$" ((global::SimpleModule.Core.IModule){fieldName}).ConfigureRateLimits(rateLimitRegistry);"
);
}
sb.AppendLine(
" SimpleModule.Hosting.RateLimiting.RateLimitingSetup.AddSimpleModuleRateLimiting(services, rateLimitRegistry);"
);

if (hasDtoTypes)
{
sb.AppendLine();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System.Globalization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RateLimiting;
using SimpleModule.Core.RateLimiting;

namespace SimpleModule.Hosting.RateLimiting;

public sealed class RateLimitHeaderMiddleware(
RequestDelegate next,
IRateLimitPolicyRegistry registry
)
{
public async Task InvokeAsync(HttpContext context)
{
var endpoint = context.GetEndpoint();
var rateLimitMetadata = endpoint?.Metadata.GetMetadata<EnableRateLimitingAttribute>();

if (rateLimitMetadata is { PolicyName: { } policyName })
{
var policy = registry.GetPolicy(policyName);
if (policy is not null)
{
context.Response.OnStarting(() =>
{
if (context.Response.StatusCode != StatusCodes.Status429TooManyRequests)
{
context.Response.Headers["X-RateLimit-Policy"] = policy.Name;
context.Response.Headers["X-RateLimit-Limit"] =
policy.PolicyType == RateLimitPolicyType.TokenBucket
? policy.TokenLimit.ToString(CultureInfo.InvariantCulture)
: policy.PermitLimit.ToString(CultureInfo.InvariantCulture);
}

return Task.CompletedTask;
});
}
}

await next(context);
}
}
143 changes: 143 additions & 0 deletions framework/SimpleModule.Hosting/RateLimiting/RateLimitingSetup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
using System.Globalization;
using System.Security.Claims;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.DependencyInjection;
using SimpleModule.Core.RateLimiting;

namespace SimpleModule.Hosting.RateLimiting;

public static class RateLimitingSetup
{
public static IServiceCollection AddSimpleModuleRateLimiting(
this IServiceCollection services,
IRateLimitPolicyRegistry registry
)
{
services.AddSingleton(registry);

services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.Headers["Retry-After"] = context.Lease.TryGetMetadata(
MetadataName.RetryAfter,
out var retryAfter
)
? ((int)retryAfter.TotalSeconds).ToString(CultureInfo.InvariantCulture)
: "60";

context.HttpContext.Response.ContentType = "application/problem+json";
await context.HttpContext.Response.WriteAsync(
"""{"type":"https://httpstatuses.io/429","title":"Too Many Requests","status":429,"detail":"Rate limit exceeded. Please retry after the period indicated in the Retry-After header."}""",
cancellationToken
);
};

foreach (var policy in registry.GetPolicies())
{
RegisterPolicy(options, policy);
}
});

return services;
}

public static WebApplication UseSimpleModuleRateLimiting(this WebApplication app)
{
app.UseMiddleware<RateLimitHeaderMiddleware>();
app.UseRateLimiter();
return app;
}

private static void RegisterPolicy(RateLimiterOptions options, RateLimitPolicyDefinition policy)
{
switch (policy.PolicyType)
{
case RateLimitPolicyType.FixedWindow:
{
var limiterOptions = new FixedWindowRateLimiterOptions
{
PermitLimit = policy.PermitLimit,
Window = policy.Window,
QueueLimit = policy.QueueLimit,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
};
options.AddPolicy(
policy.Name,
context =>
RateLimitPartition.GetFixedWindowLimiter(
ResolvePartitionKey(context, policy.Target),
_ => limiterOptions
)
);
break;
}
case RateLimitPolicyType.SlidingWindow:
{
var limiterOptions = new SlidingWindowRateLimiterOptions
{
PermitLimit = policy.PermitLimit,
Window = policy.Window,
SegmentsPerWindow = policy.SegmentsPerWindow,
QueueLimit = policy.QueueLimit,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
};
options.AddPolicy(
policy.Name,
context =>
RateLimitPartition.GetSlidingWindowLimiter(
ResolvePartitionKey(context, policy.Target),
_ => limiterOptions
)
);
break;
}
case RateLimitPolicyType.TokenBucket:
{
var limiterOptions = new TokenBucketRateLimiterOptions
{
TokenLimit = policy.TokenLimit,
TokensPerPeriod = policy.TokensPerPeriod,
ReplenishmentPeriod = policy.ReplenishmentPeriod,
QueueLimit = policy.QueueLimit,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
};
options.AddPolicy(
policy.Name,
context =>
RateLimitPartition.GetTokenBucketLimiter(
ResolvePartitionKey(context, policy.Target),
_ => limiterOptions
)
);
break;
}
default:
options.AddPolicy(
policy.Name,
context =>
RateLimitPartition.GetNoLimiter(ResolvePartitionKey(context, policy.Target))
);
break;
}
}

private static string ResolvePartitionKey(HttpContext context, RateLimitTarget target)
{
return target switch
{
RateLimitTarget.Ip => context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
RateLimitTarget.User => context.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? "anonymous",
RateLimitTarget.IpAndUser =>
$"{context.Connection.RemoteIpAddress}:{context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous"}",
RateLimitTarget.Global => "__global__",
_ => context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
};
}
}
3 changes: 3 additions & 0 deletions framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
using SimpleModule.Core.Exceptions;
using SimpleModule.Core.Inertia;
using SimpleModule.Core.Menu;
using SimpleModule.Core.RateLimiting;
using SimpleModule.Database;
using SimpleModule.Database.Health;
using SimpleModule.Database.Interceptors;
using SimpleModule.DevTools;
using SimpleModule.Hosting.RateLimiting;

namespace SimpleModule.Hosting;

Expand Down Expand Up @@ -157,6 +159,7 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app)

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

// Module middleware is added by the source-generated UseSimpleModule()
// via IModule.ConfigureMiddleware() calls.
Expand Down
Loading
Loading