case 4726 - login lockout per login name and per address

Same as AyaNova: failed logins counted in memory, 10 per login name or 50
per client address within 15 minutes, then 429 for the rest of the window.
Sockeye's login is public (only SSH to the droplet is limited).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 20:12:31 -07:00
parent cd3ebed68b
commit 6d6c74fd32
2 changed files with 94 additions and 1 deletions

View File

@@ -79,8 +79,14 @@ namespace Sockeye.Api.Controllers
//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(Sockeye.Util.ServerBootConfig.FAILED_AUTH_DELAY);
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
@@ -96,7 +102,7 @@ namespace Sockeye.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
@@ -131,11 +137,20 @@ namespace Sockeye.Api.Controllers
}
//No users matched, it's a failed login
RecordLoginFailure(creds.Login, address);
//Make a failed pw wait
await Task.Delay(Sockeye.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 Sockeye.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 nginx the real client address is seen,
/// so loopback is only the machine itself
/// </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 _);
}
}
}