using System; using System.Collections.Concurrent; using System.Net; namespace AyaNova.Util { /// /// case 4726: failed logins counted per login name and per client address (in memory, a restart resets the counts). /// Too many failures within the window and further attempts are refused until the window ends. /// Login names that don't exist are counted the same way, so the refusal says nothing about which logins exist. /// Requests from this machine (loopback) are not counted per address: behind the hosted nginx and IIS the real /// client address is seen, so loopback is only the machine itself (and the integration tests) /// internal static class LoginThrottle { internal const int MAX_LOGIN_FAILURES = 10; internal const int MAX_ADDRESS_FAILURES = 50; private static readonly TimeSpan WINDOW = TimeSpan.FromMinutes(15); private const int PRUNE_ABOVE = 10000; private record Window(DateTime Start, int Count); private static readonly ConcurrentDictionary Failures = new ConcurrentDictionary(); private static string LoginKey(string login) => "L:" + (login ?? string.Empty).Trim().ToLowerInvariant(); private static string AddressKey(IPAddress address) => address == null || IPAddress.IsLoopback(address) ? null : "A:" + address.ToString(); /// /// True if this login or this address has reached its limit in the current window /// internal static bool IsRefused(string login, IPAddress address) { return Reached(LoginKey(login), MAX_LOGIN_FAILURES) || Reached(AddressKey(address), MAX_ADDRESS_FAILURES); } /// /// Counts a failed login; returns a description when this failure reached a limit (for the log), otherwise null /// internal static string RecordFailure(string login, IPAddress address) { if (Failures.Count > PRUNE_ABOVE) Prune(); string reached = null; if (Count(LoginKey(login)) == MAX_LOGIN_FAILURES) reached = $"login \"{login}\""; var addressKey = AddressKey(address); if (addressKey != null && Count(addressKey) == MAX_ADDRESS_FAILURES) reached = (reached == null ? "" : reached + " and ") + $"address {address}"; return reached; } /// /// A correct password clears the count for that login /// internal static void RecordSuccess(string login) { Failures.TryRemove(LoginKey(login), out _); } private static bool Reached(string key, int max) { return key != null && Failures.TryGetValue(key, out var w) && DateTime.UtcNow - w.Start < WINDOW && w.Count >= max; } private static int Count(string key) { var now = DateTime.UtcNow; return Failures.AddOrUpdate(key, new Window(now, 1), (_, w) => now - w.Start < WINDOW ? w with { Count = w.Count + 1 } : new Window(now, 1)).Count; } private static void Prune() { var now = DateTime.UtcNow; foreach (var kv in Failures) if (now - kv.Value.Start >= WINDOW) Failures.TryRemove(kv.Key, out _); } } }