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()? .PolicyName; var propertyName = policy switch { AuthRateLimitPolicies.Password => "identifier", AuthRateLimitPolicies.Sms => "phone", _ => 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}"; } }