From 015c9b55824436653b11a37510de4463bb3119bd Mon Sep 17 00:00:00 2001 From: xiong Date: Sun, 26 Jul 2026 13:46:17 +0800 Subject: [PATCH] feat: add rate limiting and startup options validation --- README.md | 17 +++++ Tiku.Api/Options/ApiRateLimitOptions.cs | 17 +++++ Tiku.Api/Options/CorsOptions.cs | 8 +++ Tiku.Api/Options/OptionsValidation.cs | 21 ++++++ Tiku.Api/Program.cs | 71 ++++++++++++++++++- Tiku.Api/appsettings.Development.json | 5 ++ Tiku.Api/appsettings.json | 6 ++ Tiku.Application/Security/JwtOptions.cs | 11 +++ .../Api/SecurityFoundationTests.cs | 50 +++++++++++++ 9 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 Tiku.Api/Options/ApiRateLimitOptions.cs create mode 100644 Tiku.Api/Options/OptionsValidation.cs diff --git a/README.md b/README.md index 642f70f..eb03478 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,23 @@ Tiku.IntegrationTests # EF 模型/持久化约束测试 - 不默认允许 credentials;如果后续需要 cookie 模式或管理后台单独域名,需要显式配置。 - 多租户正式域名确定后,可以把 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,作为低分配集合处理工具。 diff --git a/Tiku.Api/Options/ApiRateLimitOptions.cs b/Tiku.Api/Options/ApiRateLimitOptions.cs new file mode 100644 index 0000000..3330337 --- /dev/null +++ b/Tiku.Api/Options/ApiRateLimitOptions.cs @@ -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; } +} diff --git a/Tiku.Api/Options/CorsOptions.cs b/Tiku.Api/Options/CorsOptions.cs index c608c7f..d8fbaae 100644 --- a/Tiku.Api/Options/CorsOptions.cs +++ b/Tiku.Api/Options/CorsOptions.cs @@ -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; } } diff --git a/Tiku.Api/Options/OptionsValidation.cs b/Tiku.Api/Options/OptionsValidation.cs new file mode 100644 index 0000000..6a85802 --- /dev/null +++ b/Tiku.Api/Options/OptionsValidation.cs @@ -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('/')); + } +} diff --git a/Tiku.Api/Program.cs b/Tiku.Api/Program.cs index 11db74d..5a62e14 100644 --- a/Tiku.Api/Program.cs +++ b/Tiku.Api/Program.cs @@ -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(builder.Configuration.GetSection(CorsOptions.SectionName)); + builder.Services.AddOptions() + .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() ?? new CorsOptions(); @@ -73,6 +80,57 @@ try } }); }); + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(ApiRateLimitOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + var rateLimitOptions = builder.Configuration + .GetSection(ApiRateLimitOptions.SectionName) + .Get() ?? new ApiRateLimitOptions(); + if (rateLimitOptions.Enabled) + { + builder.Services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.GlobalLimiter = PartitionedRateLimiter.Create(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(builder.Configuration.GetSection("Security:Jwt")); + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection("Security:Jwt")) + .ValidateDataAnnotations() + .ValidateOnStart(); var jwtOptions = builder.Configuration .GetSection("Security:Jwt") .Get() ?? new JwtOptions(); @@ -164,8 +225,14 @@ try app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging); app.UseMiddleware(); app.UseHttpsRedirection(); + app.UseRouting(); app.UseCors(CorsOptions.PolicyName); app.UseAuthentication(); + if (rateLimitOptions.Enabled) + { + app.UseRateLimiter(); + } + app.UseMiddleware(); app.UseAuthorization(); diff --git a/Tiku.Api/appsettings.Development.json b/Tiku.Api/appsettings.Development.json index 6991caa..65ebbc4 100644 --- a/Tiku.Api/appsettings.Development.json +++ b/Tiku.Api/appsettings.Development.json @@ -18,5 +18,10 @@ "http://127.0.0.1:3000" ], "AllowCredentials": false + }, + "RateLimiting": { + "PermitLimit": 1200, + "WindowSeconds": 60, + "QueueLimit": 0 } } diff --git a/Tiku.Api/appsettings.json b/Tiku.Api/appsettings.json index 8381815..7616b9d 100644 --- a/Tiku.Api/appsettings.json +++ b/Tiku.Api/appsettings.json @@ -43,6 +43,12 @@ ], "AllowCredentials": false }, + "RateLimiting": { + "Enabled": true, + "PermitLimit": 600, + "WindowSeconds": 60, + "QueueLimit": 0 + }, "Security": { "Jwt": { "Issuer": "tiku-backend", diff --git a/Tiku.Application/Security/JwtOptions.cs b/Tiku.Application/Security/JwtOptions.cs index 85e528c..8054281 100644 --- a/Tiku.Application/Security/JwtOptions.cs +++ b/Tiku.Application/Security/JwtOptions.cs @@ -2,10 +2,21 @@ namespace Tiku.Application.Security; public sealed class JwtOptions { + [System.ComponentModel.DataAnnotations.Required] public string Issuer { get; set; } = "tiku-backend"; + + [System.ComponentModel.DataAnnotations.Required] 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"; + + [System.ComponentModel.DataAnnotations.Range(1, 1440)] public int AccessTokenMinutes { get; set; } = 30; + + [System.ComponentModel.DataAnnotations.Range(1, 365)] public int RefreshTokenDays { get; set; } = 30; + public bool ValidateSessions { get; set; } = true; } diff --git a/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs b/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs index a03b703..f94342a 100644 --- a/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs +++ b/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs @@ -2,7 +2,9 @@ using System.IdentityModel.Tokens.Jwt; using System.Net; using System.Security.Claims; using System.Text; +using System.Text.Json; using Microsoft.IdentityModel.Tokens; +using Tiku.Api.Options; using Tiku.Application.Security; using Tiku.Domain.Tenancy; @@ -71,6 +73,54 @@ public sealed class SecurityFoundationTests 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() { return new ApiTestFactory();