mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 07:29:00 -07:00
123 lines
4.0 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|
|
}
|