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);
|
||||
|
||||
68
Tiku.Api/Middleware/BrowserCsrfMiddleware.cs
Normal file
68
Tiku.Api/Middleware/BrowserCsrfMiddleware.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user