diff --git a/login.php b/login.php index 27a30c03..75aeed4d 100644 --- a/login.php +++ b/login.php @@ -4,6 +4,7 @@ use MyAAC\Models\BoostedCreature; use MyAAC\Models\PlayerOnline; use MyAAC\Models\Account; use MyAAC\Models\Player; +use MyAAC\RateLimit; require_once 'common.php'; require_once SYSTEM . 'functions.php'; @@ -130,12 +131,29 @@ switch ($action) { } $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) { + $limiter->increment($ip); + if ($limiter->exceeded($ip)) { + sendError($ban_msg); + } + sendError(($inputEmail != false ? 'Email' : 'Account name') . ' or password is not correct.'); } $current_password = encrypt((USE_ACCOUNT_SALT ? $account->salt : '') . $request->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.'); } @@ -145,16 +163,26 @@ switch ($action) { if ($accountSecret != null && $accountSecret != '') { $accountHasSecret = true; if ($inputToken === false) { + $limiter->increment($ip); + if ($limiter->exceeded($ip)) { + sendError($ban_msg); + } sendError('Submit a valid two-factor authentication token.', 6); } else { require_once LIBS . 'rfc6238.php'; 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); } } } } + $limiter->reset($ip); 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!'); } diff --git a/system/pages/account/login.php b/system/pages/account/login.php index 0fce795d..98e85b46 100644 --- a/system/pages/account/login.php +++ b/system/pages/account/login.php @@ -8,6 +8,9 @@ * @copyright 2023 MyAAC * @link https://my-aac.org */ + +use MyAAC\RateLimit; + defined('MYAAC') or die('Direct access not allowed!'); // 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_password = $_POST['password_login']; $remember_me = isset($_POST['remember_me']); +$ip = get_browser_real_ip(); 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) - unset($tmp[$ip]); - } - else - $tmp = array(); - - $ip = $_SERVER['REMOTE_ADDR']; - $t = $tmp[$ip] ?? null; - } + $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(); $account_logged = new OTS_Account(); 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() - && (!isset($t) || $t['attempts'] < 5) + if($account_logged->isLoaded() && encrypt((USE_ACCOUNT_SALT ? $account_logged->getCustomField('salt') : '') . $login_password) == $account_logged->getPassword() && ($limiter->enabled && !$limiter->exceeded($ip)) ) { 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.'; - } - else { + } else { session_regenerate_id(); setSession('account', $account_logged->getId()); 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)); } + + $limiter->reset($ip); } else { $hooks->trigger(HOOK_LOGIN_ATTEMPT, array('account' => $login_account, 'password' => $login_password, 'remember_me' => $remember_me)); $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 - 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; - } + $errors[] = $errorMessage; + } } else { diff --git a/system/settings.php b/system/settings.php index 61ce5f65..1086f4d2 100644 --- a/system/settings.php +++ b/system/settings.php @@ -15,8 +15,7 @@ use MyAAC\Settings; return [ 'name' => 'MyAAC', - 'settings' => - [ + 'settings' => [ [ 'type' => 'category', 'title' => 'General' @@ -1590,6 +1589,34 @@ Sent by MyAAC,
'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' => [ 'beforeSave' => function(&$settings, &$values) { @@ -1658,6 +1685,6 @@ Sent by MyAAC,
return $success; }, - ], + ] ]; diff --git a/system/src/RateLimit.php b/system/src/RateLimit.php new file mode 100644 index 00000000..8a90e476 --- /dev/null +++ b/system/src/RateLimit.php @@ -0,0 +1,121 @@ +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; + } +}