feat: ratelimit (#267)

* feat: rate limit settings

* fix: section label

* fix: real ip

* fix: real ip
This commit is contained in:
Gabriel Pedro 2024-08-26 08:53:09 -04:00 committed by GitHub
parent bc8ada6fe2
commit 327dcb5f87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 196 additions and 53 deletions

View File

@ -4,6 +4,7 @@ use MyAAC\Models\BoostedCreature;
use MyAAC\Models\PlayerOnline; use MyAAC\Models\PlayerOnline;
use MyAAC\Models\Account; use MyAAC\Models\Account;
use MyAAC\Models\Player; use MyAAC\Models\Player;
use MyAAC\RateLimit;
require_once 'common.php'; require_once 'common.php';
require_once SYSTEM . 'functions.php'; require_once SYSTEM . 'functions.php';
@ -130,12 +131,29 @@ switch ($action) {
} }
$account = $account->first(); $account = $account->first();
$ip = get_browser_real_ip();
$limiter = new RateLimit('failed_logins', setting('core.account_login_attempts_limit'), setting('core.account_login_ban_time'));
$limiter->enabled = setting('core.account_login_ipban_protection');
$limiter->load();
$ban_msg = 'A wrong account, password or secret has been entered ' . setting('core.account_login_attempts_limit') . ' times in a row. You are unable to log into your account for the next ' . setting('core.account_login_ban_time') . ' minutes. Please wait.';
if (!$account) { if (!$account) {
$limiter->increment($ip);
if ($limiter->exceeded($ip)) {
sendError($ban_msg);
}
sendError(($inputEmail != false ? 'Email' : 'Account name') . ' or password is not correct.'); sendError(($inputEmail != false ? 'Email' : 'Account name') . ' or password is not correct.');
} }
$current_password = encrypt((USE_ACCOUNT_SALT ? $account->salt : '') . $request->password); $current_password = encrypt((USE_ACCOUNT_SALT ? $account->salt : '') . $request->password);
if (!$account || $account->password != $current_password) { if (!$account || $account->password != $current_password) {
$limiter->increment($ip);
if ($limiter->exceeded($ip)) {
sendError($ban_msg);
}
sendError(($inputEmail != false ? 'Email' : 'Account name') . ' or password is not correct.'); sendError(($inputEmail != false ? 'Email' : 'Account name') . ' or password is not correct.');
} }
@ -145,16 +163,26 @@ switch ($action) {
if ($accountSecret != null && $accountSecret != '') { if ($accountSecret != null && $accountSecret != '') {
$accountHasSecret = true; $accountHasSecret = true;
if ($inputToken === false) { if ($inputToken === false) {
$limiter->increment($ip);
if ($limiter->exceeded($ip)) {
sendError($ban_msg);
}
sendError('Submit a valid two-factor authentication token.', 6); sendError('Submit a valid two-factor authentication token.', 6);
} else { } else {
require_once LIBS . 'rfc6238.php'; require_once LIBS . 'rfc6238.php';
if (TokenAuth6238::verify($accountSecret, $inputToken) !== true) { if (TokenAuth6238::verify($accountSecret, $inputToken) !== true) {
$limiter->increment($ip);
if ($limiter->exceeded($ip)) {
sendError($ban_msg);
}
sendError('Two-factor authentication failed, token is wrong.', 6); sendError('Two-factor authentication failed, token is wrong.', 6);
} }
} }
} }
} }
$limiter->reset($ip);
if (setting('core.account_mail_verify') && $account->email_verified !== 1) { if (setting('core.account_mail_verify') && $account->email_verified !== 1) {
sendError('You need to verify your account, enter in our site and resend verify e-mail!'); sendError('You need to verify your account, enter in our site and resend verify e-mail!');
} }

View File

