mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
Forgot Password answers every address the same way and emails a code only to an active account. Codes are stored as salted SHA-256 hashes, expire 15 minutes after issue, are replaced by a newer request, and are checked only against the email they were issued to. Five failed checks delete the code; attempts are reserved with one conditional UPDATE so concurrent guesses cannot exceed the budget. VerificationCode requires the email, and email and code are accepted in the JSON body so they stay out of URLs. The three anonymous endpoints are rate limited to 10 requests per 15 minutes per client IP. Forwarded headers are trusted only through loopback and private hops, since the API sits behind the EB load balancer and nginx. The migration adds hash, salt, expiry and attempt columns and deletes the old plaintext rows.
84 lines
3.6 KiB
C#
84 lines
3.6 KiB
C#
using System.Net;
|
|
using System.Threading.RateLimiting;
|
|
using Data.SeaHavenIndustries;
|
|
using Microsoft.AspNetCore.HttpOverrides;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
|
|
namespace Api.SeaHavenIndustries.Infrastructure;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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.";
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static IServiceCollection AddPasswordResetRateLimiting(this IServiceCollection services)
|
|
{
|
|
services.Configure<ForwardedHeadersOptions>(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
|
|
}));
|
|
}
|
|
}
|