using System.Collections.Concurrent; namespace RadicadeServer.Utils { public static class AuthSecurityMonitor { public static readonly TimeSpan Window = TimeSpan.FromMinutes(10); public const int AccountFailureAlertThreshold = 8; public const int SourceSprayAccountThreshold = 12; public const int SourceFailureAlertThreshold = 40; public const int MaxProgressiveDelayMs = 3000; private static readonly ConcurrentDictionary AccountFailures = new(); private static readonly ConcurrentDictionary SourceFailures = new(); public static int RecordFailedLogin(long? accountId, string? ip) { var now = DateTime.UtcNow; var accountCount = 0; if (accountId is > 0) { var counter = AccountFailures.GetOrAdd(accountId.Value, _ => new Counter()); accountCount = counter.Record(now, Window); if (accountCount == AccountFailureAlertThreshold) Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] security alert: {accountCount} failed sign-ins for account {accountId.Value} within {Window.TotalMinutes:0} minutes - for staff review only, no automatic action taken"); } if (!string.IsNullOrWhiteSpace(ip)) { var source = SourceFailures.GetOrAdd(ip!, _ => new SourceCounter()); var (failures, accounts) = source.Record(now, Window, accountId); if (accounts == SourceSprayAccountThreshold) Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] security alert: suspected password spraying from {ip} - failed sign-ins for {accounts} different accounts within {Window.TotalMinutes:0} minutes"); else if (failures == SourceFailureAlertThreshold) Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] security alert: {failures} failed sign-ins from {ip} within {Window.TotalMinutes:0} minutes"); } return accountCount; } public static int ProgressiveDelayMs(int failureCount) => failureCount <= 3 ? 0 : Math.Min(MaxProgressiveDelayMs, (failureCount - 3) * 300); public static bool IsSourceThrottled(string? ip) { if (string.IsNullOrWhiteSpace(ip)) return false; if (!SourceFailures.TryGetValue(ip!, out var source)) return false; return source.DistinctAccounts(DateTime.UtcNow, Window) >= SourceSprayAccountThreshold; } public static void RecordSuccessfulLogin(long accountId, string? ip, bool newDevice, bool newIp) { if (accountId > 0) AccountFailures.TryRemove(accountId, out _); if (newDevice || newIp) Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] security note: account {accountId} signed in from a {(newDevice ? "new device" : "new IP")} - informational only, this never bans or locks by itself"); } public static void NoteCredentialChange(long accountId, string kind) => Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] security note: {kind} for account {accountId} - verify it was requested by the account owner"); public static void Reset() { AccountFailures.Clear(); SourceFailures.Clear(); } private sealed class Counter { private readonly object gate = new(); private readonly List hits = new(); public int Record(DateTime now, TimeSpan window) { lock (gate) { hits.RemoveAll(t => now - t > window); hits.Add(now); return hits.Count; } } } private sealed class SourceCounter { private readonly object gate = new(); private readonly List<(DateTime At, long AccountId)> hits = new(); public (int Failures, int Accounts) Record(DateTime now, TimeSpan window, long? accountId) { lock (gate) { hits.RemoveAll(h => now - h.At > window); hits.Add((now, accountId ?? 0)); return (hits.Count, Distinct()); } } public int DistinctAccounts(DateTime now, TimeSpan window) { lock (gate) { hits.RemoveAll(h => now - h.At > window); return Distinct(); } } private int Distinct() { var set = new HashSet(); foreach (var hit in hits) if (hit.AccountId > 0) set.Add(hit.AccountId); return set.Count; } } } }