feat: add rate limiting and startup options validation

This commit is contained in:
xiong
2026-07-26 13:46:17 +08:00
parent f4783c5de3
commit 015c9b5582
9 changed files with 204 additions and 2 deletions

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Scalar.AspNetCore;
@@ -6,6 +8,7 @@ using Serilog;
using Serilog.Events;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using Tiku.Api.Logging;
using Tiku.Api.Middleware;
using Tiku.Api.OpenApi;
@@ -44,7 +47,11 @@ try
});
builder.Services.AddProblemDetails();
builder.Services.AddApplication();
builder.Services.Configure<CorsOptions>(builder.Configuration.GetSection(CorsOptions.SectionName));
builder.Services.AddOptions<CorsOptions>()
.Bind(builder.Configuration.GetSection(CorsOptions.SectionName))
.ValidateDataAnnotations()
.Validate(OptionsValidation.BeValidCorsOptions, "CORS origins must be absolute HTTP/HTTPS origins, and credentials require explicit origins.")
.ValidateOnStart();
var corsOptions = builder.Configuration
.GetSection(CorsOptions.SectionName)
.Get<CorsOptions>() ?? new CorsOptions();
@@ -73,6 +80,57 @@ try
}
});
});
builder.Services.AddOptions<ApiRateLimitOptions>()
.Bind(builder.Configuration.GetSection(ApiRateLimitOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
var rateLimitOptions = builder.Configuration
.GetSection(ApiRateLimitOptions.SectionName)
.Get<ApiRateLimitOptions>() ?? new ApiRateLimitOptions();
if (rateLimitOptions.Enabled)
{
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
var partitionKey =
httpContext.User.FindFirst(TikuClaimTypes.UserId)?.Value ??
httpContext.Connection.RemoteIpAddress?.ToString() ??
"anonymous";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey,
_ => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = rateLimitOptions.PermitLimit,
QueueLimit = rateLimitOptions.QueueLimit,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
Window = TimeSpan.FromSeconds(rateLimitOptions.WindowSeconds)
});
});
options.OnRejected = async (context, cancellationToken) =>
{
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
}
var problem = new ProblemDetails
{
Title = "Too many requests.",
Status = StatusCodes.Status429TooManyRequests,
Instance = context.HttpContext.Request.Path
};
problem.Extensions["code"] = "rate_limited";
problem.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsJsonAsync(problem, cancellationToken);
};
});
}
var connectionString =
builder.Configuration.GetConnectionString("Database") ??
@@ -81,7 +139,10 @@ try
builder.Services.AddInfrastructure(connectionString);
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("Security:Jwt"));
builder.Services.AddOptions<JwtOptions>()
.Bind(builder.Configuration.GetSection("Security:Jwt"))
.ValidateDataAnnotations()
.ValidateOnStart();
var jwtOptions = builder.Configuration
.GetSection("Security:Jwt")
.Get<JwtOptions>() ?? new JwtOptions();
@@ -164,8 +225,14 @@ try
app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging);
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseHttpsRedirection();
app.UseRouting();
app.UseCors(CorsOptions.PolicyName);
app.UseAuthentication();
if (rateLimitOptions.Enabled)
{
app.UseRateLimiter();
}
app.UseMiddleware<CurrentPrincipalMiddleware>();
app.UseAuthorization();