using System.Net;
using System.Threading.RateLimiting;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
namespace Api.SeaHavenIndustries.Infrastructure;
///
/// Per-client limits on the anonymous password reset endpoints, plus the
/// forwarded-header trust that makes "per client" mean the caller and not the proxy.
///
public static class PasswordResetRateLimiting
{
public const string ForgetPasswordPolicy = "password-reset-request";
public const string VerificationCodePolicy = "password-reset-verify";
public const string ResetPasswordPolicy = "password-reset-confirm";
public const int PermitLimit = 10;
public static readonly TimeSpan Window = TimeSpan.FromMinutes(15);
public const string RejectedMessage = "Too many requests. Please try again later.";
///
/// The API runs on Elastic Beanstalk behind an application load balancer and the
/// instance's nginx, so every request reaches Kestrel from loopback. X-Forwarded-For
/// is read right to left through loopback and private (VPC) hops only; the first
/// public address is the client. Entries a caller writes further left are ignored.
///
public static IServiceCollection AddPasswordResetRateLimiting(this IServiceCollection services)
{
services.Configure(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor;
options.ForwardLimit = null;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("127.0.0.0"), 8));
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("10.0.0.0"), 8));
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("172.16.0.0"), 12));
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("192.168.0.0"), 16));
options.KnownProxies.Add(IPAddress.IPv6Loopback);
});
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsJsonAsync(
new Response { Status = "Error", Message = RejectedMessage },
cancellationToken);
};
AddPerClientPolicy(options, ForgetPasswordPolicy);
AddPerClientPolicy(options, VerificationCodePolicy);
AddPerClientPolicy(options, ResetPasswordPolicy);
});
return services;
}
public static string ClientPartitionKey(HttpContext context)
{
var address = context.Connection.RemoteIpAddress;
if (address == null)
return "unknown";
return (address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address).ToString();
}
private static void AddPerClientPolicy(RateLimiterOptions options, string policyName)
{
options.AddPolicy(policyName, context => RateLimitPartition.GetFixedWindowLimiter(
ClientPartitionKey(context),
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = PermitLimit,
Window = Window,
QueueLimit = 0,
AutoReplenishment = true
}));
}
}