forked from gongxuegit/tiku-backend.net
feat: add rate limiting and startup options validation
This commit is contained in:
17
Tiku.Api/Options/ApiRateLimitOptions.cs
Normal file
17
Tiku.Api/Options/ApiRateLimitOptions.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Tiku.Api.Options;
|
||||
|
||||
public sealed class ApiRateLimitOptions
|
||||
{
|
||||
public const string SectionName = "RateLimiting";
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Range(1, 100_000)]
|
||||
public int PermitLimit { get; set; } = 600;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Range(1, 86_400)]
|
||||
public int WindowSeconds { get; set; } = 60;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Range(0, 10_000)]
|
||||
public int QueueLimit { get; set; }
|
||||
}
|
||||
@@ -5,8 +5,16 @@ public sealed class CorsOptions
|
||||
public const string SectionName = "Cors";
|
||||
public const string PolicyName = "TikuCors";
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required]
|
||||
public string[] AllowedOrigins { get; set; } = [];
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required]
|
||||
[System.ComponentModel.DataAnnotations.MinLength(1)]
|
||||
public string[] AllowedHeaders { get; set; } = ["Authorization", "Content-Type", "x-tenant-code"];
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required]
|
||||
[System.ComponentModel.DataAnnotations.MinLength(1)]
|
||||
public string[] AllowedMethods { get; set; } = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
||||
|
||||
public bool AllowCredentials { get; set; }
|
||||
}
|
||||
|
||||
21
Tiku.Api/Options/OptionsValidation.cs
Normal file
21
Tiku.Api/Options/OptionsValidation.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace Tiku.Api.Options;
|
||||
|
||||
public static class OptionsValidation
|
||||
{
|
||||
public static bool BeValidCorsOptions(CorsOptions options)
|
||||
{
|
||||
if (options.AllowCredentials && options.AllowedOrigins.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return options.AllowedOrigins.All(IsHttpOrigin);
|
||||
}
|
||||
|
||||
private static bool IsHttpOrigin(string origin)
|
||||
{
|
||||
return Uri.TryCreate(origin, UriKind.Absolute, out var uri) &&
|
||||
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) &&
|
||||
string.IsNullOrEmpty(uri.PathAndQuery.Trim('/'));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -18,5 +18,10 @@
|
||||
"http://127.0.0.1:3000"
|
||||
],
|
||||
"AllowCredentials": false
|
||||
},
|
||||
"RateLimiting": {
|
||||
"PermitLimit": 1200,
|
||||
"WindowSeconds": 60,
|
||||
"QueueLimit": 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,12 @@
|
||||
],
|
||||
"AllowCredentials": false
|
||||
},
|
||||
"RateLimiting": {
|
||||
"Enabled": true,
|
||||
"PermitLimit": 600,
|
||||
"WindowSeconds": 60,
|
||||
"QueueLimit": 0
|
||||
},
|
||||
"Security": {
|
||||
"Jwt": {
|
||||
"Issuer": "tiku-backend",
|
||||
|
||||
Reference in New Issue
Block a user