From 6d6c74fd32e1a747199786ba8a6d54c69fd9dc46 Mon Sep 17 00:00:00 2001 From: John Cardinal Date: Sat, 24 Oct 2026 20:12:31 -0700 Subject: [PATCH] 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 --- server/Controllers/AuthController.cs | 17 +++++- server/util/LoginThrottle.cs | 78 ++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 server/util/LoginThrottle.cs diff --git a/server/Controllers/AuthController.cs b/server/Controllers/AuthController.cs index bb553e9..e9ad79a 100644 --- a/server/Controllers/AuthController.cs +++ b/server/Controllers/AuthController.cs @@ -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"); + } + diff --git a/server/util/LoginThrottle.cs b/server/util/LoginThrottle.cs new file mode 100644 index 0000000..18e6b1a --- /dev/null +++ b/server/util/LoginThrottle.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Concurrent; +using System.Net; + +namespace Sockeye.Util +{ + /// + /// 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 + /// + 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 Failures = new ConcurrentDictionary(); + + 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(); + + /// + /// True if this login or this address has reached its limit in the current window + /// + internal static bool IsRefused(string login, IPAddress address) + { + return Reached(LoginKey(login), MAX_LOGIN_FAILURES) || Reached(AddressKey(address), MAX_ADDRESS_FAILURES); + } + + /// + /// Counts a failed login; returns a description when this failure reached a limit (for the log), otherwise null + /// + 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; + } + + /// + /// A correct password clears the count for that login + /// + 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 _); + } + } +}