@ -8,6 +8,9 @@
* @copyright 2023 MyAAC * @copyright 2023 MyAAC
* @link https://my-aac.org * @link https://my-aac.org
*/ */
use MyAAC\RateLimit;
defined('MYAAC') or die('Direct access not allowed!'); defined('MYAAC') or die('Direct access not allowed!');
// new login with data from form // new login with data from form
@ -18,30 +21,13 @@ if($logged || !isset($_POST['account_login']) || !isset($_POST['password_login']
$login_account = $_POST['account_login']; $login_account = $_POST['account_login'];
$login_password = $_POST['password_login']; $login_password = $_POST['password_login'];
$remember_me = isset($_POST['remember_me']); $remember_me = isset($_POST['remember_me']);
$ip = get_browser_real_ip();
if(!empty($login_account) && !empty($login_password)) if(!empty($login_account) && !empty($login_password))
{ {
if($cache->enabled())
{
$tmp = '';
if($cache->fetch('failed_logins', $tmp))
{
$tmp = unserialize($tmp);
$to_remove = array();
foreach($tmp as $ip => $t)
{
if(time() - $t['last'] >= 5 * 60)
$to_remove[] = $ip;
}
foreach($to_remove as $ip) $limiter = new RateLimit('failed_logins', setting('core.account_login_attempts_limit'), setting('core.account_login_ban_time'));
unset($tmp[$ip]); $limiter->enabled = setting('core.account_login_ipban_protection');
} $limiter->load();
else
$tmp = array();
$ip = $_SERVER['REMOTE_ADDR'];
$t = $tmp[$ip] ?? null;
}
$account_logged = new OTS_Account(); $account_logged = new OTS_Account();
if (config('account_login_by_email')) { if (config('account_login_by_email')) {
@ -56,14 +42,12 @@ if(!empty($login_account) && !empty($login_password))
} }
} }
if($account_logged->isLoaded() && encrypt((USE_ACCOUNT_SALT ? $account_logged->getCustomField('salt') : '') . $login_password) == $account_logged->getPassword() if($account_logged->isLoaded() && encrypt((USE_ACCOUNT_SALT ? $account_logged->getCustomField('salt') : '') . $login_password) == $account_logged->getPassword() && ($limiter->enabled && !$limiter->exceeded($ip))
&& (!isset($t) || $t['attempts'] < 5)
) )
{ {
if (setting('core.account_mail_verify') && (int)$account_logged->getCustomField('email_verified') !== 1) { if (setting('core.account_mail_verify') && (int)$account_logged->getCustomField('email_verified') !== 1) {
$errors[] = 'Your account is not verified. Please verify your email address. If the message is not coming check the SPAM folder in your E-Mail client.'; $errors[] = 'Your account is not verified. Please verify your email address. If the message is not coming check the SPAM folder in your E-Mail client.';
} } else {
else {
session_regenerate_id(); session_regenerate_id();
setSession('account', $account_logged->getId()); setSession('account', $account_logged->getId());
setSession('password', encrypt((USE_ACCOUNT_SALT ? $account_logged->getCustomField('salt') : '') . $login_password)); setSession('password', encrypt((USE_ACCOUNT_SALT ? $account_logged->getCustomField('salt') : '') . $login_password));
@ -87,38 +71,21 @@ if(!empty($login_account) && !empty($login_password))
$hooks->trigger(HOOK_LOGIN, array('account' => $account_logged, 'password' => $login_password, 'remember_me' => $remember_me)); $hooks->trigger(HOOK_LOGIN, array('account' => $account_logged, 'password' => $login_password, 'remember_me' => $remember_me));
} }
$limiter->reset($ip);
} }
else else
{ {
$hooks->trigger(HOOK_LOGIN_ATTEMPT, array('account' => $login_account, 'password' => $login_password, 'remember_me' => $remember_me)); $hooks->trigger(HOOK_LOGIN_ATTEMPT, array('account' => $login_account, 'password' => $login_password, 'remember_me' => $remember_me));
$errorMessage = getAccountLoginByLabel() . ' or password is not correct.'; $errorMessage = getAccountLoginByLabel() . ' or password is not correct.';
$limiter->increment($ip);
if ($limiter->exceeded($ip)) {
$errorMessage = 'A wrong password has been entered ' . $limiter->max_attempts . ' times in a row. You are unable to log into your account for the next ' . $limiter->ttl . ' minutes. Please wait.';
}
// temporary solution for blocking failed login attempts $errors[] = $errorMessage;
if($cache->enabled())
{
if(isset($t))
{
$t['attempts']++;
$t['last'] = time();
if($t['attempts'] >= 5)
$errors[] = 'A wrong password has been entered 5 times in a row. You are unable to log into your account for the next 5 minutes. Please wait.';
else
$errors[] = $errorMessage;
}
else
{
$t = array('attempts' => 1, 'last' => time());
$errors[] = $errorMessage;
}
$tmp[$ip] = $t;
$cache->set('failed_logins', serialize($tmp), 60 * 60); // save for 1 hour
}
else {
$errors[] = $errorMessage;
}
} }
} }
else { else {

View File

@ -15,8 +15,7 @@ use MyAAC\Settings;
return [ return [
'name' => 'MyAAC', 'name' => 'MyAAC',
'settings' => 'settings' => [
[
[ [
'type' => 'category', 'type' => 'category',
'title' => 'General' 'title' => 'General'
@ -1590,6 +1589,34 @@ Sent by MyAAC,<br/>
'account_change_character_sex', '=', 'true', 'account_change_character_sex', '=', 'true',
], ],
], ],
[
'type' => 'category',
'title' => 'Security',
],
[
'type' => 'section',
'title' => 'IP Ban Protection',
],
'account_login_ipban_protection' => [
'name' => 'IP Ban Protection',
'type' => 'boolean',
'desc' => 'Activate IP ban protection after exceeding incorrect login attempts',
'default' => true,
],
'account_login_attempts_limit' => [
'name' => 'Login Attempts Limit',
'type' => 'number',
'desc' => 'Number of incorrect login attempts before banning the IP',
'default' => 5, // Ajuste conforme necessário
],
'account_login_ban_time' => [
'name' => 'Ban Time (Minutes)',
'type' => 'number',
'desc' => 'Time in minutes the IP will be banned after exceeding login attempts',
'default' => 30, // Ajuste conforme necessário
],
], ],
'callbacks' => [ 'callbacks' => [
'beforeSave' => function(&$settings, &$values) { 'beforeSave' => function(&$settings, &$values) {
@ -1658,6 +1685,6 @@ Sent by MyAAC,<br/>
return $success; return $success;
}, },
], ]
]; ];

