Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to rate limit via Validate class #2998

Merged
merged 2 commits into from
Aug 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions core/classes/Core/Validate.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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])) {
Expand All @@ -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];
}
Expand Down
1 change: 1 addition & 0 deletions custom/languages/en_UK.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 10 additions & 4 deletions modules/Core/pages/login.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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')
]);
Expand Down