[Security] Add per-address rate limiting for auth endpoints

Introduce a thread-safe RateLimiter that tracks attempts per key
(IP address) within a sliding time window, and wire it into the
authentication endpoints:

- Login: failed login attempts from an address are counted; once the
  configured maximum is exceeded within the window, further logins
  from that address are rejected with RespTooManyRequests. A
  successful login clears the failed attempts for that address.
- Registration: implement the previously stubbed
  tooManyRegistrationAttempts, limiting how many accounts can be
  created per address per window.
- Forgot-password: throttle both the email-request and the
  email-challenge paths per address.

New [security] settings with defaults:
  max_login_attempts_per_ip=5 / login_attempt_window_seconds=900
  max_registrations_per_ip=2 / registration_window_seconds=3600
  max_forgot_password_requests_per_ip=3 / forgot_password_window_seconds=3600

Adds unit tests for the RateLimiter (window limit, over-limit
blocking, clearing, per-key independence).

Took 3 minutes
This commit is contained in:
Lukas Brübach 2026-08-04 10:38:20 +02:00
parent 1ed9823b56
commit b2f63255f0
11 changed files with 238 additions and 3 deletions

View file

@ -175,6 +175,15 @@ public:
{
return false;
}
/** @brief Record a failed login attempt from the given address; returns true if the address is now locked out. */
virtual bool recordFailedLogin(const QString & /*ipAddress*/)
{
return false;
}
/** @brief Clear any failed-login lockout for the given address, e.g. after a successful login. */
virtual void clearFailedLogins(const QString & /*ipAddress*/)
{
}
Server_DatabaseInterface *getDatabaseInterface() const;
int getNextLocalGameId()

View file

@ -527,10 +527,14 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
return Response::RespUserIsBanned;
}
case NotLoggedIn:
if (server->recordFailedLogin(getAddress())) {
return Response::RespTooManyRequests;
}
return Response::RespWrongPassword;
case WouldOverwriteOldSession:
return Response::RespWouldOverwriteOldSession;
case UsernameInvalid: {
server->recordFailedLogin(getAddress());
auto *re = new Response_Login;
re->set_denied_reason_str(reasonStr.toStdString());
rc.setResponseExtension(re);
@ -541,8 +545,10 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
case ClientIdRequired:
return Response::RespClientIdRequired;
case UserIsInactive:
server->recordFailedLogin(getAddress());
return Response::RespAccountNotActivated;
default:
server->clearFailedLogins(getAddress());
authState = res;
usingRealPassword = needsHash;
}