forked from gongxuegit/tiku-backend.net
feat: add rate limiting and startup options validation
This commit is contained in:
17
README.md
17
README.md
@@ -75,6 +75,23 @@ Tiku.IntegrationTests # EF 模型/持久化约束测试
|
|||||||
- 不默认允许 credentials;如果后续需要 cookie 模式或管理后台单独域名,需要显式配置。
|
- 不默认允许 credentials;如果后续需要 cookie 模式或管理后台单独域名,需要显式配置。
|
||||||
- 多租户正式域名确定后,可以把 CORS 白名单从静态配置升级为“租户域名 + 平台管理域名”的集中策略。
|
- 多租户正式域名确定后,可以把 CORS 白名单从静态配置升级为“租户域名 + 平台管理域名”的集中策略。
|
||||||
|
|
||||||
|
### 限流
|
||||||
|
|
||||||
|
- API 接入 ASP.NET Core RateLimiter,作为全局兜底防线。
|
||||||
|
- 匿名请求按 IP 分桶,已登录请求按用户分桶,减少同学校/同机构出口 NAT 下的误伤。
|
||||||
|
- 默认使用固定窗口限流:
|
||||||
|
- 生产默认 `600 / minute`
|
||||||
|
- 开发默认 `1200 / minute`
|
||||||
|
- 被限流时返回标准 `429 Too Many Requests`,响应体包含 `code = rate_limited` 和 `traceId`。
|
||||||
|
- 当前先使用进程内限流;多实例部署时,需要上移到网关/负载均衡层,或接入 Redis 等集中式限流状态。
|
||||||
|
|
||||||
|
### 配置校验
|
||||||
|
|
||||||
|
- 关键配置使用 Options validation,并在启动阶段 `ValidateOnStart()`。
|
||||||
|
- JWT 配置会校验 issuer、audience、签名密钥长度、access/refresh token 有效期范围。
|
||||||
|
- CORS 配置会校验 Origin 必须是绝对 `http/https` origin;开启 credentials 时必须显式配置 origin。
|
||||||
|
- RateLimit 配置会校验 permit/window/queue 范围,避免错误配置在运行时才暴露。
|
||||||
|
|
||||||
### 热路径集合处理
|
### 热路径集合处理
|
||||||
|
|
||||||
- API 项目引入 ZLinq,作为低分配集合处理工具。
|
- API 项目引入 ZLinq,作为低分配集合处理工具。
|
||||||
|
|||||||
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 SectionName = "Cors";
|
||||||
public const string PolicyName = "TikuCors";
|
public const string PolicyName = "TikuCors";
|
||||||
|
|
||||||
|
[System.ComponentModel.DataAnnotations.Required]
|
||||||
public string[] AllowedOrigins { get; set; } = [];
|
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"];
|
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 string[] AllowedMethods { get; set; } = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
||||||
|
|
||||||
public bool AllowCredentials { get; set; }
|
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.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
@@ -6,6 +8,7 @@ using Serilog;
|
|||||||
using Serilog.Events;
|
using Serilog.Events;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
using Tiku.Api.Logging;
|
using Tiku.Api.Logging;
|
||||||
using Tiku.Api.Middleware;
|
using Tiku.Api.Middleware;
|
||||||
using Tiku.Api.OpenApi;
|
using Tiku.Api.OpenApi;
|
||||||
@@ -44,7 +47,11 @@ try
|
|||||||
});
|
});
|
||||||
builder.Services.AddProblemDetails();
|
builder.Services.AddProblemDetails();
|
||||||
builder.Services.AddApplication();
|
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
|
var corsOptions = builder.Configuration
|
||||||
.GetSection(CorsOptions.SectionName)
|
.GetSection(CorsOptions.SectionName)
|
||||||
.Get<CorsOptions>() ?? new CorsOptions();
|
.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 =
|
var connectionString =
|
||||||
builder.Configuration.GetConnectionString("Database") ??
|
builder.Configuration.GetConnectionString("Database") ??
|
||||||
@@ -81,7 +139,10 @@ try
|
|||||||
|
|
||||||
builder.Services.AddInfrastructure(connectionString);
|
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
|
var jwtOptions = builder.Configuration
|
||||||
.GetSection("Security:Jwt")
|
.GetSection("Security:Jwt")
|
||||||
.Get<JwtOptions>() ?? new JwtOptions();
|
.Get<JwtOptions>() ?? new JwtOptions();
|
||||||
@@ -164,8 +225,14 @@ try
|
|||||||
app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging);
|
app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging);
|
||||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
app.UseRouting();
|
||||||
app.UseCors(CorsOptions.PolicyName);
|
app.UseCors(CorsOptions.PolicyName);
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
|
if (rateLimitOptions.Enabled)
|
||||||
|
{
|
||||||
|
app.UseRateLimiter();
|
||||||
|
}
|
||||||
|
|
||||||
app.UseMiddleware<CurrentPrincipalMiddleware>();
|
app.UseMiddleware<CurrentPrincipalMiddleware>();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
|||||||
@@ -18,5 +18,10 @@
|
|||||||
"http://127.0.0.1:3000"
|
"http://127.0.0.1:3000"
|
||||||
],
|
],
|
||||||
"AllowCredentials": false
|
"AllowCredentials": false
|
||||||
|
},
|
||||||
|
"RateLimiting": {
|
||||||
|
"PermitLimit": 1200,
|
||||||
|
"WindowSeconds": 60,
|
||||||
|
"QueueLimit": 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,12 @@
|
|||||||
],
|
],
|
||||||
"AllowCredentials": false
|
"AllowCredentials": false
|
||||||
},
|
},
|
||||||
|
"RateLimiting": {
|
||||||
|
"Enabled": true,
|
||||||
|
"PermitLimit": 600,
|
||||||
|
"WindowSeconds": 60,
|
||||||
|
"QueueLimit": 0
|
||||||
|
},
|
||||||
"Security": {
|
"Security": {
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Issuer": "tiku-backend",
|
"Issuer": "tiku-backend",
|
||||||
|
|||||||
@@ -2,10 +2,21 @@ namespace Tiku.Application.Security;
|
|||||||
|
|
||||||
public sealed class JwtOptions
|
public sealed class JwtOptions
|
||||||
{
|
{
|
||||||
|
[System.ComponentModel.DataAnnotations.Required]
|
||||||
public string Issuer { get; set; } = "tiku-backend";
|
public string Issuer { get; set; } = "tiku-backend";
|
||||||
|
|
||||||
|
[System.ComponentModel.DataAnnotations.Required]
|
||||||
public string Audience { get; set; } = "tiku-api";
|
public string Audience { get; set; } = "tiku-api";
|
||||||
|
|
||||||
|
[System.ComponentModel.DataAnnotations.Required]
|
||||||
|
[System.ComponentModel.DataAnnotations.MinLength(32)]
|
||||||
public string SigningKey { get; set; } = "development-only-tiku-signing-key-change-before-production";
|
public string SigningKey { get; set; } = "development-only-tiku-signing-key-change-before-production";
|
||||||
|
|
||||||
|
[System.ComponentModel.DataAnnotations.Range(1, 1440)]
|
||||||
public int AccessTokenMinutes { get; set; } = 30;
|
public int AccessTokenMinutes { get; set; } = 30;
|
||||||
|
|
||||||
|
[System.ComponentModel.DataAnnotations.Range(1, 365)]
|
||||||
public int RefreshTokenDays { get; set; } = 30;
|
public int RefreshTokenDays { get; set; } = 30;
|
||||||
|
|
||||||
public bool ValidateSessions { get; set; } = true;
|
public bool ValidateSessions { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ using System.IdentityModel.Tokens.Jwt;
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using Tiku.Api.Options;
|
||||||
using Tiku.Application.Security;
|
using Tiku.Application.Security;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
|
|
||||||
@@ -71,6 +73,54 @@ public sealed class SecurityFoundationTests
|
|||||||
Assert.Contains("true", body, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("true", body, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Global_rate_limiter_returns_too_many_requests_problem()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
using var firstResponse = await client.GetAsync("/api/health");
|
||||||
|
HttpResponseMessage? rejectedResponse = null;
|
||||||
|
for (var index = 0; index < 1200; index++)
|
||||||
|
{
|
||||||
|
rejectedResponse?.Dispose();
|
||||||
|
rejectedResponse = await client.GetAsync("/api/health");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var secondResponse = rejectedResponse ?? throw new InvalidOperationException("Rate limit test did not send a second request.");
|
||||||
|
var body = JsonDocument.Parse(await secondResponse.Content.ReadAsStringAsync());
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
|
||||||
|
Assert.Equal("rate_limited", body.RootElement.GetProperty("code").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://tenant.example.com", true)]
|
||||||
|
[InlineData("http://localhost:5173", true)]
|
||||||
|
[InlineData("localhost:5173", false)]
|
||||||
|
[InlineData("https://tenant.example.com/path", false)]
|
||||||
|
public void Cors_options_validation_requires_absolute_http_origins(string origin, bool expected)
|
||||||
|
{
|
||||||
|
var options = new CorsOptions
|
||||||
|
{
|
||||||
|
AllowedOrigins = [origin]
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(expected, OptionsValidation.BeValidCorsOptions(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Cors_options_validation_requires_explicit_origins_when_credentials_are_enabled()
|
||||||
|
{
|
||||||
|
var options = new CorsOptions
|
||||||
|
{
|
||||||
|
AllowCredentials = true
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.False(OptionsValidation.BeValidCorsOptions(options));
|
||||||
|
}
|
||||||
|
|
||||||
private static ApiTestFactory CreateFactory()
|
private static ApiTestFactory CreateFactory()
|
||||||
{
|
{
|
||||||
return new ApiTestFactory();
|
return new ApiTestFactory();
|
||||||
|
|||||||
Reference in New Issue
Block a user