forked from gongxuegit/tiku-backend.net
feat: harden SaaS authentication and authorization
This commit is contained in:
106
Tiku.Api/Middleware/AuthRateLimitPartitionMiddleware.cs
Normal file
106
Tiku.Api/Middleware/AuthRateLimitPartitionMiddleware.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Tiku.Api.Options;
|
||||
|
||||
namespace Tiku.Api.Middleware;
|
||||
|
||||
public sealed class AuthRateLimitPartitionMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var policy = context.GetEndpoint()?
|
||||
.Metadata
|
||||
.GetMetadata<EnableRateLimitingAttribute>()?
|
||||
.PolicyName;
|
||||
var propertyName = policy switch
|
||||
{
|
||||
AuthRateLimitPolicies.Password => "identifier",
|
||||
AuthRateLimitPolicies.Sms => "phone",
|
||||
AuthRateLimitPolicies.Mfa => "challengeToken",
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (HttpMethods.IsPost(context.Request.Method) && propertyName is not null)
|
||||
{
|
||||
await CaptureAccountHashAsync(context, propertyName);
|
||||
}
|
||||
|
||||
await next(context);
|
||||
}
|
||||
|
||||
private static async Task CaptureAccountHashAsync(HttpContext context, string propertyName)
|
||||
{
|
||||
context.Request.EnableBuffering(bufferThreshold: 4096, bufferLimit: 16_384);
|
||||
try
|
||||
{
|
||||
using var document = await JsonDocument.ParseAsync(
|
||||
context.Request.Body,
|
||||
cancellationToken: context.RequestAborted);
|
||||
var captured = TryGetStringProperty(document.RootElement, propertyName) ??
|
||||
(propertyName == "identifier" ? TryGetStringProperty(document.RootElement, "phone") : null);
|
||||
if (captured is { } value &&
|
||||
!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
context.Items[AuthRateLimitPartitionKey.AccountHashItemKey] = Hash(value.Trim());
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// MVC will produce the canonical malformed JSON response.
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Oversized or unreadable bodies share the IP-only fallback partition.
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (context.Request.Body.CanSeek)
|
||||
{
|
||||
context.Request.Body.Position = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryGetStringProperty(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase) &&
|
||||
property.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return property.Value.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string Hash(string value)
|
||||
{
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))
|
||||
.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
public static class AuthRateLimitPartitionKey
|
||||
{
|
||||
internal const string AccountHashItemKey = "tiku.auth_rate_limit.account_hash";
|
||||
|
||||
public static string Resolve(HttpContext context, string policyName)
|
||||
{
|
||||
var ipAddress = context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip";
|
||||
var accountHash = context.Items.TryGetValue(AccountHashItemKey, out var value) &&
|
||||
value is string hash &&
|
||||
!string.IsNullOrWhiteSpace(hash)
|
||||
? hash
|
||||
: "unknown-account";
|
||||
return $"{policyName}:{ipAddress}:{accountHash}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user