mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-08-06 02:33:48 -05:00
Merge b2f63255f0 into c7ab75c332
This commit is contained in:
commit
2fc8b5a246
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
42
servatrice/src/ratelimiter.cpp
Normal file
42
servatrice/src/ratelimiter.cpp
Normal 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> ×tamps = 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 ×tamp : it.value()) {
|
||||
if (timestamp > now - windowSeconds) {
|
||||
++count;
|
||||
}
|
||||
}
|
||||
return count > maxAttempts;
|
||||
}
|
||||
|
||||
void RateLimiter::clearAttempts(const QString &key)
|
||||
{
|
||||
QMutexLocker locker(&mutex);
|
||||
attempts.remove(key);
|
||||
}
|
||||
25
servatrice/src/ratelimiter.h
Normal file
25
servatrice/src/ratelimiter.h
Normal 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
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
49
tests/rate_limiter_test.cpp
Normal file
49
tests/rate_limiter_test.cpp
Normal 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();
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user