From 98fe4b7fce5509e49e71f1357118db887b8b88e0 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 7 Aug 2022 20:32:07 +0100 Subject: [PATCH] Add ability to rate limit via Validate class (#2998) --- core/classes/Core/Validate.php | 61 ++++++++++++++++++++++++++++++++-- custom/languages/en_UK.json | 1 + modules/Core/pages/login.php | 14 +++++--- 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/core/classes/Core/Validate.php b/core/classes/Core/Validate.php index b7e3f57fae..fdb5679bfb 100644 --- a/core/classes/Core/Validate.php +++ b/core/classes/Core/Validate.php @@ -81,6 +81,11 @@ class Validate { */ public const NOT_START_WITH = 'not_start_with'; + /** + * @var string Set a rate limit + */ + public const RATE_LIMIT = 'rate_limit'; + private DB $_db; private ?string $_message = null; @@ -112,6 +117,7 @@ private function __construct() { * @param array $items subset of inputs to be validated * * @return Validate New instance of Validate. + * @throws Exception If provided configuration for a rule is invalid - not if a provided value is invalid! */ public static function check(array $source, array $items = []): Validate { $validator = new Validate(); @@ -318,6 +324,51 @@ public static function check(array $source, array $items = []): Validate { break; } break; + + case self::RATE_LIMIT: + if (is_array($rule_value) && count($rule_value) === 2) { + // If array treat as [limit, seconds] + [$limit, $seconds] = $rule_value; + } else if (is_int($rule_value)) { + // If integer default seconds to 60 + [$limit, $seconds] = [$rule_value, 60]; + } + + if (!isset($limit) || !isset($seconds)) { + throw new Exception('Invalid rate limit configuration'); + } + + $key = "rate_limit_{$item}"; + $session = $_SESSION[$key]; + $time = date('U'); + $limit_end = $time + $seconds; + + if (isset($session) && is_array($session) && count($session) === 2) { + [$count, $expires] = $session; + $diff = $expires - $time; + + if (++$count >= $limit && $diff > 0) { + $validator->addError([ + 'field' => $item, + 'rule' => self::RATE_LIMIT, + 'fallback' => "$item has reached the rate limit which expires in $diff seconds.", + 'meta' => ['expires' => $diff], + ]); + break; + } + + if ($diff <= 0) { + // Reset + $_SESSION[$key] = [1, $limit_end]; + break; + } + + $_SESSION[$key] = [$count, $expires]; + } else { + $_SESSION[$key] = [1, $limit_end]; + } + + break; } } } @@ -379,7 +430,7 @@ public function errors(): array { // Loop all errors to convert and get their custom messages foreach ($this->_to_convert as $error) { - $message = $this->getMessage($error['field'], $error['rule'], $error['fallback']); + $message = $this->getMessage($error['field'], $error['rule'], $error['fallback'], $error['meta']); // If there is no generic `message()` set or the translated message is not equal to generic message // we can continue without worrying about duplications @@ -409,10 +460,11 @@ public function errors(): array { * @param string $field name of field to search for. * @param string $rule rule which check failed. should be from the constants defined above. * @param string $fallback fallback default message if custom message and generic message are not supplied. + * @param ?array $meta optional meta to provide to message. * * @return string Message for this field and rule. */ - private function getMessage(string $field, string $rule, string $fallback): string { + private function getMessage(string $field, string $rule, string $fallback, ?array $meta = []): string { // No custom messages defined for this field if (!isset($this->_messages[$field])) { @@ -436,6 +488,11 @@ private function getMessage(string $field, string $rule, string $fallback): stri return $this->_message ?? $fallback; } + // If the message is a callback function, provide it with meta + if (is_callable($this->_messages[$field][$rule])) { + return $this->_messages[$field][$rule]($meta); + } + // Rule-specific custom message was supplied return $this->_messages[$field][$rule]; } diff --git a/custom/languages/en_UK.json b/custom/languages/en_UK.json index de726824e0..29b69f1dbd 100644 --- a/custom/languages/en_UK.json +++ b/custom/languages/en_UK.json @@ -798,6 +798,7 @@ "general/previous": "Previous", "general/privacy_policy": "Privacy Policy", "general/profile": "Profile", + "general/rate_limit": "Please try again in {{expires}} seconds", "general/register": "Register", "general/remove": "Remove", "general/report": "Report", diff --git a/modules/Core/pages/login.php b/modules/Core/pages/login.php index 08e5af7ea0..bc452ae375 100644 --- a/modules/Core/pages/login.php +++ b/modules/Core/pages/login.php @@ -54,12 +54,15 @@ unset($_SESSION['remember'], $_SESSION['password'], $_SESSION['tfa']); } + $rate_limit = [5, 60]; // 5 attempts in 60 seconds - TODO allow this to be customised? + if ($login_method == 'email') { $to_validate = [ 'email' => [ Validate::REQUIRED => true, Validate::IS_BANNED => true, - Validate::IS_ACTIVE => true + Validate::IS_ACTIVE => true, + Validate::RATE_LIMIT => $rate_limit, ], 'password' => [ Validate::REQUIRED => true @@ -70,7 +73,8 @@ 'username' => [ Validate::REQUIRED => true, Validate::IS_BANNED => true, - Validate::IS_ACTIVE => true + Validate::IS_ACTIVE => true, + Validate::RATE_LIMIT => $rate_limit, ], 'password' => [ Validate::REQUIRED => true @@ -82,12 +86,14 @@ 'email' => [ Validate::REQUIRED => $language->get('user', 'must_input_email'), Validate::IS_BANNED => $language->get('user', 'account_banned'), - Validate::IS_ACTIVE => $language->get('user', 'inactive_account') + Validate::IS_ACTIVE => $language->get('user', 'inactive_account'), + Validate::RATE_LIMIT => fn($meta) => $language->get('general', 'rate_limit', $meta), ], 'username' => [ Validate::REQUIRED => ($login_method == 'username' ? $language->get('user', 'must_input_username') : $language->get('user', 'must_input_email_or_username')), Validate::IS_BANNED => $language->get('user', 'account_banned'), - Validate::IS_ACTIVE => $language->get('user', 'inactive_account') + Validate::IS_ACTIVE => $language->get('user', 'inactive_account'), + Validate::RATE_LIMIT => fn($meta) => $language->get('general', 'rate_limit', $meta), ], 'password' => $language->get('user', 'must_input_password') ]);