forked from gongxuegit/tiku-backend.net
feat(security): add distributed authorization foundation
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user