Skip to content

Commit 98fe4b7

Browse files
authored
Add ability to rate limit via Validate class (#2998)
1 parent cdd7d85 commit 98fe4b7

File tree

3 files changed

+70
-6
lines changed

3 files changed

+70
-6
lines changed

Diff for: core/classes/Core/Validate.php

+59-2
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ class Validate {
8181
*/
8282
public const NOT_START_WITH = 'not_start_with';
8383

84+
/**
85+
* @var string Set a rate limit
86+
*/
87+
public const RATE_LIMIT = 'rate_limit';
88+
8489
private DB $_db;
8590

8691
private ?string $_message = null;
@@ -112,6 +117,7 @@ private function __construct() {
112117
* @param array $items subset of inputs to be validated
113118
*
114119
* @return Validate New instance of Validate.
120+
* @throws Exception If provided configuration for a rule is invalid - not if a provided value is invalid!
115121
*/
116122
public static function check(array $source, array $items = []): Validate {
117123
$validator = new Validate();
@@ -318,6 +324,51 @@ public static function check(array $source, array $items = []): Validate {
318324
break;
319325
}
320326
break;
327+
328+
case self::RATE_LIMIT:
329+
if (is_array($rule_value) && count($rule_value) === 2) {
330+
// If array treat as [limit, seconds]
331+
[$limit, $seconds] = $rule_value;
332+
} else if (is_int($rule_value)) {
333+
// If integer default seconds to 60
334+
[$limit, $seconds] = [$rule_value, 60];
335+
}
336+
337+
if (!isset($limit) || !isset($seconds)) {
338+
throw new Exception('Invalid rate limit configuration');
339+
}
340+
341+
$key = "rate_limit_{$item}";
342+
$session = $_SESSION[$key];
343+
$time = date('U');
344+
$limit_end = $time + $seconds;
345+
346+
if (isset($session) && is_array($session) && count($session) === 2) {
347+
[$count, $expires] = $session;
348+
$diff = $expires - $time;
349+
350+
if (++$count >= $limit && $diff > 0) {
351+
$validator->addError([
352+
'field' => $item,
353+
'rule' => self::RATE_LIMIT,
354+
'fallback' => "$item has reached the rate limit which expires in $diff seconds.",
355+
'meta' => ['expires' => $diff],
356+
]);
357+
break;
358+
}
359+
360+
if ($diff <= 0) {
361+
// Reset
362+
$_SESSION[$key] = [1, $limit_end];
363+
break;
364+
}
365+
366+
$_SESSION[$key] = [$count, $expires];
367+
} else {
368+
$_SESSION[$key] = [1, $limit_end];
369+
}
370+
371+
break;
321372
}
322373
}
323374
}
@@ -379,7 +430,7 @@ public function errors(): array {
379430
// Loop all errors to convert and get their custom messages
380431
foreach ($this->_to_convert as $error) {
381432

382-
$message = $this->getMessage($error['field'], $error['rule'], $error['fallback']);
433+
$message = $this->getMessage($error['field'], $error['rule'], $error['fallback'], $error['meta']);
383434

384435
// If there is no generic `message()` set or the translated message is not equal to generic message
385436
// we can continue without worrying about duplications
@@ -409,10 +460,11 @@ public function errors(): array {
409460
* @param string $field name of field to search for.
410461
* @param string $rule rule which check failed. should be from the constants defined above.
411462
* @param string $fallback fallback default message if custom message and generic message are not supplied.
463+
* @param ?array $meta optional meta to provide to message.
412464
*
413465
* @return string Message for this field and rule.
414466
*/
415-
private function getMessage(string $field, string $rule, string $fallback): string {
467+
private function getMessage(string $field, string $rule, string $fallback, ?array $meta = []): string {
416468

417469
// No custom messages defined for this field
418470
if (!isset($this->_messages[$field])) {
@@ -436,6 +488,11 @@ private function getMessage(string $field, string $rule, string $fallback): stri
436488
return $this->_message ?? $fallback;
437489
}
438490

491+
// If the message is a callback function, provide it with meta
492+
if (is_callable($this->_messages[$field][$rule])) {
493+
return $this->_messages[$field][$rule]($meta);
494+
}
495+
439496
// Rule-specific custom message was supplied
440497
return $this->_messages[$field][$rule];
441498
}

Diff for: custom/languages/en_UK.json

+1
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,7 @@
798798
"general/previous": "Previous",
799799
"general/privacy_policy": "Privacy Policy",
800800
"general/profile": "Profile",
801+
"general/rate_limit": "Please try again in {{expires}} seconds",
801802
"general/register": "Register",
802803
"general/remove": "Remove",
803804
"general/report": "Report",

Diff for: modules/Core/pages/login.php

+10-4
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,15 @@
5454
unset($_SESSION['remember'], $_SESSION['password'], $_SESSION['tfa']);
5555
}
5656

57+
$rate_limit = [5, 60]; // 5 attempts in 60 seconds - TODO allow this to be customised?
58+
5759
if ($login_method == 'email') {
5860
$to_validate = [
5961
'email' => [
6062
Validate::REQUIRED => true,
6163
Validate::IS_BANNED => true,
62-
Validate::IS_ACTIVE => true
64+
Validate::IS_ACTIVE => true,
65+
Validate::RATE_LIMIT => $rate_limit,
6366
],
6467
'password' => [
6568
Validate::REQUIRED => true
@@ -70,7 +73,8 @@
7073
'username' => [
7174
Validate::REQUIRED => true,
7275
Validate::IS_BANNED => true,
73-
Validate::IS_ACTIVE => true
76+
Validate::IS_ACTIVE => true,
77+
Validate::RATE_LIMIT => $rate_limit,
7478
],
7579
'password' => [
7680
Validate::REQUIRED => true
@@ -82,12 +86,14 @@
8286
'email' => [
8387
Validate::REQUIRED => $language->get('user', 'must_input_email'),
8488
Validate::IS_BANNED => $language->get('user', 'account_banned'),
85-
Validate::IS_ACTIVE => $language->get('user', 'inactive_account')
89+
Validate::IS_ACTIVE => $language->get('user', 'inactive_account'),
90+
Validate::RATE_LIMIT => fn($meta) => $language->get('general', 'rate_limit', $meta),
8691
],
8792
'username' => [
8893
Validate::REQUIRED => ($login_method == 'username' ? $language->get('user', 'must_input_username') : $language->get('user', 'must_input_email_or_username')),
8994
Validate::IS_BANNED => $language->get('user', 'account_banned'),
90-
Validate::IS_ACTIVE => $language->get('user', 'inactive_account')
95+
Validate::IS_ACTIVE => $language->get('user', 'inactive_account'),
96+
Validate::RATE_LIMIT => fn($meta) => $language->get('general', 'rate_limit', $meta),
9197
],
9298
'password' => $language->get('user', 'must_input_password')
9399
]);

0 commit comments

Comments
 (0)