121
system/src/RateLimit.php Normal file
View File

@ -0,0 +1,121 @@
<?php
namespace MyAAC;
class RateLimit
{
public string $key;
public int $max_attempts;
public int $ttl;
public $enabled = false;
protected array $data;
public function __construct(string $key, int $max_attempts, int $ttl)
{
$this->key = $key;
$this->max_attempts = $max_attempts;
$this->ttl = $ttl;
}
public function attempts(string $ip): int
{
if (!$this->enabled) {
return 0;
}
if (isset($this->data[$ip]['attempts'])) {
return $this->data[$ip]['attempts'];
}
return 0;
}
public function exceeded(string $ip): bool {
if (!$this->enabled) {
return false;
}
return $this->attempts($ip) > $this->max_attempts;
}
public function increment(string $ip): bool
{
global $cache;
if ($this->enabled && $cache->enabled()) {
if (isset($this->data[$ip]['attempts']) && isset($this->data[$ip]['last'])) {
$this->data[$ip]['attempts']++;
$this->data[$ip]['last'] = time();
} else {
$this->data[$ip] = [
'attempts' => 1,
'last' => time(),
];
}
$this->save();
}
return false;
}
public function reset(string $ip): void
{
if (!$this->enabled) {
return;
}
if (isset($this->data[$ip])) {
unset($this->data[$ip]);
}
$this->save();
}
public function save(): void
{
global $cache;
if (!$this->enabled) {
return;
}
$data = $this->data;
$cache->set($this->key, serialize($data), $this->ttl * 60);
}
public function load(): void
{
global $cache;
if (!$this->enabled) {
return;
}
$data = [];
if ($this->enabled && $cache->enabled()) {
$tmp = '';
if ($cache->fetch($this->key, $tmp)) {
$data = unserialize($tmp);
$to_remove = [];
foreach ($data as $ip => $t) {
if (time() - $t['last'] >= ($this->ttl * 60)) {
$to_remove[] = $ip;
}
}
if (count($to_remove)) {
foreach ($to_remove as $ip) {
unset($data[$ip]);
}
$this->save();
}
} else {
$data = [];
}
}
$this->data = $data;
}
}