using System.Collections.Concurrent; namespace RadicadeServer.Auth; public static class AdminLoginThrottle { public const int MaxFailedAttempts = 5; public static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(15); private sealed record Entry(int Failures, DateTime FirstFailureAt, DateTime LockedUntil); private static readonly ConcurrentDictionary Attempts = new(); public static bool IsLocked(string username, string ip, out TimeSpan remaining) { remaining = TimeSpan.Zero; var byUser = LockedRemaining("u:" + username); var byIp = LockedRemaining("ip:" + ip); if (byUser == null && byIp == null) return false; remaining = Max(byUser, byIp)!.Value; return true; } public static void RecordFailure(string username, string ip) { var now = DateTime.UtcNow; foreach (var key in Keys(username, ip)) { Attempts.AddOrUpdate(key, _ => new Entry(1, now, DateTime.MinValue), (_, existing) => { if (now - existing.FirstFailureAt > LockoutDuration) return new Entry(1, now, DateTime.MinValue); var failures = existing.Failures + 1; var lockedUntil = failures >= MaxFailedAttempts ? now + LockoutDuration : DateTime.MinValue; return new Entry(failures, existing.FirstFailureAt, lockedUntil); }); } Prune(now); } public static void Clear(string username, string ip) { foreach (var key in Keys(username, ip)) Attempts.TryRemove(key, out _); } private static TimeSpan? LockedRemaining(string key) { if (string.IsNullOrWhiteSpace(key)) return null; if (!Attempts.TryGetValue(key, out var entry)) return null; if (entry.Failures < MaxFailedAttempts) return null; var now = DateTime.UtcNow; if (now >= entry.LockedUntil) { Attempts.TryRemove(key, out _); return null; } return entry.LockedUntil - now; } private static IEnumerable Keys(string username, string ip) { if (!string.IsNullOrWhiteSpace(username)) yield return "u:" + username; if (!string.IsNullOrWhiteSpace(ip)) yield return "ip:" + ip; } private static TimeSpan? Max(TimeSpan? a, TimeSpan? b) { if (a == null) return b; if (b == null) return a; return a > b ? a : b; } private static void Prune(DateTime now) { foreach (var kv in Attempts) { var e = kv.Value; var lockActive = e.Failures >= MaxFailedAttempts && now < e.LockedUntil; if (!lockActive && now - e.FirstFailureAt > LockoutDuration) Attempts.TryRemove(kv.Key, out _); } } }