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