mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 01:23:24 -08:00
[Server] Inform client of login/registration rate limit timeout (#7468)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [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 * [Server] Fix rate limiter: disable semantics, draining window, bounded keys, pre-work login gate - recordAttempt()/isBlocked() return false when maxAttempts <= 0 or windowSeconds <= 0, so the documented 'set to 0 to disable' actually disables instead of blocking every address permanently - Keys at their limit are rejected without recording, so repeated attempts at the attacker's pace cannot keep sliding the window and holding the lockout open; a block now clears once the recorded attempts age out of windowSeconds - Attempts map is bounded: an opportunistic prune (every 60s) erases keys whose attempts have fully aged out - Login is throttled before the work: isLoginRateLimited() (a new virtual on Server, backed by isBlocked()) gates loginUser(), so a locked-out address no longer burns a database round trip and password verification per attempt - recordAttemptAt()/isBlockedAt() time seams make the sliding-window behaviour deterministically testable; tests rewritten for the real semantics (no reliance on the maxAttempts=0 bug, no sleeps) * [Server] Inform client of login/registration rate limit timeout - Add retryAfterSeconds/retryAfterSecondsAt to RateLimiter to compute seconds until oldest attempt exits the sliding window - Expose loginRateLimitRemainingSeconds via Server/Servatrice - Include denied_end_time (absolute epoch) in RespTooManyRequests for login pre-auth and when transitioning to lockout after failed attempt - Include denied_end_time in registration rate limit response - Client: show retry time for login RespTooManyRequests - Client: simplify registration RespTooManyRequests message to show retry time when provided - Add test for retryAfterSecondsAt * [Client] Restore registration guidance and drop duplicated rate limit reason The registration RespTooManyRequests case now only shows the retry-after copy when the server actually sent a denied_end_time; the per-email account cap path (no end time) falls back to the previous guidance about already having an account and contacting the server operator. Drop the canned denied_reason_str from the new login rate limit branches so the client no longer prints the same sentence twice. * [Server] Exempt trusted_sources from auth rate limits Addresses in security/trusted_sources bypass per-address limits, matching the existing max_users_per_address bypass in initSession. Without this, Webatrice behind local nginx (all clients as 127.0.0.1) would lock out every web user after 5 mistyped passwords, and cap the whole server at 2 registrations / 3 forgot-password requests per window. Adds Servatrice::isRateLimitExempt helper; recordFailedLogin, isLoginRateLimited, recordRegistrationAttempt and recordForgotPasswordAttempt all check it. # Conflicts: # servatrice/src/servatrice.cpp * [Server] Key login rate limit by (address, username) A successful login cleared the whole per-IP failure history, so 4 bad guesses plus 1 self-login (or guest UnknownUser login) from the same address bypassed the limiter. Failures are now bucketed per (address, lowercase username) and a success clears only its own pair. --------- Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
parent
213ce691dd
commit
456d9d9e70
12 changed files with 600 additions and 9 deletions
|
|
@ -239,6 +239,17 @@ void ConnectionController::onLoginError(int r,
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case Response::RespTooManyRequests: {
|
||||||
|
QString bannedStr = endTime ? tr("Too many login attempts. Try again after %1.")
|
||||||
|
.arg(QDateTime::fromSecsSinceEpoch(endTime).toString())
|
||||||
|
: tr("Too many login attempts. Please try again later.");
|
||||||
|
if (!reasonStr.isEmpty()) {
|
||||||
|
bannedStr.append("\n\n" + reasonStr);
|
||||||
|
}
|
||||||
|
QMessageBox::critical(dialogParent, tr("Error"), bannedStr);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case Response::RespWrongPassword: {
|
case Response::RespWrongPassword: {
|
||||||
QMessageBox::critical(dialogParent, tr("Error"),
|
QMessageBox::critical(dialogParent, tr("Error"),
|
||||||
tr("Incorrect username or password. "
|
tr("Incorrect username or password. "
|
||||||
|
|
@ -365,12 +376,15 @@ void ConnectionController::onRegisterError(int r, QString reasonStr, quint32 end
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case Response::RespTooManyRequests: {
|
case Response::RespTooManyRequests: {
|
||||||
QMessageBox::critical(dialogParent, tr("Registration denied"),
|
QString deniedStr =
|
||||||
tr("It appears you are attempting to register a new account on this server yet you "
|
endTime ? tr("Too many registration attempts. Try again after %1.")
|
||||||
|
.arg(QDateTime::fromSecsSinceEpoch(endTime).toString())
|
||||||
|
: tr("It appears you are attempting to register a new account on this server yet you "
|
||||||
"already have an account registered with the email provided. This server "
|
"already have an account registered with the email provided. This server "
|
||||||
"restricts the number of accounts a user can register per address. Please "
|
"restricts the number of accounts a user can register per address. Please "
|
||||||
"contact the server operator for further assistance or to obtain your "
|
"contact the server operator for further assistance or to obtain your "
|
||||||
"credential information."));
|
"credential information.");
|
||||||
|
QMessageBox::critical(dialogParent, tr("Registration denied"), deniedStr);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case Response::RespPasswordTooShort: {
|
case Response::RespPasswordTooShort: {
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,39 @@ public:
|
||||||
virtual void observeGameStartDurationMs(qint64 /* elapsedMs */)
|
virtual void observeGameStartDurationMs(qint64 /* elapsedMs */)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* @brief Record a failed login attempt for the (address, username) pair; returns true if now locked out.
|
||||||
|
*
|
||||||
|
* Keyed by pair so a successful login as one user cannot wipe out another
|
||||||
|
* user's failure history from the same address (e.g. a guest login or the
|
||||||
|
* attacker's own account clearing the way for further guessing).
|
||||||
|
*/
|
||||||
|
virtual bool recordFailedLogin(const QString & /*ipAddress*/, const QString & /*userName*/)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @brief True if the (address, username) pair is already locked out of logging in.
|
||||||
|
*
|
||||||
|
* Consulted before any authentication work (database round trip, password
|
||||||
|
* verification) so a blocked pair cannot burn server CPU per attempt.
|
||||||
|
*/
|
||||||
|
virtual bool isLoginRateLimited(const QString & /*ipAddress*/, const QString & /*userName*/)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @brief Seconds remaining before the login rate limit for the (address, username) pair lifts.
|
||||||
|
* Returns 0 if not limited.
|
||||||
|
*/
|
||||||
|
virtual int loginRateLimitRemainingSeconds(const QString & /*ipAddress*/, const QString & /*userName*/)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
/** @brief Clear any failed-login lockout for the (address, username) pair, e.g. after a successful login. */
|
||||||
|
virtual void clearFailedLogins(const QString & /*ipAddress*/, const QString & /*userName*/)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
Server_DatabaseInterface *getDatabaseInterface() const;
|
Server_DatabaseInterface *getDatabaseInterface() const;
|
||||||
int getNextLocalGameId()
|
int getNextLocalGameId()
|
||||||
|
|
|
||||||
|
|
@ -592,6 +592,17 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
|
||||||
QString reasonStr;
|
QString reasonStr;
|
||||||
int banSecondsLeft = 0;
|
int banSecondsLeft = 0;
|
||||||
QString connectionType = getConnectionType();
|
QString connectionType = getConnectionType();
|
||||||
|
// Throttle before doing any work: a locked-out address must not get a full
|
||||||
|
// database round trip and password verification on every attempt.
|
||||||
|
if (server->isLoginRateLimited(getAddress(), userName)) {
|
||||||
|
int remaining = server->loginRateLimitRemainingSeconds(getAddress(), userName);
|
||||||
|
if (remaining > 0) {
|
||||||
|
auto *re = new Response_Login;
|
||||||
|
re->set_denied_end_time(QDateTime::currentDateTime().addSecs(remaining).toSecsSinceEpoch());
|
||||||
|
rc.setResponseExtension(re);
|
||||||
|
}
|
||||||
|
return Response::RespTooManyRequests;
|
||||||
|
}
|
||||||
AuthenticationResult res = server->loginUser(this, userName, password, needsHash, reasonStr, banSecondsLeft,
|
AuthenticationResult res = server->loginUser(this, userName, password, needsHash, reasonStr, banSecondsLeft,
|
||||||
clientId, clientVersion, connectionType);
|
clientId, clientVersion, connectionType);
|
||||||
switch (res) {
|
switch (res) {
|
||||||
|
|
@ -605,10 +616,20 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
|
||||||
return Response::RespUserIsBanned;
|
return Response::RespUserIsBanned;
|
||||||
}
|
}
|
||||||
case NotLoggedIn:
|
case NotLoggedIn:
|
||||||
|
if (server->recordFailedLogin(getAddress(), userName)) {
|
||||||
|
int remaining = server->loginRateLimitRemainingSeconds(getAddress(), userName);
|
||||||
|
if (remaining > 0) {
|
||||||
|
auto *re = new Response_Login;
|
||||||
|
re->set_denied_end_time(QDateTime::currentDateTime().addSecs(remaining).toSecsSinceEpoch());
|
||||||
|
rc.setResponseExtension(re);
|
||||||
|
}
|
||||||
|
return Response::RespTooManyRequests;
|
||||||
|
}
|
||||||
return Response::RespWrongPassword;
|
return Response::RespWrongPassword;
|
||||||
case WouldOverwriteOldSession:
|
case WouldOverwriteOldSession:
|
||||||
return Response::RespWouldOverwriteOldSession;
|
return Response::RespWouldOverwriteOldSession;
|
||||||
case UsernameInvalid: {
|
case UsernameInvalid: {
|
||||||
|
server->recordFailedLogin(getAddress(), userName);
|
||||||
auto *re = new Response_Login;
|
auto *re = new Response_Login;
|
||||||
re->set_denied_reason_str(reasonStr.toStdString());
|
re->set_denied_reason_str(reasonStr.toStdString());
|
||||||
rc.setResponseExtension(re);
|
rc.setResponseExtension(re);
|
||||||
|
|
@ -619,10 +640,12 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
|
||||||
case ClientIdRequired:
|
case ClientIdRequired:
|
||||||
return Response::RespClientIdRequired;
|
return Response::RespClientIdRequired;
|
||||||
case UserIsInactive:
|
case UserIsInactive:
|
||||||
|
server->recordFailedLogin(getAddress(), userName);
|
||||||
return Response::RespAccountNotActivated;
|
return Response::RespAccountNotActivated;
|
||||||
case PasswordChangeRequired:
|
case PasswordChangeRequired:
|
||||||
return Response::RespPasswordChangeRequired;
|
return Response::RespPasswordChangeRequired;
|
||||||
default:
|
default:
|
||||||
|
server->clearFailedLogins(getAddress(), userName);
|
||||||
authState = res;
|
authState = res;
|
||||||
usingRealPassword = needsHash;
|
usingRealPassword = needsHash;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ set(servatrice_SOURCES
|
||||||
src/event_loop_watchdog.cpp
|
src/event_loop_watchdog.cpp
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/metrics_registry.cpp
|
src/metrics_registry.cpp
|
||||||
|
src/ratelimiter.cpp
|
||||||
src/servatrice.cpp
|
src/servatrice.cpp
|
||||||
src/servatrice_connection_pool.cpp
|
src/servatrice_connection_pool.cpp
|
||||||
src/servatrice_database_interface.cpp
|
src/servatrice_database_interface.cpp
|
||||||
|
|
|
||||||
|
|
@ -366,6 +366,27 @@ max_users_websocket=500
|
||||||
; Maximum number of users that can connect from the same IP address; useful to avoid bots, default is 4
|
; Maximum number of users that can connect from the same IP address; useful to avoid bots, default is 4
|
||||||
max_users_per_address=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
|
; 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
|
; 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"
|
; 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"
|
||||||
|
|
|
||||||
125
servatrice/src/ratelimiter.cpp
Normal file
125
servatrice/src/ratelimiter.cpp
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
#include "ratelimiter.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
|
||||||
|
bool RateLimiter::recordAttempt(const QString &key, int maxAttempts, int windowSeconds)
|
||||||
|
{
|
||||||
|
return recordAttemptAt(key, maxAttempts, windowSeconds, QDateTime::currentSecsSinceEpoch());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RateLimiter::recordAttemptAt(const QString &key, int maxAttempts, int windowSeconds, qint64 now)
|
||||||
|
{
|
||||||
|
if (maxAttempts <= 0 || windowSeconds <= 0) {
|
||||||
|
return false; // rate limiting disabled for this endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
QMutexLocker locker(&mutex);
|
||||||
|
pruneLocked(now);
|
||||||
|
|
||||||
|
AttemptHistory &history = attempts[key];
|
||||||
|
history.windowSeconds = windowSeconds;
|
||||||
|
QList<qint64> ×tamps = history.timestamps;
|
||||||
|
while (!timestamps.isEmpty() && timestamps.first() <= now - windowSeconds) {
|
||||||
|
timestamps.removeFirst();
|
||||||
|
}
|
||||||
|
if (timestamps.size() >= maxAttempts) {
|
||||||
|
// Already at the limit: reject without appending so repeated attempts
|
||||||
|
// at the attacker's own pace cannot keep sliding the window forward
|
||||||
|
// and hold the lockout open forever.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamps.append(now);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RateLimiter::isBlocked(const QString &key, int maxAttempts, int windowSeconds) const
|
||||||
|
{
|
||||||
|
return isBlockedAt(key, maxAttempts, windowSeconds, QDateTime::currentSecsSinceEpoch());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RateLimiter::isBlockedAt(const QString &key, int maxAttempts, int windowSeconds, qint64 now) const
|
||||||
|
{
|
||||||
|
if (maxAttempts <= 0 || windowSeconds <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QMutexLocker locker(&mutex);
|
||||||
|
|
||||||
|
const auto it = attempts.constFind(key);
|
||||||
|
if (it == attempts.constEnd()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
for (const qint64 ×tamp : it.value().timestamps) {
|
||||||
|
if (timestamp > now - windowSeconds) {
|
||||||
|
++count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count >= maxAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RateLimiter::clearAttempts(const QString &key)
|
||||||
|
{
|
||||||
|
QMutexLocker locker(&mutex);
|
||||||
|
attempts.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
int RateLimiter::retryAfterSeconds(const QString &key, int maxAttempts, int windowSeconds) const
|
||||||
|
{
|
||||||
|
return retryAfterSecondsAt(key, maxAttempts, windowSeconds, QDateTime::currentSecsSinceEpoch());
|
||||||
|
}
|
||||||
|
|
||||||
|
int RateLimiter::retryAfterSecondsAt(const QString &key, int maxAttempts, int windowSeconds, qint64 now) const
|
||||||
|
{
|
||||||
|
if (maxAttempts <= 0 || windowSeconds <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QMutexLocker locker(&mutex);
|
||||||
|
|
||||||
|
const auto it = attempts.constFind(key);
|
||||||
|
if (it == attempts.constEnd()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<qint64> timestamps = it.value().timestamps;
|
||||||
|
while (!timestamps.isEmpty() && timestamps.first() <= now - windowSeconds) {
|
||||||
|
timestamps.removeFirst();
|
||||||
|
}
|
||||||
|
if (timestamps.size() < maxAttempts) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (timestamps.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
qint64 oldest = timestamps.first();
|
||||||
|
qint64 retryAt = oldest + windowSeconds;
|
||||||
|
if (retryAt <= now) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return static_cast<int>(retryAt - now);
|
||||||
|
}
|
||||||
|
|
||||||
|
void RateLimiter::pruneLocked(qint64 now)
|
||||||
|
{
|
||||||
|
if (lastPruneSecs + PruneIntervalSeconds > now) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastPruneSecs = now;
|
||||||
|
|
||||||
|
auto it = attempts.begin();
|
||||||
|
while (it != attempts.end()) {
|
||||||
|
QList<qint64> ×tamps = it.value().timestamps;
|
||||||
|
while (!timestamps.isEmpty() && timestamps.first() <= now - it.value().windowSeconds) {
|
||||||
|
timestamps.removeFirst();
|
||||||
|
}
|
||||||
|
if (timestamps.isEmpty()) {
|
||||||
|
it = attempts.erase(it);
|
||||||
|
} else {
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
servatrice/src/ratelimiter.h
Normal file
93
servatrice/src/ratelimiter.h
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
#ifndef RATELIMITER_H
|
||||||
|
#define RATELIMITER_H
|
||||||
|
|
||||||
|
#include <QList>
|
||||||
|
#include <QMap>
|
||||||
|
#include <QMutex>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
/** @brief Thread-safe per-key attempt limiter used to throttle abusive requests. */
|
||||||
|
class RateLimiter
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* @brief Record an attempt for @p key and report whether the key is now blocked.
|
||||||
|
*
|
||||||
|
* A key that is already at its limit is not extended: the attempt is
|
||||||
|
* rejected without appending, so the lockout lifts once the recorded
|
||||||
|
* attempts age out of the window. Returns false immediately when
|
||||||
|
* @p maxAttempts <= 0 or @p windowSeconds <= 0 (rate limiting disabled),
|
||||||
|
* recording nothing.
|
||||||
|
*
|
||||||
|
* Delegates to recordAttemptAt() with the current wall-clock time.
|
||||||
|
*/
|
||||||
|
bool recordAttempt(const QString &key, int maxAttempts, int windowSeconds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Record an attempt as seen at epoch second @p now.
|
||||||
|
*
|
||||||
|
* Exposed for deterministic tests; production callers should use
|
||||||
|
* recordAttempt().
|
||||||
|
*/
|
||||||
|
bool recordAttemptAt(const QString &key, int maxAttempts, int windowSeconds, qint64 now);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief True if @p key already has at least @p maxAttempts attempts within @p windowSeconds.
|
||||||
|
*
|
||||||
|
* Reads only; records nothing. Returns false when @p maxAttempts <= 0 or
|
||||||
|
* @p windowSeconds <= 0. Delegates to isBlockedAt().
|
||||||
|
*/
|
||||||
|
bool isBlocked(const QString &key, int maxAttempts, int windowSeconds) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Whether @p key is blocked as measured at epoch second @p now.
|
||||||
|
*
|
||||||
|
* Exposed for deterministic tests; production callers should use
|
||||||
|
* isBlocked().
|
||||||
|
*/
|
||||||
|
bool isBlockedAt(const QString &key, int maxAttempts, int windowSeconds, qint64 now) const;
|
||||||
|
|
||||||
|
/** @brief Drop all recorded attempts for the key, e.g. after a successful login. */
|
||||||
|
void clearAttempts(const QString &key);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Get the retry-after window in seconds for @p key.
|
||||||
|
*
|
||||||
|
* Returns the number of seconds from @p now until the oldest attempt
|
||||||
|
* exits @p windowSeconds and the key is no longer blocked. If the key is
|
||||||
|
* not blocked, returns 0. Returns 0 if @p maxAttempts <= 0 or
|
||||||
|
* @p windowSeconds <= 0.
|
||||||
|
*
|
||||||
|
* Delegates to retryAfterSecondsAt().
|
||||||
|
*/
|
||||||
|
int retryAfterSeconds(const QString &key, int maxAttempts, int windowSeconds) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Get the retry-after window in seconds for @p key as measured at @p now.
|
||||||
|
*
|
||||||
|
* Exposed for deterministic tests.
|
||||||
|
*/
|
||||||
|
int retryAfterSecondsAt(const QString &key, int maxAttempts, int windowSeconds, qint64 now) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct AttemptHistory
|
||||||
|
{
|
||||||
|
QList<qint64> timestamps; // epoch seconds, oldest first
|
||||||
|
int windowSeconds;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Drops keys whose last attempts have fully aged out of their window.
|
||||||
|
*
|
||||||
|
* Runs at most every PruneIntervalSeconds to stop the map from growing
|
||||||
|
* without bound when attackers rotate source addresses.
|
||||||
|
*/
|
||||||
|
void pruneLocked(qint64 now);
|
||||||
|
|
||||||
|
mutable QMutex mutex;
|
||||||
|
QMap<QString, AttemptHistory> attempts; // key -> attempt history
|
||||||
|
qint64 lastPruneSecs = 0;
|
||||||
|
static constexpr int PruneIntervalSeconds = 60;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -51,6 +51,7 @@
|
||||||
#include <QSsl>
|
#include <QSsl>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
|
#include <QStringLiteral>
|
||||||
#include <QThread>
|
#include <QThread>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QTypeInfo>
|
#include <QTypeInfo>
|
||||||
|
|
@ -1286,6 +1287,105 @@ int Servatrice::getForgotPasswordTokenLife() const
|
||||||
return settingsCache->value("forgotpassword/tokenlife", 60).toInt();
|
return settingsCache->value("forgotpassword/tokenlife", 60).toInt();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Rate limiter key for one login target.
|
||||||
|
*
|
||||||
|
* Keyed by (address, username) so a success only clears that pair's history:
|
||||||
|
* a guest login or a login as the attacker's own account from the same
|
||||||
|
* address must not wipe out another user's failure count. Lowercased so
|
||||||
|
* case variations of the same name share one bucket.
|
||||||
|
*/
|
||||||
|
static QString loginLimitKey(const QString &ipAddress, const QString &userName)
|
||||||
|
{
|
||||||
|
return QStringLiteral("login:") + ipAddress + QStringLiteral(":") + userName.toLower();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Servatrice::recordFailedLogin(const QString &ipAddress, const QString &userName)
|
||||||
|
{
|
||||||
|
if (isRateLimitExempt(ipAddress)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return rateLimiter.recordAttempt(loginLimitKey(ipAddress, userName), getMaxLoginAttemptsPerIp(),
|
||||||
|
getLoginAttemptWindowSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Servatrice::isLoginRateLimited(const QString &ipAddress, const QString &userName)
|
||||||
|
{
|
||||||
|
if (isRateLimitExempt(ipAddress)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return rateLimiter.isBlocked(loginLimitKey(ipAddress, userName), getMaxLoginAttemptsPerIp(),
|
||||||
|
getLoginAttemptWindowSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
int Servatrice::loginRateLimitRemainingSeconds(const QString &ipAddress, const QString &userName)
|
||||||
|
{
|
||||||
|
return rateLimiter.retryAfterSeconds(loginLimitKey(ipAddress, userName), getMaxLoginAttemptsPerIp(),
|
||||||
|
getLoginAttemptWindowSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Servatrice::recordRegistrationAttempt(const QString &ipAddress)
|
||||||
|
{
|
||||||
|
if (isRateLimitExempt(ipAddress)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return rateLimiter.recordAttempt("register:" + ipAddress, getMaxRegistrationsPerIp(),
|
||||||
|
getRegistrationWindowSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Servatrice::recordForgotPasswordAttempt(const QString &ipAddress)
|
||||||
|
{
|
||||||
|
if (isRateLimitExempt(ipAddress)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return rateLimiter.recordAttempt("forgot:" + ipAddress, getMaxForgotPasswordRequestsPerIp(),
|
||||||
|
getForgotPasswordWindowSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Servatrice::isRateLimitExempt(const QString &ipAddress) const
|
||||||
|
{
|
||||||
|
// Mirrors the max_users_per_address bypass in initSession: trusted sources
|
||||||
|
// (e.g. 127.0.0.1 for Webatrice behind local nginx, where every websocket
|
||||||
|
// client shares one address) are not subject to per-address limits.
|
||||||
|
const QString trustedSources = settingsCache->value("security/trusted_sources", "127.0.0.1,::1").toString();
|
||||||
|
return trustedSources.contains(ipAddress, Qt::CaseInsensitive);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Servatrice::clearFailedLogins(const QString &ipAddress, const QString &userName)
|
||||||
|
{
|
||||||
|
rateLimiter.clearAttempts(loginLimitKey(ipAddress, userName));
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
bool Servatrice::getEnableForgotPasswordChallenge() const
|
||||||
{
|
{
|
||||||
return settingsCache->value("forgotpassword/enablechallenge", false).toBool();
|
return settingsCache->value("forgotpassword/enablechallenge", false).toBool();
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
#define SERVATRICE_H
|
#define SERVATRICE_H
|
||||||
|
|
||||||
#include "metrics_registry.h"
|
#include "metrics_registry.h"
|
||||||
|
#include "ratelimiter.h"
|
||||||
#include "smtpclient.h"
|
#include "smtpclient.h"
|
||||||
|
|
||||||
#include <QByteArray>
|
#include <QByteArray>
|
||||||
|
|
@ -204,6 +205,8 @@ private:
|
||||||
std::shared_ptr<const Response_ReportStats> reportStatsCache;
|
std::shared_ptr<const Response_ReportStats> reportStatsCache;
|
||||||
static constexpr int reportStatsCacheTtlSeconds = 60;
|
static constexpr int reportStatsCacheTtlSeconds = 60;
|
||||||
|
|
||||||
|
RateLimiter rateLimiter;
|
||||||
|
|
||||||
mutable QMutex serverListMutex;
|
mutable QMutex serverListMutex;
|
||||||
QList<ServerProperties> serverList;
|
QList<ServerProperties> serverList;
|
||||||
void updateServerList();
|
void updateServerList();
|
||||||
|
|
@ -362,6 +365,25 @@ public:
|
||||||
*/
|
*/
|
||||||
void watchWorkerThread(QThread *thread);
|
void watchWorkerThread(QThread *thread);
|
||||||
|
|
||||||
|
RateLimiter *getRateLimiter()
|
||||||
|
{
|
||||||
|
return &rateLimiter;
|
||||||
|
}
|
||||||
|
bool recordFailedLogin(const QString &ipAddress, const QString &userName) override;
|
||||||
|
bool isLoginRateLimited(const QString &ipAddress, const QString &userName) override;
|
||||||
|
int loginRateLimitRemainingSeconds(const QString &ipAddress, const QString &userName) override;
|
||||||
|
void clearFailedLogins(const QString &ipAddress, const QString &userName) override;
|
||||||
|
bool recordRegistrationAttempt(const QString &ipAddress);
|
||||||
|
bool recordForgotPasswordAttempt(const QString &ipAddress);
|
||||||
|
/// True when the address is in security/trusted_sources and therefore exempt from per-address limits.
|
||||||
|
bool isRateLimitExempt(const QString &ipAddress) const;
|
||||||
|
int getMaxLoginAttemptsPerIp() const;
|
||||||
|
int getLoginAttemptWindowSeconds() const;
|
||||||
|
int getMaxRegistrationsPerIp() const;
|
||||||
|
int getRegistrationWindowSeconds() const;
|
||||||
|
int getMaxForgotPasswordRequestsPerIp() const;
|
||||||
|
int getForgotPasswordWindowSeconds() const;
|
||||||
|
|
||||||
bool islConnectionExists(int _serverId) const;
|
bool islConnectionExists(int _serverId) const;
|
||||||
void addIslInterface(int _serverId, IslInterface *interface);
|
void addIslInterface(int _serverId, IslInterface *interface);
|
||||||
void removeIslInterface(int _serverId);
|
void removeIslInterface(int _serverId);
|
||||||
|
|
|
||||||
|
|
@ -3263,6 +3263,15 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRegisterAccount(const C
|
||||||
false);
|
false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Response_Register *re = new Response_Register;
|
||||||
|
re->set_denied_reason_str("Too many registration attempts");
|
||||||
|
int remaining = servatrice->getRateLimiter()->retryAfterSeconds("register:" + this->getAddress(),
|
||||||
|
servatrice->getMaxRegistrationsPerIp(),
|
||||||
|
servatrice->getRegistrationWindowSeconds());
|
||||||
|
if (remaining > 0) {
|
||||||
|
re->set_denied_end_time(QDateTime::currentDateTime().addSecs(remaining).toSecsSinceEpoch());
|
||||||
|
}
|
||||||
|
rc.setResponseExtension(re);
|
||||||
return Response::RespTooManyRequests;
|
return Response::RespTooManyRequests;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3330,9 +3339,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRegisterAccount(const C
|
||||||
|
|
||||||
bool AbstractServerSocketInterface::tooManyRegistrationAttempts(const QString &ipAddress)
|
bool AbstractServerSocketInterface::tooManyRegistrationAttempts(const QString &ipAddress)
|
||||||
{
|
{
|
||||||
//! \todo Implement registration attempt limiting.
|
return servatrice->recordRegistrationAttempt(ipAddress);
|
||||||
Q_UNUSED(ipAddress);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Response::ResponseCode AbstractServerSocketInterface::cmdActivateAccount(const Command_Activate &cmd,
|
Response::ResponseCode AbstractServerSocketInterface::cmdActivateAccount(const Command_Activate &cmd,
|
||||||
|
|
@ -3687,6 +3694,15 @@ Response::ResponseCode AbstractServerSocketInterface::cmdForgotPasswordRequest(c
|
||||||
|
|
||||||
qCDebug(AbstractServerSocketInterfaceLog) << "Received reset password request from user:" << userName;
|
qCDebug(AbstractServerSocketInterfaceLog) << "Received reset password request from user:" << userName;
|
||||||
|
|
||||||
|
if (servatrice->recordForgotPasswordAttempt(this->getAddress())) {
|
||||||
|
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->getEnableForgotPassword()) {
|
||||||
if (servatrice->getEnableForgotPasswordAudit()) {
|
if (servatrice->getEnableForgotPasswordAudit()) {
|
||||||
sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(),
|
sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(),
|
||||||
|
|
@ -3829,6 +3845,14 @@ AbstractServerSocketInterface::cmdForgotPasswordChallenge(const Command_ForgotPa
|
||||||
|
|
||||||
qCDebug(AbstractServerSocketInterfaceLog) << "Received reset password challenge from user:" << userName;
|
qCDebug(AbstractServerSocketInterfaceLog) << "Received reset password challenge from user:" << userName;
|
||||||
|
|
||||||
|
if (servatrice->recordForgotPasswordAttempt(this->getAddress())) {
|
||||||
|
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->getEnableForgotPasswordChallenge()) {
|
||||||
if (servatrice->getEnableForgotPasswordAudit()) {
|
if (servatrice->getEnableForgotPasswordAudit()) {
|
||||||
sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(),
|
sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(),
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,17 @@ cockatrice_add_test(
|
||||||
LABELS
|
LABELS
|
||||||
server
|
server
|
||||||
)
|
)
|
||||||
|
cockatrice_add_test(
|
||||||
|
NAME
|
||||||
|
rate_limiter_test
|
||||||
|
SOURCES
|
||||||
|
${CMAKE_SOURCE_DIR}/servatrice/src/ratelimiter.cpp
|
||||||
|
rate_limiter_test.cpp
|
||||||
|
INCLUDE_DIRS
|
||||||
|
${CMAKE_SOURCE_DIR}/servatrice/src
|
||||||
|
LABELS
|
||||||
|
server
|
||||||
|
)
|
||||||
cockatrice_add_test(
|
cockatrice_add_test(
|
||||||
NAME
|
NAME
|
||||||
server_card_counter_test
|
server_card_counter_test
|
||||||
|
|
|
||||||
124
tests/rate_limiter_test.cpp
Normal file
124
tests/rate_limiter_test.cpp
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
#include "ratelimiter.h"
|
||||||
|
|
||||||
|
#include "gtest/gtest.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
TEST(RateLimiterTest, AllowsAttemptsWithinLimit)
|
||||||
|
{
|
||||||
|
RateLimiter limiter;
|
||||||
|
ASSERT_FALSE(limiter.recordAttemptAt("ip", 3, 60, 1000));
|
||||||
|
ASSERT_FALSE(limiter.recordAttemptAt("ip", 3, 60, 1001));
|
||||||
|
// Still under the limit after two attempts, so the key is not blocked...
|
||||||
|
ASSERT_FALSE(limiter.isBlockedAt("ip", 3, 60, 1002));
|
||||||
|
// ...and the third (the limit) is allowed through.
|
||||||
|
ASSERT_FALSE(limiter.recordAttemptAt("ip", 3, 60, 1002));
|
||||||
|
// The next attempt is blocked once three are on the books.
|
||||||
|
ASSERT_TRUE(limiter.isBlockedAt("ip", 3, 60, 1003));
|
||||||
|
ASSERT_TRUE(limiter.recordAttemptAt("ip", 3, 60, 1003));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(RateLimiterTest, BlocksAttemptsAtTheLimit)
|
||||||
|
{
|
||||||
|
RateLimiter limiter;
|
||||||
|
ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, 60, 1000));
|
||||||
|
ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, 60, 1001));
|
||||||
|
ASSERT_TRUE(limiter.recordAttemptAt("ip", 2, 60, 1002));
|
||||||
|
ASSERT_TRUE(limiter.isBlockedAt("ip", 2, 60, 1002));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(RateLimiterTest, NonPositiveConfigDisablesLimiting)
|
||||||
|
{
|
||||||
|
RateLimiter limiter;
|
||||||
|
// maxAttempts = 0 is the documented "set to 0 to disable"; negative or
|
||||||
|
// zero windows are config mistakes and must not lock everyone out either.
|
||||||
|
ASSERT_FALSE(limiter.recordAttempt("ip", 0, 60));
|
||||||
|
ASSERT_FALSE(limiter.isBlocked("ip", 0, 60));
|
||||||
|
ASSERT_FALSE(limiter.recordAttempt("ip", -1, 60));
|
||||||
|
ASSERT_FALSE(limiter.isBlocked("ip", -1, 60));
|
||||||
|
ASSERT_FALSE(limiter.recordAttempt("ip", 5, 0));
|
||||||
|
ASSERT_FALSE(limiter.isBlocked("ip", 5, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(RateLimiterTest, ClearAttempts)
|
||||||
|
{
|
||||||
|
RateLimiter limiter;
|
||||||
|
ASSERT_FALSE(limiter.recordAttemptAt("ip", 1, 60, 1000));
|
||||||
|
ASSERT_TRUE(limiter.recordAttemptAt("ip", 1, 60, 1001));
|
||||||
|
ASSERT_TRUE(limiter.isBlockedAt("ip", 1, 60, 1001));
|
||||||
|
limiter.clearAttempts("ip");
|
||||||
|
ASSERT_FALSE(limiter.isBlockedAt("ip", 1, 60, 1002));
|
||||||
|
// The cleared key starts fresh and is allowed again.
|
||||||
|
ASSERT_FALSE(limiter.recordAttemptAt("ip", 1, 60, 1002));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(RateLimiterTest, KeysAreIndependent)
|
||||||
|
{
|
||||||
|
RateLimiter limiter;
|
||||||
|
ASSERT_FALSE(limiter.recordAttemptAt("a", 1, 60, 1000));
|
||||||
|
ASSERT_TRUE(limiter.recordAttemptAt("a", 1, 60, 1001));
|
||||||
|
ASSERT_FALSE(limiter.isBlockedAt("b", 1, 60, 1001));
|
||||||
|
ASSERT_TRUE(limiter.isBlockedAt("a", 1, 60, 1001));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(RateLimiterTest, AttemptsAgeOutOfTheWindow)
|
||||||
|
{
|
||||||
|
RateLimiter limiter;
|
||||||
|
const qint64 t = 1000;
|
||||||
|
const int window = 60;
|
||||||
|
|
||||||
|
ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, window, t));
|
||||||
|
ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, window, t + 1));
|
||||||
|
ASSERT_TRUE(limiter.recordAttemptAt("ip", 2, window, t + 2));
|
||||||
|
ASSERT_TRUE(limiter.isBlockedAt("ip", 2, window, t + 2));
|
||||||
|
|
||||||
|
// A blocked attempt is rejected without recording, so it cannot extend
|
||||||
|
// the lockout either.
|
||||||
|
ASSERT_TRUE(limiter.recordAttemptAt("ip", 2, window, t + 3));
|
||||||
|
|
||||||
|
// Once the recorded attempts fall outside the window the key clears by
|
||||||
|
// itself, with no manual unlock.
|
||||||
|
ASSERT_FALSE(limiter.isBlockedAt("ip", 2, window, t + 61));
|
||||||
|
ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, window, t + 62));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(RateLimiterTest, BlockedKeyIsUnlockedAfterSuccessfulClear)
|
||||||
|
{
|
||||||
|
RateLimiter limiter;
|
||||||
|
const qint64 t = 5000;
|
||||||
|
|
||||||
|
ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, 60, t));
|
||||||
|
ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, 60, t + 1));
|
||||||
|
ASSERT_TRUE(limiter.recordAttemptAt("ip", 2, 60, t + 2));
|
||||||
|
ASSERT_TRUE(limiter.isBlockedAt("ip", 2, 60, t + 2));
|
||||||
|
|
||||||
|
limiter.clearAttempts("ip");
|
||||||
|
ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, 60, t + 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(RateLimiterTest, RetryAfterSecondsReportsTimeUntilUnlock)
|
||||||
|
{
|
||||||
|
RateLimiter limiter;
|
||||||
|
const qint64 t = 1000;
|
||||||
|
const int window = 60;
|
||||||
|
|
||||||
|
ASSERT_EQ(0, limiter.retryAfterSecondsAt("ip", 2, window, t));
|
||||||
|
ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, window, t));
|
||||||
|
ASSERT_EQ(0, limiter.retryAfterSecondsAt("ip", 2, window, t));
|
||||||
|
ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, window, t + 1));
|
||||||
|
ASSERT_TRUE(limiter.isBlockedAt("ip", 2, window, t + 2));
|
||||||
|
// Oldest attempt was at t, window 60, so retry at t+60; now is t+2 => 58.
|
||||||
|
ASSERT_EQ(58, limiter.retryAfterSecondsAt("ip", 2, window, t + 2));
|
||||||
|
// At t+60 the window just elapsed; retry should return 0.
|
||||||
|
ASSERT_EQ(0, limiter.retryAfterSecondsAt("ip", 2, window, t + 60));
|
||||||
|
ASSERT_EQ(0, limiter.retryAfterSecondsAt("ip", 2, window, t + 61));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main(int argc, char **argv)
|
||||||
|
{
|
||||||
|
::testing::InitGoogleTest(&argc, argv);
|
||||||
|
return RUN_ALL_TESTS();
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue