Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ CancellationToken cancellationToken

httpContext.Response.StatusCode = statusCode;

if (httpContext.Request.Headers.ContainsKey("X-Inertia"))
if (httpContext.Request.IsInertia())
{
return await WriteInertiaErrorAsync(httpContext, statusCode, title, detail);
}
Expand Down Expand Up @@ -112,8 +112,8 @@ string message
version = InertiaMiddleware.Version,
};

httpContext.Response.Headers["X-Inertia"] = "true";
httpContext.Response.Headers["Vary"] = "X-Inertia";
httpContext.Response.Headers[InertiaHttpExtensions.InertiaHeader] = "true";
httpContext.Response.Headers["Vary"] = InertiaHttpExtensions.InertiaHeader;
httpContext.Response.ContentType = "application/json";
var json = JsonSerializer.Serialize(pageData, InertiaJsonOptions);
await httpContext.Response.WriteAsync(json);
Expand Down
13 changes: 13 additions & 0 deletions framework/SimpleModule.Core/Inertia/InertiaHttpExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Http;

namespace SimpleModule.Core.Inertia;

public static class InertiaHttpExtensions
{
public const string InertiaHeader = "X-Inertia";
public const string InertiaVersionHeader = "X-Inertia-Version";
public const string InertiaLocationHeader = "X-Inertia-Location";

public static bool IsInertia(this HttpRequest request) =>
request.Headers.ContainsKey(InertiaHeader);
}
12 changes: 7 additions & 5 deletions framework/SimpleModule.Core/Inertia/InertiaMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,18 @@ public static IApplicationBuilder UseInertia(this IApplicationBuilder app)
return app.Use(
async (context, next) =>
{
context.Response.Headers["X-Inertia-Version"] = Version;
context.Response.Headers[InertiaHttpExtensions.InertiaVersionHeader] = Version;

if (
context.Request.Headers.ContainsKey("X-Inertia")
context.Request.IsInertia()
&& context.Request.Method == "GET"
&& context.Request.Headers["X-Inertia-Version"].FirstOrDefault() != Version
&& context
.Request.Headers[InertiaHttpExtensions.InertiaVersionHeader]
.FirstOrDefault() != Version
)
{
context.Response.StatusCode = 409;
context.Response.Headers["X-Inertia-Location"] =
context.Response.Headers[InertiaHttpExtensions.InertiaLocationHeader] =
context.Request.GetEncodedUrl();
return;
}
Expand All @@ -38,7 +40,7 @@ public static IApplicationBuilder UseInertia(this IApplicationBuilder app)
// Inertia protocol: convert 302 redirects to 303 for
// PUT/PATCH/DELETE so the browser follows with GET
if (
context.Request.Headers.ContainsKey("X-Inertia")
context.Request.IsInertia()
&& context.Response.StatusCode == 302
&& context.Request.Method != "GET"
)
Expand Down
6 changes: 3 additions & 3 deletions framework/SimpleModule.Core/Inertia/InertiaResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ public async Task ExecuteAsync(HttpContext httpContext)
version = InertiaMiddleware.Version,
};

if (httpContext.Request.Headers.ContainsKey("X-Inertia"))
if (httpContext.Request.IsInertia())
{
httpContext.Response.Headers["X-Inertia"] = "true";
httpContext.Response.Headers["Vary"] = "X-Inertia";
httpContext.Response.Headers[InertiaHttpExtensions.InertiaHeader] = "true";
httpContext.Response.Headers["Vary"] = InertiaHttpExtensions.InertiaHeader;
httpContext.Response.ContentType = "application/json";
var json = JsonSerializer.Serialize(pageData, options);
await httpContext.Response.WriteAsync(json);
Expand Down
16 changes: 16 additions & 0 deletions framework/SimpleModule.Core/RateLimiting/IRateLimitRuleSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Http;

namespace SimpleModule.Core.RateLimiting;

/// <summary>
/// Snapshot of database-defined rate-limit rules consulted by the global rate
/// limiter on every request. Modules that own the rule storage register the
/// implementation as a singleton and call <see cref="RefreshAsync"/> whenever
/// rules change so admin edits take effect without a restart.
/// </summary>
public interface IRateLimitRuleSource
{
RateLimitPolicyDefinition? FindForPath(PathString path);

Task RefreshAsync(CancellationToken cancellationToken = default);
}
171 changes: 99 additions & 72 deletions framework/SimpleModule.Hosting/RateLimiting/RateLimitingSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.DependencyInjection;
using SimpleModule.Core.Inertia;
using SimpleModule.Core.RateLimiting;

namespace SimpleModule.Hosting.RateLimiting;

public static class RateLimitingSetup
{
private const string NoDbRulePartitionKey = "__no_db_rule__";
private const string GlobalPartitionKey = "__global__";
private const string UnknownIpPartitionKey = "unknown";
private const string AnonymousUserPartitionKey = "anonymous";

public static IServiceCollection AddSimpleModuleRateLimiting(
this IServiceCollection services,
IRateLimitPolicyRegistry registry
Expand All @@ -21,26 +27,20 @@ IRateLimitPolicyRegistry registry
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = RateLimitRejectionHandler.HandleAsync;

options.OnRejected = async (context, cancellationToken) =>
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
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
);
};
var source = context.RequestServices.GetService<IRateLimitRuleSource>();
var policy = source?.FindForPath(context.Request.Path);
return policy is null
? RateLimitPartition.GetNoLimiter(NoDbRulePartitionKey)
: CreatePartition(context, policy);
});

foreach (var policy in registry.GetPolicies())
{
RegisterPolicy(options, policy);
options.AddPolicy(policy.Name, context => CreatePartition(context, policy));
}
});

Expand All @@ -54,90 +54,117 @@ public static WebApplication UseSimpleModuleRateLimiting(this WebApplication app
return app;
}

private static void RegisterPolicy(RateLimiterOptions options, RateLimitPolicyDefinition policy)
private static RateLimitPartition<string> CreatePartition(
HttpContext context,
RateLimitPolicyDefinition policy
)
{
switch (policy.PolicyType)
var key = ResolvePartitionKey(context, policy.Target);

return policy.PolicyType switch
{
case RateLimitPolicyType.FixedWindow:
{
var limiterOptions = new FixedWindowRateLimiterOptions
RateLimitPolicyType.FixedWindow => RateLimitPartition.GetFixedWindowLimiter(
key,
_ => 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
}
),
RateLimitPolicyType.SlidingWindow => RateLimitPartition.GetSlidingWindowLimiter(
key,
_ => 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
}
),
RateLimitPolicyType.TokenBucket => RateLimitPartition.GetTokenBucketLimiter(
key,
_ => 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;
}
}
),
_ => RateLimitPartition.GetNoLimiter(key),
};
}

private static string ResolvePartitionKey(HttpContext context, RateLimitTarget target)
{
return target switch
{
RateLimitTarget.Ip => context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
RateLimitTarget.Ip => context.Connection.RemoteIpAddress?.ToString()
?? UnknownIpPartitionKey,
RateLimitTarget.User => context.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? "anonymous",
?? AnonymousUserPartitionKey,
RateLimitTarget.IpAndUser =>
$"{context.Connection.RemoteIpAddress}:{context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous"}",
RateLimitTarget.Global => "__global__",
_ => context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
$"{context.Connection.RemoteIpAddress}:{context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? AnonymousUserPartitionKey}",
RateLimitTarget.Global => GlobalPartitionKey,
_ => context.Connection.RemoteIpAddress?.ToString() ?? UnknownIpPartitionKey,
};
}

internal static class RateLimitRejectionHandler
{
private const string JsonProblemBody =
"""{"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."}""";

private const string HtmlBody = """
<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8"><title>429 Too Many Requests</title>
<style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;color:#1f2937}h1{margin-bottom:.5rem}</style>
</head><body>
<h1>Too many requests</h1>
<p>You have hit the rate limit for this endpoint. Please wait and try again.</p>
</body></html>
""";

public static async ValueTask HandleAsync(
OnRejectedContext context,
CancellationToken cancellationToken
)
{
var response = context.HttpContext.Response;
response.Headers["Retry-After"] = context.Lease.TryGetMetadata(
MetadataName.RetryAfter,
out var retryAfter
)
? ((int)retryAfter.TotalSeconds).ToString(CultureInfo.InvariantCulture)
: "60";

if (PrefersHtml(context.HttpContext.Request))
{
response.ContentType = "text/html; charset=utf-8";
await response.WriteAsync(HtmlBody, cancellationToken);
}
else
{
response.ContentType = "application/problem+json";
await response.WriteAsync(JsonProblemBody, cancellationToken);
}
}

private static bool PrefersHtml(HttpRequest request)
{
// Inertia AJAX requests expect JSON even though they originate
// from a browser, so this check must come before the Accept sniff.
if (request.IsInertia())
{
return false;
}

var accept = request.Headers.Accept.ToString();
return accept.Contains("text/html", StringComparison.OrdinalIgnoreCase);
}
}
}
12 changes: 6 additions & 6 deletions modules/Admin/src/SimpleModule.Admin/Locales/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,10 @@ export const AdminKeys = {
ConfirmDisable2faTitle: 'UsersEdit.ConfirmDisable2faTitle',
ConfirmReverifyAction: 'UsersEdit.ConfirmReverifyAction',
ConfirmReverifyDescription: 'UsersEdit.ConfirmReverifyDescription',
ConfirmReverifyTitle: 'UsersEdit.ConfirmReverifyTitle',
ConfirmReverifyPhoneAction: 'UsersEdit.ConfirmReverifyPhoneAction',
ConfirmReverifyPhoneDescription: 'UsersEdit.ConfirmReverifyPhoneDescription',
ConfirmReverifyPhoneTitle: 'UsersEdit.ConfirmReverifyPhoneTitle',
ConfirmReverifyTitle: 'UsersEdit.ConfirmReverifyTitle',
ConfirmRevokeAllAction: 'UsersEdit.ConfirmRevokeAllAction',
ConfirmRevokeAllDescription: 'UsersEdit.ConfirmRevokeAllDescription',
ConfirmRevokeAllTitle: 'UsersEdit.ConfirmRevokeAllTitle',
Expand All @@ -129,11 +129,6 @@ export const AdminKeys = {
EmailVerificationStatus: 'UsersEdit.EmailVerificationStatus',
EmailVerificationTitle: 'UsersEdit.EmailVerificationTitle',
EmailVerified: 'UsersEdit.EmailVerified',
PhoneVerificationTitle: 'UsersEdit.PhoneVerificationTitle',
PhoneVerificationStatus: 'UsersEdit.PhoneVerificationStatus',
PhoneVerified: 'UsersEdit.PhoneVerified',
PhoneNotVerified: 'UsersEdit.PhoneNotVerified',
PhoneNotSet: 'UsersEdit.PhoneNotSet',
ErrorPasswordMismatch: 'UsersEdit.ErrorPasswordMismatch',
FailedLoginAttempts: 'UsersEdit.FailedLoginAttempts',
FieldConfirmPassword: 'UsersEdit.FieldConfirmPassword',
Expand All @@ -149,6 +144,11 @@ export const AdminKeys = {
LoginInfoTitle: 'UsersEdit.LoginInfoTitle',
NoActiveSessions: 'UsersEdit.NoActiveSessions',
NoRolesDefined: 'UsersEdit.NoRolesDefined',
PhoneNotSet: 'UsersEdit.PhoneNotSet',
PhoneNotVerified: 'UsersEdit.PhoneNotVerified',
PhoneVerificationStatus: 'UsersEdit.PhoneVerificationStatus',
PhoneVerificationTitle: 'UsersEdit.PhoneVerificationTitle',
PhoneVerified: 'UsersEdit.PhoneVerified',
ReactivateButton: 'UsersEdit.ReactivateButton',
ResetPasswordButton: 'UsersEdit.ResetPasswordButton',
ResetPasswordTitle: 'UsersEdit.ResetPasswordTitle',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using SimpleModule.Core;
using SimpleModule.Core.RateLimiting;
using SimpleModule.OpenIddict.Contracts;
using SimpleModule.Permissions.Contracts;
using SimpleModule.Users.Contracts;
Expand All @@ -24,7 +25,8 @@ public void Map(IEndpointRouteBuilder app)
{
app.MapPost(ConnectRouteConstants.ConnectToken, (Delegate)HandleAsync)
.ExcludeFromDescription()
.AllowAnonymous();
.AllowAnonymous()
.RateLimit("auth-strict");
}

private static async Task<IResult> HandleAsync(HttpContext context)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,11 @@ public CreateRequestValidator()
RuleFor(x => x.PermitLimit)
.GreaterThan(0)
.WithMessage("Permit limit must be greater than zero.");
RuleFor(x => x.WindowSeconds).GreaterThan(0);
RuleFor(x => x.SegmentsPerWindow).GreaterThan(0);
RuleFor(x => x.ReplenishmentPeriodSeconds).GreaterThan(0);
RuleFor(x => x.TokenLimit).GreaterThan(0);
RuleFor(x => x.TokensPerPeriod).GreaterThan(0);
RuleFor(x => x.QueueLimit).GreaterThanOrEqualTo(0);
}
}
Loading
Loading