Files
Radicade/Server/Auth/AdminLoginThrottle.cs
T
2026-10-16 23:33:13 -07:00

109 lines
2.4 KiB
C#

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<string, Entry> 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<string> 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 _);
}
}
}