case 4726 - login lockout per login name and per address

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>
This commit is contained in:
2026-10-24 20:12:26 -07:00
parent 80f81aea0f
commit d8be701bcd
5 changed files with 100 additions and 3 deletions

View File

@@ -82,8 +82,14 @@ namespace AyaNova.Api.Controllers
return StatusCode(451, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED, "generalerror", "License agreement consent required"));
}
//case 4726: too many failed logins for this login name or from this address: refused at once, the password is not checked
var address = HttpContext.Connection.RemoteIpAddress;
if (LoginThrottle.IsRefused(creds.Login, address))
return StatusCode(429, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED, null, "Too many failed login attempts, please try again later"));
if (string.IsNullOrWhiteSpace(creds.Login) || string.IsNullOrWhiteSpace(creds.Password))
{
RecordLoginFailure(creds.Login, address);
//Make a failed pw wait
await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY);
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
@@ -99,7 +105,7 @@ namespace AyaNova.Api.Controllers
string hashed = Hasher.hash(u.Salt, creds.Password);
if (hashed == u.Password)
{
LoginThrottle.RecordSuccess(creds.Login);
//TWO FACTOR ENABLED??
//if 2fa enabled then need to validate it before sending token, so we're halfway there and need to send a 2fa prompt
@@ -134,11 +140,20 @@ namespace AyaNova.Api.Controllers
}
//No users matched, it's a failed login
RecordLoginFailure(creds.Login, address);
//Make a failed pw wait
await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY);
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
}
//case 4726
private void RecordLoginFailure(string login, System.Net.IPAddress address)
{
var reached = LoginThrottle.RecordFailure(login, address);
if (reached != null)
log.LogWarning($"Too many failed login attempts for {reached}: further attempts refused for up to 15 minutes");
}

View File

@@ -0,0 +1,78 @@
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 _);
}
}
}