mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 15:23:12 +00:00
130 lines
4.3 KiB
C#
130 lines
4.3 KiB
C#
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 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);
|
|
}
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
}
|
|
}
|
|
}
|