using System.Security.Cryptography; using System.Text; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Helpers { /// /// Process-wide sliding-window counters for . /// 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. /// 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 _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 void ReleaseCodeRequest(string email) { var now = _timeProvider.GetUtcNow(); lock (_gate) { var account = AccountFor(email, now); if (account.Requests.Count > 0) account.Requests.RemoveAt(account.Requests.Count - 1); } } 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); } } /// The same normalization the user lookup applies: trimmed, invariant upper case. 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 Requests { get; } = new(); public List FailedChecks { get; } = new(); public void Prune(DateTimeOffset cutoff) { Requests.RemoveAll(at => at <= cutoff); FailedChecks.RemoveAll(at => at <= cutoff); } } } }