feat(security): add distributed authorization foundation

This commit is contained in:
2026-07-29 10:40:10 +08:00
parent c7f9a4e3c9
commit df88fa19cb
76 changed files with 22020 additions and 88 deletions

View File

@@ -3,11 +3,24 @@ using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.RateLimiting;
using Tiku.Api.Options;
using Tiku.Application.Security;
using Tiku.Infrastructure.Security;
namespace Tiku.Api.Middleware;
public sealed class AuthRateLimitPartitionMiddleware(RequestDelegate next)
public sealed class AuthRateLimitPartitionMiddleware(
RequestDelegate next,
IRedisSecurityStore redisSecurityStore,
Microsoft.Extensions.Options.IOptions<AuthRateLimitOptions> options)
{
public AuthRateLimitPartitionMiddleware(RequestDelegate next)
: this(
next,
new NullRedisSecurityStore(),
Microsoft.Extensions.Options.Options.Create(new AuthRateLimitOptions()))
{
}
public async Task InvokeAsync(HttpContext context)
{
var policy = context.GetEndpoint()?
@@ -24,11 +37,70 @@ public sealed class AuthRateLimitPartitionMiddleware(RequestDelegate next)
if (HttpMethods.IsPost(context.Request.Method) && propertyName is not null)
{
await CaptureAccountHashAsync(context, propertyName);
if (redisSecurityStore.IsConfigured)
{
await ConsumeDistributedLimitAsync(context, policy!);
if (context.Response.HasStarted)
{
return;
}
}
}
await next(context);
}
private async Task ConsumeDistributedLimitAsync(HttpContext context, string policyName)
{
var ip = Hash(context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip");
var account = context.Items.TryGetValue(AuthRateLimitPartitionKey.AccountHashItemKey, out var value)
? value as string ?? "unknown-account"
: "unknown-account";
var isPassword = policyName == AuthRateLimitPolicies.Password;
var limit = isPassword ? options.Value.PasswordPermitLimit : options.Value.SmsPermitLimit;
var window = TimeSpan.FromSeconds(isPassword
? options.Value.PasswordWindowSeconds
: options.Value.SmsWindowSeconds);
DistributedRateLimitResult result;
try
{
result = await redisSecurityStore.ConsumeAsync(
[
new DistributedRateLimitBucket($"{policyName}:ip:{ip}", limit * 4, window),
new DistributedRateLimitBucket($"{policyName}:ip-account:{ip}:{account}", limit, window)
], context.RequestAborted);
}
catch (RedisSecurityUnavailableException)
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsJsonAsync(new
{
title = "Authentication security service is unavailable.",
status = StatusCodes.Status503ServiceUnavailable,
code = "auth_security_unavailable",
traceId = context.TraceIdentifier
}, context.RequestAborted);
return;
}
if (!result.Allowed)
{
if (result.RetryAfter is { } retryAfter)
{
context.Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
}
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.Response.WriteAsJsonAsync(new
{
title = "Too many requests.",
status = StatusCodes.Status429TooManyRequests,
code = "rate_limited",
traceId = context.TraceIdentifier
}, context.RequestAborted);
}
}
private static async Task CaptureAccountHashAsync(HttpContext context, string propertyName)
{
context.Request.EnableBuffering(bufferThreshold: 4096, bufferLimit: 16_384);