Failed logins are counted in memory (util/LoginThrottle.cs): 10 per login name or 50 per client address within 15 minutes, then attempts are refused with 429 for the rest of the window without checking the password. A correct password clears the login's count; loopback is not counted per address. Each lockout is logged. F6 fixed (2FA part in case 4710). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
79 lines
3.4 KiB
C#
79 lines
3.4 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Net;
|
|
|
|
namespace AyaNova.Util
|
|
{
|
|
/// <summary>
|
|
/// case 4726: failed logins counted per login name and per client address (in memory, a restart resets the counts).
|
|
/// Too many failures within the window and further attempts are refused until the window ends.
|
|
/// Login names that don't exist are counted the same way, so the refusal says nothing about which logins exist.
|
|
/// Requests from this machine (loopback) are not counted per address: behind the hosted nginx and IIS the real
|
|
/// client address is seen, so loopback is only the machine itself (and the integration tests)
|
|
/// </summary>
|
|
internal static class LoginThrottle
|
|
{
|
|
internal const int MAX_LOGIN_FAILURES = 10;
|
|
internal const int MAX_ADDRESS_FAILURES = 50;
|
|
private static readonly TimeSpan WINDOW = TimeSpan.FromMinutes(15);
|
|
private const int PRUNE_ABOVE = 10000;
|
|
|
|
private record Window(DateTime Start, int Count);
|
|
private static readonly ConcurrentDictionary<string, Window> Failures = new ConcurrentDictionary<string, Window>();
|
|
|
|
private static string LoginKey(string login) => "L:" + (login ?? string.Empty).Trim().ToLowerInvariant();
|
|
private static string AddressKey(IPAddress address) => address == null || IPAddress.IsLoopback(address) ? null : "A:" + address.ToString();
|
|
|
|
/// <summary>
|
|
/// True if this login or this address has reached its limit in the current window
|
|
/// </summary>
|
|
internal static bool IsRefused(string login, IPAddress address)
|
|
{
|
|
return Reached(LoginKey(login), MAX_LOGIN_FAILURES) || Reached(AddressKey(address), MAX_ADDRESS_FAILURES);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Counts a failed login; returns a description when this failure reached a limit (for the log), otherwise null
|
|
/// </summary>
|
|
internal static string RecordFailure(string login, IPAddress address)
|
|
{
|
|
if (Failures.Count > PRUNE_ABOVE)
|
|
Prune();
|
|
string reached = null;
|
|
if (Count(LoginKey(login)) == MAX_LOGIN_FAILURES)
|
|
reached = $"login \"{login}\"";
|
|
var addressKey = AddressKey(address);
|
|
if (addressKey != null && Count(addressKey) == MAX_ADDRESS_FAILURES)
|
|
reached = (reached == null ? "" : reached + " and ") + $"address {address}";
|
|
return reached;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A correct password clears the count for that login
|
|
/// </summary>
|
|
internal static void RecordSuccess(string login)
|
|
{
|
|
Failures.TryRemove(LoginKey(login), out _);
|
|
}
|
|
|
|
private static bool Reached(string key, int max)
|
|
{
|
|
return key != null && Failures.TryGetValue(key, out var w) && DateTime.UtcNow - w.Start < WINDOW && w.Count >= max;
|
|
}
|
|
|
|
private static int Count(string key)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
return Failures.AddOrUpdate(key, new Window(now, 1), (_, w) => now - w.Start < WINDOW ? w with { Count = w.Count + 1 } : new Window(now, 1)).Count;
|
|
}
|
|
|
|
private static void Prune()
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
foreach (var kv in Failures)
|
|
if (now - kv.Value.Start >= WINDOW)
|
|
Failures.TryRemove(kv.Key, out _);
|
|
}
|
|
}
|
|
}
|