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 _); + } + } +}