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);

View File

@@ -0,0 +1,68 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Options;
using Tiku.Api.Options;
namespace Tiku.Api.Middleware;
public sealed class BrowserCsrfMiddleware(
RequestDelegate next,
IOptions<BrowserAuthOptions> options)
{
public async Task InvokeAsync(HttpContext context)
{
if (!IsUnsafe(context.Request.Method) || !IsBrowserCookieRequest(context.Request))
{
await next(context);
return;
}
if (!IsTrustedOrigin(context.Request) || !HasValidCsrfToken(context.Request))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new
{
title = "Browser request origin or CSRF token is invalid.",
status = StatusCodes.Status403Forbidden,
code = "browser_csrf_rejected",
traceId = context.TraceIdentifier
}, context.RequestAborted);
return;
}
await next(context);
}
private static bool IsUnsafe(string method) =>
!HttpMethods.IsGet(method) && !HttpMethods.IsHead(method) && !HttpMethods.IsOptions(method);
private static bool IsBrowserCookieRequest(HttpRequest request) =>
request.Cookies.ContainsKey(BrowserAuthOptions.AccessCookie) ||
request.Cookies.ContainsKey(BrowserAuthOptions.RefreshCookie);
private bool IsTrustedOrigin(HttpRequest request)
{
var origin = request.Headers.Origin.ToString().Trim().TrimEnd('/');
if (string.IsNullOrWhiteSpace(origin) || !Uri.TryCreate(origin, UriKind.Absolute, out var uri))
{
return false;
}
var sameOrigin = string.Equals(uri.Scheme, request.Scheme, StringComparison.OrdinalIgnoreCase) &&
string.Equals(uri.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase);
return sameOrigin || options.Value.AllowedOrigins.Any(allowed =>
string.Equals(allowed.Trim().TrimEnd('/'), origin, StringComparison.OrdinalIgnoreCase));
}
private static bool HasValidCsrfToken(HttpRequest request)
{
var cookie = request.Cookies[BrowserAuthOptions.CsrfCookie];
var header = request.Headers[BrowserAuthOptions.CsrfHeader].ToString();
if (string.IsNullOrWhiteSpace(cookie) || string.IsNullOrWhiteSpace(header))
{
return false;
}
var left = System.Text.Encoding.UTF8.GetBytes(cookie);
var right = System.Text.Encoding.UTF8.GetBytes(header);
return left.Length == right.Length && CryptographicOperations.FixedTimeEquals(left, right);
}
}

View File

@@ -50,6 +50,12 @@ public sealed class ExceptionHandlingMiddleware(
return;
}
if (exception is BrowserOriginException)
{
await WriteProblemAsync(context, exception.Message, StatusCodes.Status403Forbidden, "browser_origin_rejected");
return;
}
if (exception is TenantContextConflictException)
{
await WriteProblemAsync(
@@ -380,6 +386,7 @@ public sealed class ExceptionHandlingMiddleware(
"tenant_access_denied" => StatusCodes.Status403Forbidden,
"sms_rate_limited" => StatusCodes.Status429TooManyRequests,
"auth_provider_not_configured" => StatusCodes.Status503ServiceUnavailable,
"auth_security_unavailable" => StatusCodes.Status503ServiceUnavailable,
"session_revoked" => StatusCodes.Status401Unauthorized,
_ => StatusCodes.Status401Unauthorized
};
@@ -494,7 +501,7 @@ public sealed class ExceptionHandlingMiddleware(
{
return code switch
{
"platform_access_denied" or "tenant_access_denied" => StatusCodes.Status403Forbidden,
"platform_access_denied" or "tenant_access_denied" or "capability_not_available" => StatusCodes.Status403Forbidden,
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound,
_ => StatusCodes.Status400BadRequest
};