shoc-backend/SeaHaven.Services/Helpers/InMemoryPasswordResetThrottle.cs

120 lines
3.9 KiB
C#
Raw Normal View History

using System.Security.Cryptography;
using System.Text;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// Process-wide sliding-window counters for <see cref="IPasswordResetThrottle"/>.
/// Registered as a singleton; the API runs as a single instance, and a restart
/// clears the windows. Emails are held only as SHA-256 digests.
/// </summary>
public sealed class InMemoryPasswordResetThrottle : IPasswordResetThrottle
{
public const int CodeRequestsPerHour = 3;
public const int CodeRequestsPerDay = 10;
public const int FailedChecksPerDay = 10;
private static readonly TimeSpan Hour = TimeSpan.FromHours(1);
private static readonly TimeSpan Day = TimeSpan.FromDays(1);
private const int SweepEvery = 1024;
private readonly TimeProvider _timeProvider;
private readonly object _gate = new();
private readonly Dictionary<string, Account> _accounts = new(StringComparer.Ordinal);
private int _operations;
public InMemoryPasswordResetThrottle(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public bool TryAcceptCodeRequest(string email)
{
var now = _timeProvider.GetUtcNow();
lock (_gate)
{
var account = AccountFor(email, now);
if (account.Requests.Count >= CodeRequestsPerDay
|| account.Requests.Count(at => at > now - Hour) >= CodeRequestsPerHour)
{
return false;
}
account.Requests.Add(now);
return true;
}
}
public bool TryReserveCheck(string email)
{
var now = _timeProvider.GetUtcNow();
lock (_gate)
{
var account = AccountFor(email, now);
if (account.FailedChecks.Count >= FailedChecksPerDay)
return false;
account.FailedChecks.Add(now);
return true;
}
}
public void ReleaseCheck(string email)
{
var now = _timeProvider.GetUtcNow();
lock (_gate)
{
var account = AccountFor(email, now);
if (account.FailedChecks.Count > 0)
account.FailedChecks.RemoveAt(account.FailedChecks.Count - 1);
}
}
/// <summary>The same normalization the user lookup applies: trimmed, invariant upper case.</summary>
public static string KeyFor(string email)
{
var normalized = (email ?? string.Empty).Trim().ToUpperInvariant();
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized)));
}
private Account AccountFor(string email, DateTimeOffset now)
{
if (++_operations % SweepEvery == 0)
Sweep(now);
var key = KeyFor(email);
if (!_accounts.TryGetValue(key, out var account))
{
account = new Account();
_accounts[key] = account;
}
account.Prune(now - Day);
return account;
}
private void Sweep(DateTimeOffset now)
{
foreach (var (key, account) in _accounts.ToList())
{
account.Prune(now - Day);
if (account.Requests.Count == 0 && account.FailedChecks.Count == 0)
_accounts.Remove(key);
}
}
private sealed class Account
{
public List<DateTimeOffset> Requests { get; } = new();
public List<DateTimeOffset> FailedChecks { get; } = new();
public void Prune(DateTimeOffset cutoff)
{
Requests.RemoveAll(at => at <= cutoff);
FailedChecks.RemoveAll(at => at <= cutoff);
}
}
}
}