This commit is contained in:
BruebachL 2026-08-05 09:52:11 -07:00 committed by GitHub
commit 2fc8b5a246
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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;
}

View File

@ -7,6 +7,7 @@ project(Servatrice VERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${
set(servatrice_SOURCES
src/email_parser.cpp
src/main.cpp
src/ratelimiter.cpp
src/servatrice.cpp
src/servatrice_connection_pool.cpp
src/servatrice_database_interface.cpp

View File

@ -348,6 +348,27 @@ max_users_websocket=500
; Maximum number of users that can connect from the same IP address; useful to avoid bots, default is 4
max_users_per_address=4
; Maximum number of failed login attempts from a single IP address before that
; address is temporarily locked out. Default is 5; set to 0 to disable.
max_login_attempts_per_ip=5
; Length in seconds of the sliding window used for the login lockout. Default is 900 (15 minutes).
login_attempt_window_seconds=900
; Maximum number of account registrations from a single IP address within the window below.
; Default is 2; set to 0 to disable.
max_registrations_per_ip=2
; Length in seconds of the registration window. Default is 3600 (1 hour).
registration_window_seconds=3600
; Maximum number of forgot-password requests from a single IP address within the window below.
; Default is 3; set to 0 to disable.
max_forgot_password_requests_per_ip=3
; Length in seconds of the forgot-password window. Default is 3600 (1 hour).
forgot_password_window_seconds=3600
; You may want to allow an unlimited number of users from a trusted source. This setting can contain a
; comma-separed list of IP addresses which will allow an unlimited number of connections from each of the
; IP addresses listed (ignoring the max_users_per_address). Default is "127.0.0.1,::1"; example: "192.73.233.244,81.4.100.74"

View File

@ -0,0 +1,42 @@
#include "ratelimiter.h"
#include <QDateTime>
bool RateLimiter::recordAttempt(const QString &key, int maxAttempts, int windowSeconds)
{
QMutexLocker locker(&mutex);
const qint64 now = QDateTime::currentSecsSinceEpoch();
QList<qint64> &timestamps = attempts[key];
timestamps.append(now);
while (!timestamps.isEmpty() && timestamps.first() <= now - windowSeconds) {
timestamps.removeFirst();
}
return timestamps.size() > maxAttempts;
}
bool RateLimiter::isBlocked(const QString &key, int maxAttempts, int windowSeconds) const
{
QMutexLocker locker(&mutex);
const qint64 now = QDateTime::currentSecsSinceEpoch();
const auto it = attempts.constFind(key);
if (it == attempts.constEnd()) {
return false;
}
int count = 0;
for (const qint64 &timestamp : it.value()) {
if (timestamp > now - windowSeconds) {
++count;
}
}
return count > maxAttempts;
}
void RateLimiter::clearAttempts(const QString &key)
{
QMutexLocker locker(&mutex);
attempts.remove(key);
}

View File

@ -0,0 +1,25 @@
#ifndef RATELIMITER_H
#define RATELIMITER_H
#include <QList>
#include <QMap>
#include <QMutex>
#include <QString>
/** @brief Thread-safe per-key attempt counter used to throttle abusive requests. */
class RateLimiter
{
public:
/** @brief Record an attempt for the key and report whether the key is now over the limit. */
bool recordAttempt(const QString &key, int maxAttempts, int windowSeconds);
/** @brief True if the key already has more than maxAttempts attempts within windowSeconds. */
bool isBlocked(const QString &key, int maxAttempts, int windowSeconds) const;
/** @brief Drop all recorded attempts for the key, e.g. after a successful login. */
void clearAttempts(const QString &key);
private:
mutable QMutex mutex;
QMap<QString, QList<qint64>> attempts; // key -> attempt timestamps (epoch seconds)
};
#endif

View File

@ -1075,6 +1075,46 @@ int Servatrice::getForgotPasswordTokenLife() const
return settingsCache->value("forgotpassword/tokenlife", 60).toInt();
}
bool Servatrice::recordFailedLogin(const QString &ipAddress)
{
return rateLimiter.recordAttempt("login:" + ipAddress, getMaxLoginAttemptsPerIp(), getLoginAttemptWindowSeconds());
}
void Servatrice::clearFailedLogins(const QString &ipAddress)
{
rateLimiter.clearAttempts("login:" + ipAddress);
}
int Servatrice::getMaxLoginAttemptsPerIp() const
{
return settingsCache->value("security/max_login_attempts_per_ip", 5).toInt();
}
int Servatrice::getLoginAttemptWindowSeconds() const
{
return settingsCache->value("security/login_attempt_window_seconds", 900).toInt();
}
int Servatrice::getMaxRegistrationsPerIp() const
{
return settingsCache->value("security/max_registrations_per_ip", 2).toInt();
}
int Servatrice::getRegistrationWindowSeconds() const
{
return settingsCache->value("security/registration_window_seconds", 3600).toInt();
}
int Servatrice::getMaxForgotPasswordRequestsPerIp() const
{
return settingsCache->value("security/max_forgot_password_requests_per_ip", 3).toInt();
}
int Servatrice::getForgotPasswordWindowSeconds() const
{
return settingsCache->value("security/forgot_password_window_seconds", 3600).toInt();
}
bool Servatrice::getEnableForgotPasswordChallenge() const
{
return settingsCache->value("forgotpassword/enablechallenge", false).toBool();

View File

@ -20,6 +20,8 @@
#ifndef SERVATRICE_H
#define SERVATRICE_H
#include "ratelimiter.h"
#include <QHostAddress>
#include <QMetaType>
#include <QMutex>
@ -172,6 +174,8 @@ private:
int nextShutdownMessageMinutes;
QTimer *shutdownTimer;
RateLimiter rateLimiter;
mutable QMutex serverListMutex;
QList<ServerProperties> serverList;
void updateServerList();
@ -275,6 +279,19 @@ public:
void incRxBytes(quint64 num);
void addDatabaseInterface(QThread *thread, Servatrice_DatabaseInterface *databaseInterface);
RateLimiter *getRateLimiter()
{
return &rateLimiter;
}
bool recordFailedLogin(const QString &ipAddress) override;
void clearFailedLogins(const QString &ipAddress) override;
int getMaxLoginAttemptsPerIp() const;
int getLoginAttemptWindowSeconds() const;
int getMaxRegistrationsPerIp() const;
int getRegistrationWindowSeconds() const;
int getMaxForgotPasswordRequestsPerIp() const;
int getForgotPasswordWindowSeconds() const;
bool islConnectionExists(int _serverId) const;
void addIslInterface(int _serverId, IslInterface *interface);
void removeIslInterface(int _serverId);

View File

@ -1433,9 +1433,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRegisterAccount(const C
bool AbstractServerSocketInterface::tooManyRegistrationAttempts(const QString &ipAddress)
{
//! \todo Implement registration attempt limiting.
Q_UNUSED(ipAddress);
return false;
return servatrice->getRateLimiter()->recordAttempt("register:" + ipAddress, servatrice->getMaxRegistrationsPerIp(),
servatrice->getRegistrationWindowSeconds());
}
Response::ResponseCode AbstractServerSocketInterface::cmdActivateAccount(const Command_Activate &cmd,
@ -1788,6 +1787,17 @@ Response::ResponseCode AbstractServerSocketInterface::cmdForgotPasswordRequest(c
qCDebug(AbstractServerSocketInterfaceLog) << "Received reset password request from user:" << userName;
if (servatrice->getRateLimiter()->recordAttempt("forgot:" + this->getAddress(),
servatrice->getMaxForgotPasswordRequestsPerIp(),
servatrice->getForgotPasswordWindowSeconds())) {
if (servatrice->getEnableForgotPasswordAudit()) {
sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(),
"PASSWORD_RESET_REQUEST", "Too many requests from this ip address", false);
}
return Response::RespTooManyRequests;
}
if (!servatrice->getEnableForgotPassword()) {
if (servatrice->getEnableForgotPasswordAudit()) {
sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(),
@ -1929,6 +1939,16 @@ AbstractServerSocketInterface::cmdForgotPasswordChallenge(const Command_ForgotPa
qCDebug(AbstractServerSocketInterfaceLog) << "Received reset password challenge from user:" << userName;
if (servatrice->getRateLimiter()->recordAttempt("forgot:" + this->getAddress(),
servatrice->getMaxForgotPasswordRequestsPerIp(),
servatrice->getForgotPasswordWindowSeconds())) {
if (servatrice->getEnableForgotPasswordAudit()) {
sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(),
"PASSWORD_RESET_CHALLENGE", "Too many requests from this ip address", false);
}
return Response::RespTooManyRequests;
}
if (!servatrice->getEnableForgotPasswordChallenge()) {
if (servatrice->getEnableForgotPasswordAudit()) {
sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(),

View File

@ -7,6 +7,7 @@ add_test(NAME expression_test COMMAND expression_test)
add_test(NAME clamped_arithmetic_test COMMAND clamped_arithmetic_test)
add_test(NAME test_age_formatting COMMAND test_age_formatting)
add_test(NAME password_hash_test COMMAND password_hash_test)
add_test(NAME rate_limiter_test COMMAND rate_limiter_test)
add_test(NAME server_card_counter_test COMMAND server_card_counter_test)
add_test(NAME server_counter_test COMMAND server_counter_test)
@ -20,6 +21,7 @@ add_executable(expression_test expression_test.cpp)
add_executable(clamped_arithmetic_test clamped_arithmetic_test.cpp)
add_executable(test_age_formatting test_age_formatting.cpp)
add_executable(password_hash_test password_hash_test.cpp)
add_executable(rate_limiter_test ../servatrice/src/ratelimiter.cpp rate_limiter_test.cpp)
add_executable(deck_hash_performance_test deck_hash_performance_test.cpp)
add_executable(server_card_counter_test server_card_counter_test.cpp)
add_executable(server_counter_test server_counter_test.cpp)
@ -54,6 +56,7 @@ if(NOT GTEST_FOUND)
add_dependencies(clamped_arithmetic_test gtest)
add_dependencies(test_age_formatting gtest)
add_dependencies(password_hash_test gtest)
add_dependencies(rate_limiter_test gtest)
add_dependencies(deck_hash_performance_test gtest)
add_dependencies(server_card_counter_test gtest)
add_dependencies(server_counter_test gtest)
@ -71,6 +74,8 @@ target_link_libraries(
target_link_libraries(
password_hash_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)
target_include_directories(rate_limiter_test PRIVATE "${CMAKE_SOURCE_DIR}/servatrice/src")
target_link_libraries(rate_limiter_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES})
target_link_libraries(
deck_hash_performance_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES}
${TEST_QT_MODULES}

View File

@ -0,0 +1,49 @@
#include "ratelimiter.h"
#include "gtest/gtest.h"
namespace
{
TEST(RateLimiterTest, AllowsAttemptsWithinLimit)
{
RateLimiter limiter;
ASSERT_FALSE(limiter.recordAttempt("ip", 3, 60));
ASSERT_FALSE(limiter.recordAttempt("ip", 3, 60));
ASSERT_FALSE(limiter.recordAttempt("ip", 3, 60));
ASSERT_FALSE(limiter.isBlocked("ip", 3, 60));
}
TEST(RateLimiterTest, BlocksAttemptsOverLimit)
{
RateLimiter limiter;
ASSERT_FALSE(limiter.recordAttempt("ip", 2, 60));
ASSERT_FALSE(limiter.recordAttempt("ip", 2, 60));
ASSERT_TRUE(limiter.recordAttempt("ip", 2, 60));
ASSERT_TRUE(limiter.isBlocked("ip", 2, 60));
}
TEST(RateLimiterTest, ClearAttempts)
{
RateLimiter limiter;
ASSERT_TRUE(limiter.recordAttempt("ip", 0, 60));
ASSERT_TRUE(limiter.isBlocked("ip", 0, 60));
limiter.clearAttempts("ip");
ASSERT_FALSE(limiter.isBlocked("ip", 0, 60));
}
TEST(RateLimiterTest, KeysAreIndependent)
{
RateLimiter limiter;
ASSERT_TRUE(limiter.recordAttempt("a", 0, 60));
ASSERT_FALSE(limiter.isBlocked("b", 0, 60));
ASSERT_TRUE(limiter.isBlocked("a", 0, 60));
}
} // namespace
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}