Files
Radicade/Server/Players/AuthSecurityMonitor.cs
2026-10-16 23:33:13 -07:00

123 lines
4.0 KiB
C#

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<long, Counter> AccountFailures = new();
private static readonly ConcurrentDictionary<string, SourceCounter> 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<DateTime> 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<long>();
foreach (var hit in hits)
if (hit.AccountId > 0) set.Add(hit.AccountId);
return set.Count;
}
}
}
}