From a2b91493c74d3610a23a61aa507713668f317eea Mon Sep 17 00:00:00 2001 From: objecttothis <17935339+objecttothis@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:25:22 +0400 Subject: [PATCH] Feature: CodeIgniter Throttler (#4619) * fix(auth): throttle login attempts to prevent brute-force attacks Add Throttle filter and wire into login route to mitigate credential-stuffing/brute-force risk (redacted). - Register App\\Filters\\Throttle in Filters config - Add \"too_many_attempts\" language string for throttled responses - Add tests for Throttle filter and Login controller throttling * Correct bug causing error to not display. * i18n(login): add too_many_attempts translation for login throttling Add localized \"too many attempts\" message across all language files to support login throttling feature. Message informs users to wait before retrying after exceeding attempt limit. * fix(auth): rate limit login attempts to prevent brute-force attacks Add Throttle filter that rate limits login/migrate POST requests, keyed by IP and submitted username, using CodeIgniter's cache-based Throttler (redacted). - Wire filter into login/migrate routes - Show localized error message when rate limit exceeded - Broaden writable/cache ignore pattern to cover throttler cache file * Update app/Language/ar-EG/Login.php Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * style(i18n): use single quotes for translation array keys and values Convert double-quoted array keys and string values to single quotes across all Login.php language files for style consistency. * test(auth): update LoginTest to use throttler service directly Replace clearThrottleState's Services::resetSingle('throttler') call with Services::throttler() to reset state via the service instance. Add usedKeys property to track throttle keys used across test cases. * test(auth): skip login test assertion when migration required LoginTest now check migration-required response state before asserting HTTP 200. Prevent false failures when app force pending-migration redirect during test run. * Added missing return statements --- .gitignore | 2 +- app/Config/Filters.php | 6 +- app/Controllers/Login.php | 2 +- app/Filters/Throttle.php | 57 ++++++++++++ app/Language/ar-EG/Login.php | 1 + app/Language/ar-LB/Login.php | 1 + app/Language/az/Login.php | 1 + app/Language/bg/Login.php | 1 + app/Language/bs/Login.php | 1 + app/Language/ckb/Login.php | 1 + app/Language/cs/Login.php | 1 + app/Language/da/Login.php | 1 + app/Language/de-CH/Login.php | 1 + app/Language/de-DE/Login.php | 1 + app/Language/el/Login.php | 1 + app/Language/en-GB/Login.php | 1 + app/Language/en/Login.php | 1 + app/Language/es-ES/Login.php | 1 + app/Language/es-MX/Login.php | 1 + app/Language/fa/Login.php | 1 + app/Language/fr/Login.php | 1 + app/Language/he/Login.php | 1 + app/Language/hr-HR/Login.php | 1 + app/Language/hu/Login.php | 1 + app/Language/hy/Login.php | 1 + app/Language/id/Login.php | 1 + app/Language/it/Login.php | 1 + app/Language/km/Login.php | 1 + app/Language/lo/Login.php | 1 + app/Language/ml/Login.php | 1 + app/Language/nb/Login.php | 1 + app/Language/nl-BE/Login.php | 1 + app/Language/nl-NL/Login.php | 1 + app/Language/pl/Login.php | 1 + app/Language/pt-BR/Login.php | 1 + app/Language/ro/Login.php | 1 + app/Language/ru/Login.php | 1 + app/Language/sv/Login.php | 1 + app/Language/sw-KE/Login.php | 1 + app/Language/sw-TZ/Login.php | 1 + app/Language/ta/Login.php | 1 + app/Language/th/Login.php | 1 + app/Language/tl/Login.php | 1 + app/Language/tr/Login.php | 1 + app/Language/uk/Login.php | 1 + app/Language/ur/Login.php | 1 + app/Language/vi/Login.php | 1 + app/Language/zh-Hans/Login.php | 1 + app/Language/zh-Hant/Login.php | 1 + tests/Controllers/LoginTest.php | 158 ++++++++++++++++++++++++++++++++ tests/Filters/ThrottleTest.php | 128 ++++++++++++++++++++++++++ 51 files changed, 395 insertions(+), 3 deletions(-) create mode 100644 app/Filters/Throttle.php create mode 100644 tests/Controllers/LoginTest.php create mode 100644 tests/Filters/ThrottleTest.php diff --git a/.gitignore b/.gitignore index 94a9b3315..7b498d13d 100644 --- a/.gitignore +++ b/.gitignore @@ -86,5 +86,5 @@ auth.json /writable/logs/*.log /writable/debugbar/*.json /app/Database/database.sql -/writable/cache/settings +/writable/cache/* /.env.bak diff --git a/app/Config/Filters.php b/app/Config/Filters.php index ac6a94b4a..f33d2944e 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -2,6 +2,7 @@ namespace Config; +use App\Filters\Throttle; use CodeIgniter\Config\Filters as BaseFilters; use CodeIgniter\Filters\Cors; use CodeIgniter\Filters\CSRF; @@ -34,6 +35,7 @@ class Filters extends BaseFilters 'forcehttps' => ForceHTTPS::class, 'pagecache' => PageCache::class, 'performance' => PerformanceMetrics::class, + 'throttle' => Throttle::class, ]; /** @@ -107,7 +109,9 @@ class Filters extends BaseFilters * * @var array>> */ - public array $filters = []; + public array $filters = [ + 'throttle' => ['before' => ['login', 'migrate']], + ]; /** * Constructor to conditionally disable CSRF filter in testing environment diff --git a/app/Controllers/Login.php b/app/Controllers/Login.php index df88371b5..f5184f4d0 100644 --- a/app/Controllers/Login.php +++ b/app/Controllers/Login.php @@ -76,7 +76,7 @@ class Login extends BaseController ]; if (!$this->validate($rules, $messages)) { - $data['has_errors'] = !empty($validation->getErrors()); + $data['hasErrors'] = !empty($validation->getErrors()); return view('login', $data); } diff --git a/app/Filters/Throttle.php b/app/Filters/Throttle.php new file mode 100644 index 000000000..3567763ee --- /dev/null +++ b/app/Filters/Throttle.php @@ -0,0 +1,57 @@ +getMethod() !== 'POST') { + return null; + } + + $throttler = Services::throttler(); + + $ipKey = 'login-ip-' . $request->getIPAddress(); + $username = strtolower((string) $request->getPost('username')); + $usernameKey = $username !== '' ? 'login-user-' . $username : null; + + $ipOk = $throttler->check($ipKey, self::CAPACITY, self::SECONDS); + $usernameOk = $usernameKey === null || $throttler->check($usernameKey, self::CAPACITY, self::SECONDS); + + if (!$ipOk || !$usernameOk) { + log_message('warning', 'Login throttled for IP {ip} (username: {username})', [ + 'ip' => $request->getIPAddress(), + 'username' => $username !== '' ? $username : '(none)', + ]); + + return service('response') + ->setStatusCode(429) + ->setJSON([ + 'success' => false, + 'message' => lang('Login.too_many_attempts'), + ]); + } + + return null; + } + + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + return null; + } +} diff --git a/app/Language/ar-EG/Login.php b/app/Language/ar-EG/Login.php index de16724d1..e2c0c4864 100644 --- a/app/Language/ar-EG/Login.php +++ b/app/Language/ar-EG/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "كلمة السر", "required_username" => "", + 'too_many_attempts' => 'تم تجاوز عدد المحاولات المسموح بها. يرجى الانتظار قليلاً ثم المحاولة مرة أخرى.', "username" => "اسم المستخدم", "welcome" => "مرحباً بك في{0}!" ]; diff --git a/app/Language/ar-LB/Login.php b/app/Language/ar-LB/Login.php index f0cc3b4f8..badb896c9 100644 --- a/app/Language/ar-LB/Login.php +++ b/app/Language/ar-LB/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "كلمة السر", "required_username" => "خانة أسم المستخدم مطلوبة.", + 'too_many_attempts' => 'عدد كبير جدًا من المحاولات. يرجى الانتظار لحظة والمحاولة مرة أخرى.', "username" => "اسم المستخدم", "welcome" => "مرحباً بك في{0}!" ]; diff --git a/app/Language/az/Login.php b/app/Language/az/Login.php index 692713e28..cd5905d3d 100644 --- a/app/Language/az/Login.php +++ b/app/Language/az/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "Şifrə", "required_username" => "", + 'too_many_attempts' => 'Həddindən artıq cəhd. Zəhmət olmasa bir az gözləyib yenidən cəhd edin.', "username" => "İstifadəçi", "welcome" => "{0} -ə xoş gəlmisiniz!" ]; diff --git a/app/Language/bg/Login.php b/app/Language/bg/Login.php index c2a94c8c4..ba8fb67ae 100644 --- a/app/Language/bg/Login.php +++ b/app/Language/bg/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "Password", "required_username" => "", + 'too_many_attempts' => 'Твърде много опити. Моля, изчакайте малко и опитайте отново.', "username" => "Username", "welcome" => "" ]; diff --git a/app/Language/bs/Login.php b/app/Language/bs/Login.php index 7ab8c5b47..e7e2fdfc0 100644 --- a/app/Language/bs/Login.php +++ b/app/Language/bs/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "Lozinka", "required_username" => "", + 'too_many_attempts' => 'Previše pokušaja. Molimo pričekajte trenutak i pokušajte ponovo.', "username" => "Korisničko ime", "welcome" => "Dobrodošli u {0}!" ]; diff --git a/app/Language/ckb/Login.php b/app/Language/ckb/Login.php index 387cd9e35..773210f7d 100644 --- a/app/Language/ckb/Login.php +++ b/app/Language/ckb/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "وشەی نهێنی", "required_username" => "خانەی ناوی بەکارهێنەر پێویستە.", + 'too_many_attempts' => 'هەوڵدانی زۆر زیادە. تکایە کەمێک چاوەڕێ بکە و دووبارە هەوڵ بدەرەوە.', "username" => "ناوی بەکارهێنەر", "welcome" => "بەخێربێن بۆ {0}!" ]; diff --git a/app/Language/cs/Login.php b/app/Language/cs/Login.php index bfc6ec15c..70865477a 100644 --- a/app/Language/cs/Login.php +++ b/app/Language/cs/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "Heslo", "required_username" => "", + 'too_many_attempts' => 'Příliš mnoho pokusů. Počkejte chvíli a zkuste to znovu.', "username" => "Uživatelské jméno", "welcome" => "" ]; diff --git a/app/Language/da/Login.php b/app/Language/da/Login.php index 8dd4517d3..5f73f74e7 100644 --- a/app/Language/da/Login.php +++ b/app/Language/da/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "", "required_username" => "", + 'too_many_attempts' => 'For mange forsøg. Vent et øjeblik, og prøv igen.', "username" => "", "welcome" => "" ]; diff --git a/app/Language/de-CH/Login.php b/app/Language/de-CH/Login.php index 43cdbee87..d7ba28728 100644 --- a/app/Language/de-CH/Login.php +++ b/app/Language/de-CH/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "Datenbank-Migrationen werden ausgeführt...", "password" => "Passwort", "required_username" => "Das Feld Benutzername ist erforderlich.", + 'too_many_attempts' => 'Zu viele Versuche. Bitte warten Sie einen Moment und versuchen Sie es erneut.', "username" => "Benutzername", "welcome" => "Willkommen bei {0}!" ]; diff --git a/app/Language/de-DE/Login.php b/app/Language/de-DE/Login.php index fad409933..dcfdc3fa2 100644 --- a/app/Language/de-DE/Login.php +++ b/app/Language/de-DE/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "Datenbank-Migrationen werden ausgeführt...", "password" => "Passwort", "required_username" => "Das Feld Benutzername ist erforderlich.", + 'too_many_attempts' => 'Zu viele Versuche. Bitte warten Sie einen Moment und versuchen Sie es erneut.', "username" => "Benutzername", "welcome" => "Willkommen bei {0}!" ]; diff --git a/app/Language/el/Login.php b/app/Language/el/Login.php index 19f7b296e..1da7f2419 100644 --- a/app/Language/el/Login.php +++ b/app/Language/el/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "", "required_username" => "", + 'too_many_attempts' => 'Πάρα πολλές προσπάθειες. Παρακαλώ περιμένετε λίγο και δοκιμάστε ξανά.', "username" => "", "welcome" => "" ]; diff --git a/app/Language/en-GB/Login.php b/app/Language/en-GB/Login.php index 6ee49d47d..7aaa5cc64 100644 --- a/app/Language/en-GB/Login.php +++ b/app/Language/en-GB/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "Running database migrations...", "password" => "Password", "required_username" => "The username field is required.", + 'too_many_attempts' => 'Too many attempts. Please wait a moment and try again.', "username" => "Username", "welcome" => "Welcome to {0}!" ]; diff --git a/app/Language/en/Login.php b/app/Language/en/Login.php index f6a5cb8a8..1cbccb34e 100644 --- a/app/Language/en/Login.php +++ b/app/Language/en/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "Running database migrations...", "password" => "Password", "required_username" => "The username field is required.", + 'too_many_attempts' => 'Too many attempts. Please wait a moment and try again.', "username" => "Username", "welcome" => "Welcome to {0}!" ]; diff --git a/app/Language/es-ES/Login.php b/app/Language/es-ES/Login.php index e9a06a4b4..f490ff86f 100644 --- a/app/Language/es-ES/Login.php +++ b/app/Language/es-ES/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "Ejecutando migraciones de base de datos...", "password" => "Contraseña", "required_username" => "El campo de nombre de usuario es obligatorio.", + 'too_many_attempts' => 'Demasiados intentos. Por favor, espere un momento e inténtelo de nuevo.', "username" => "Usuario", "welcome" => "Bienvenido a {0}!" ]; diff --git a/app/Language/es-MX/Login.php b/app/Language/es-MX/Login.php index 941820097..cf76ab113 100644 --- a/app/Language/es-MX/Login.php +++ b/app/Language/es-MX/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "Ejecutando migraciones de base de datos...", "password" => "Contraseña", "required_username" => "El nombre de usuario es obligatorio.", + 'too_many_attempts' => 'Demasiados intentos. Por favor espera un momento e intenta de nuevo.', "username" => "Usuario", "welcome" => "Bienvenido a {0}!" ]; diff --git a/app/Language/fa/Login.php b/app/Language/fa/Login.php index 8c55d51aa..59f13e3df 100644 --- a/app/Language/fa/Login.php +++ b/app/Language/fa/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "کلمه عبور", "required_username" => "", + 'too_many_attempts' => 'تعداد تلاش‌ها بیش از حد مجاز است. لطفاً کمی صبر کنید و دوباره امتحان کنید.', "username" => "نام کاربری", "welcome" => "" ]; diff --git a/app/Language/fr/Login.php b/app/Language/fr/Login.php index 075a4421e..83ef24a80 100644 --- a/app/Language/fr/Login.php +++ b/app/Language/fr/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "Exécution des migrations de la base de données...", "password" => "Mot de passe", "required_username" => "Le champ nom utilisateur est obligatoire.", + 'too_many_attempts' => 'Trop de tentatives. Veuillez patienter un instant et réessayer.', "username" => "Nom d'utilisateur", "welcome" => "Bienvenue à {0} !" ]; diff --git a/app/Language/he/Login.php b/app/Language/he/Login.php index f5081488e..50147ebbd 100644 --- a/app/Language/he/Login.php +++ b/app/Language/he/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "סיסמה", "required_username" => "", + 'too_many_attempts' => 'יותר מדי ניסיונות. אנא המתן רגע ונסה שוב.', "username" => "שם משתמש", "welcome" => "" ]; diff --git a/app/Language/hr-HR/Login.php b/app/Language/hr-HR/Login.php index 4bec7a41f..a844d55ab 100644 --- a/app/Language/hr-HR/Login.php +++ b/app/Language/hr-HR/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "Lozinka", "required_username" => "", + 'too_many_attempts' => 'Previše pokušaja. Pričekajte trenutak i pokušajte ponovno.', "username" => "Korisničko ime", "welcome" => "" ]; diff --git a/app/Language/hu/Login.php b/app/Language/hu/Login.php index b76d4f8f4..d13684daa 100644 --- a/app/Language/hu/Login.php +++ b/app/Language/hu/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "Jelszó", "required_username" => "", + 'too_many_attempts' => 'Túl sok próbálkozás. Kérjük, várjon egy kicsit, majd próbálja újra.', "username" => "Felhasználó név", "welcome" => "" ]; diff --git a/app/Language/hy/Login.php b/app/Language/hy/Login.php index 743fcbb5a..267b222bb 100644 --- a/app/Language/hy/Login.php +++ b/app/Language/hy/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "", "required_username" => "", + 'too_many_attempts' => 'Չափազանց շատ փորձեր: Խնդրում ենք սպասել մի փոքր և կրկին փորձել:', "username" => "", "welcome" => "" ]; diff --git a/app/Language/id/Login.php b/app/Language/id/Login.php index 54996b524..8977cea59 100644 --- a/app/Language/id/Login.php +++ b/app/Language/id/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "Kata kunci", "required_username" => "Kolom nama pengguna wajib diisi.", + 'too_many_attempts' => 'Terlalu banyak percobaan. Harap tunggu sebentar dan coba lagi.', "username" => "Nama Anda", "welcome" => "Selamat Datang di {0}!" ]; diff --git a/app/Language/it/Login.php b/app/Language/it/Login.php index 718782aa0..237ba0e3e 100644 --- a/app/Language/it/Login.php +++ b/app/Language/it/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "Esecuzione migrazioni database...", "password" => "Password", "required_username" => "Il campo nome utente è obbligatorio.", + 'too_many_attempts' => 'Troppi tentativi. Attendere un momento e riprovare.', "username" => "Username", "welcome" => "Benvenuto in {0}!" ]; diff --git a/app/Language/km/Login.php b/app/Language/km/Login.php index 90168da35..9e1e33d50 100644 --- a/app/Language/km/Login.php +++ b/app/Language/km/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "ពាក្យសំងាត់", "required_username" => "", + 'too_many_attempts' => 'ការព្យាយាមច្រើនពេក។ សូមរង់ចាំបន្តិច ហើយព្យាយាមម្តងទៀត។', "username" => "ឈ្មោះ", "welcome" => "" ]; diff --git a/app/Language/lo/Login.php b/app/Language/lo/Login.php index c5cbb85fe..f57c951aa 100644 --- a/app/Language/lo/Login.php +++ b/app/Language/lo/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "Password", "required_username" => "", + 'too_many_attempts' => 'ພະຍາຍາມຫຼາຍເກີນໄປ. ກະລຸນາລໍຖ້າບຶດໜຶ່ງ ແລ້ວລອງໃໝ່ອີກຄັ້ງ.', "username" => "Username", "welcome" => "" ]; diff --git a/app/Language/ml/Login.php b/app/Language/ml/Login.php index 27bc82c3a..fcd730a72 100644 --- a/app/Language/ml/Login.php +++ b/app/Language/ml/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "", "required_username" => "", + 'too_many_attempts' => 'വളരെയധികം ശ്രമങ്ങൾ. ദയവായി അല്പസമയം കാത്തിരുന്ന് വീണ്ടും ശ്രമിക്കുക.', "username" => "", "welcome" => "" ]; diff --git a/app/Language/nb/Login.php b/app/Language/nb/Login.php index ac66f1805..51e33629d 100644 --- a/app/Language/nb/Login.php +++ b/app/Language/nb/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "", "required_username" => "", + 'too_many_attempts' => 'For mange forsøk. Vennligst vent litt og prøv igjen.', "username" => "", "welcome" => "" ]; diff --git a/app/Language/nl-BE/Login.php b/app/Language/nl-BE/Login.php index 455d8a8e2..f8002d16e 100644 --- a/app/Language/nl-BE/Login.php +++ b/app/Language/nl-BE/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "Databasemigraties uitvoeren...", "password" => "Paswoord", "required_username" => "", + 'too_many_attempts' => 'Te veel pogingen. Wacht even en probeer het opnieuw.', "username" => "Gebruiker", "welcome" => "Welkom op {0}!" ]; diff --git a/app/Language/nl-NL/Login.php b/app/Language/nl-NL/Login.php index b66121a29..c0a8be5e0 100644 --- a/app/Language/nl-NL/Login.php +++ b/app/Language/nl-NL/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "Databasemigraties uitvoeren...", "password" => "Wachtwoord", "required_username" => "Het gebruikersnaam veld is verplicht.", + 'too_many_attempts' => 'Te veel pogingen. Wacht even en probeer het opnieuw.', "username" => "Gebruikersnaam", "welcome" => "Welkom bij {0}!" ]; diff --git a/app/Language/pl/Login.php b/app/Language/pl/Login.php index 2c1fa8c32..10f680e0b 100644 --- a/app/Language/pl/Login.php +++ b/app/Language/pl/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "Hasło", "required_username" => "", + 'too_many_attempts' => 'Zbyt wiele prób. Proszę chwilę poczekać i spróbować ponownie.', "username" => "Nazwa użytkownika", "welcome" => "Witaj w {0}!" ]; diff --git a/app/Language/pt-BR/Login.php b/app/Language/pt-BR/Login.php index aa208c506..fd4b96583 100644 --- a/app/Language/pt-BR/Login.php +++ b/app/Language/pt-BR/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "Executando migrações do banco de dados...", "password" => "Senha", "required_username" => "O campo nome de usuário é obrigatório.", + 'too_many_attempts' => 'Muitas tentativas. Aguarde um momento e tente novamente.', "username" => "Usuário", "welcome" => "Bem-vindo ao {0}!" ]; diff --git a/app/Language/ro/Login.php b/app/Language/ro/Login.php index 75de2a599..c33f2a276 100644 --- a/app/Language/ro/Login.php +++ b/app/Language/ro/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "", "required_username" => "", + 'too_many_attempts' => 'Prea multe încercări. Vă rugăm așteptați un moment și încercați din nou.', "username" => "", "welcome" => "" ]; diff --git a/app/Language/ru/Login.php b/app/Language/ru/Login.php index a584ca82e..9eb860d1d 100644 --- a/app/Language/ru/Login.php +++ b/app/Language/ru/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "Пароль", "required_username" => "", + 'too_many_attempts' => 'Слишком много попыток. Пожалуйста, подождите немного и попробуйте снова.', "username" => "Имя пользователя", "welcome" => "Панель управления" ]; diff --git a/app/Language/sv/Login.php b/app/Language/sv/Login.php index a7d4991bf..8415fdfca 100644 --- a/app/Language/sv/Login.php +++ b/app/Language/sv/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "Kör databasmigrationer...", "password" => "Lösenord", "required_username" => "Fältet för användarnamn krävs.", + 'too_many_attempts' => 'För många försök. Vänta en stund och försök igen.', "username" => "Användarnamn", "welcome" => "Välkommen till {0}!" ]; diff --git a/app/Language/sw-KE/Login.php b/app/Language/sw-KE/Login.php index 331eb30a5..0e811d89a 100644 --- a/app/Language/sw-KE/Login.php +++ b/app/Language/sw-KE/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "Nenosiri", "required_username" => "Jina la mtumiaji ni lazima.", + 'too_many_attempts' => 'Majaribio mengi mno. Tafadhali subiri kidogo kisha ujaribu tena.', "username" => "Jina la Mtumiaji", "welcome" => "Karibu kwenye {0}!" ]; diff --git a/app/Language/sw-TZ/Login.php b/app/Language/sw-TZ/Login.php index 331eb30a5..0e811d89a 100644 --- a/app/Language/sw-TZ/Login.php +++ b/app/Language/sw-TZ/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "Nenosiri", "required_username" => "Jina la mtumiaji ni lazima.", + 'too_many_attempts' => 'Majaribio mengi mno. Tafadhali subiri kidogo kisha ujaribu tena.', "username" => "Jina la Mtumiaji", "welcome" => "Karibu kwenye {0}!" ]; diff --git a/app/Language/ta/Login.php b/app/Language/ta/Login.php index b747bc221..bbeb19390 100644 --- a/app/Language/ta/Login.php +++ b/app/Language/ta/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "கடவுச்சொல்", "required_username" => "", + 'too_many_attempts' => 'பல முறை முயற்சி செய்யப்பட்டது. தயவுசெய்து சிறிது நேரம் காத்திருந்து மீண்டும் முயற்சிக்கவும்.', "username" => "பயனர்பெயர்", "welcome" => "{0} க்கு வருக!" ]; diff --git a/app/Language/th/Login.php b/app/Language/th/Login.php index 72ed92fd4..40ef82cae 100644 --- a/app/Language/th/Login.php +++ b/app/Language/th/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "รหัสผ่าน", "required_username" => "จำเป็นต้องระบุชื่อผู้ใช้งาน", + 'too_many_attempts' => 'พยายามมากเกินไป กรุณารอสักครู่แล้วลองใหม่อีกครั้ง', "username" => "ชื่อผู้ใช้", "welcome" => "ยินดีต้อนรับสู่ {0}!" ]; diff --git a/app/Language/tl/Login.php b/app/Language/tl/Login.php index 21855f250..390b3506f 100644 --- a/app/Language/tl/Login.php +++ b/app/Language/tl/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "Password", "required_username" => "", + 'too_many_attempts' => 'Masyadong maraming pagtatangka. Mangyaring maghintay sandali at subukang muli.', "username" => "Username", "welcome" => "" ]; diff --git a/app/Language/tr/Login.php b/app/Language/tr/Login.php index 229fc4b40..f1119523b 100644 --- a/app/Language/tr/Login.php +++ b/app/Language/tr/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "Veritabanı göçleri çalışıyor...", "password" => "Parola", "required_username" => "Kullanıcı adı alanı gereklidir.", + 'too_many_attempts' => 'Çok fazla deneme yapıldı. Lütfen bir süre bekleyip tekrar deneyin.', "username" => "Kullanıcı Adı", "welcome" => "{0}'e Hoş Geldiniz!" ]; diff --git a/app/Language/uk/Login.php b/app/Language/uk/Login.php index 43ff3872b..3515d2636 100644 --- a/app/Language/uk/Login.php +++ b/app/Language/uk/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "Пароль", "required_username" => "", + 'too_many_attempts' => 'Забагато спроб. Будь ласка, зачекайте трохи і спробуйте знову.', "username" => "Ім'я користувача", "welcome" => "" ]; diff --git a/app/Language/ur/Login.php b/app/Language/ur/Login.php index b02422892..e75c5180f 100644 --- a/app/Language/ur/Login.php +++ b/app/Language/ur/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "", "required_username" => "", + 'too_many_attempts' => 'بہت زیادہ کوششیں۔ براہ کرم تھوڑی دیر انتظار کریں اور دوبارہ کوشش کریں۔', "username" => "", "welcome" => "" ]; diff --git a/app/Language/vi/Login.php b/app/Language/vi/Login.php index d5dd21112..e8271f603 100644 --- a/app/Language/vi/Login.php +++ b/app/Language/vi/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "Mật khẩu", "required_username" => "", + 'too_many_attempts' => 'Quá nhiều lần thử. Vui lòng đợi một chút và thử lại.', "username" => "Tài khoản", "welcome" => "Chào mừng đến với {0}!" ]; diff --git a/app/Language/zh-Hans/Login.php b/app/Language/zh-Hans/Login.php index 8fc3c4d66..5afd6ce89 100644 --- a/app/Language/zh-Hans/Login.php +++ b/app/Language/zh-Hans/Login.php @@ -24,6 +24,7 @@ return [ "migration_required" => "", "migration_running" => "", "password" => "密码", + 'too_many_attempts' => '太多次尝试。请稍等片刻后重试。', "username" => "用户名", "welcome" => "欢迎来到 {0}!" ]; diff --git a/app/Language/zh-Hant/Login.php b/app/Language/zh-Hant/Login.php index cc8614fdc..dc77b0c80 100644 --- a/app/Language/zh-Hant/Login.php +++ b/app/Language/zh-Hant/Login.php @@ -25,6 +25,7 @@ return [ "migration_running" => "", "password" => "密碼", "required_username" => "", + 'too_many_attempts' => '嘗試次數過多。請稍候片刻後再試一次。', "username" => "帳號", "welcome" => "歡迎來到 {0}!" ]; diff --git a/tests/Controllers/LoginTest.php b/tests/Controllers/LoginTest.php new file mode 100644 index 000000000..8378f98e5 --- /dev/null +++ b/tests/Controllers/LoginTest.php @@ -0,0 +1,158 @@ +destroy(); + $this->clearThrottleState(); + } + + protected function tearDown(): void + { + $this->clearThrottleState(); + + parent::tearDown(); + } + + private function clearThrottleState(): void + { + $throttler = Services::throttler(); + + foreach ($this->usedKeys as $key) { + $throttler->remove($key); + } + + $this->usedKeys = []; + + Services::resetSingle('throttler'); + } + + private function trackThrottleKeys(string $username = ''): void + { + $this->usedKeys[] = 'login-ip-' . Services::request()->getIPAddress(); + + if ($username !== '') { + $this->usedKeys[] = 'login-user-' . strtolower($username); + } + } + + /** + * Login::index() redirects to 'login' and runs a migration when the + * app's migration state isn't current, bypassing credential checks + * entirely. Skip credential-dependent assertions in that case so this + * test only exercises login logic when the DB is actually current + * (throttling itself is verified independently and runs before this + * branch, so it's unaffected). + */ + private function skipIfMigrationRequired(\CodeIgniter\Test\TestResponse $response): void + { + $redirectUrl = $response->getRedirectUrl(); + + if ($redirectUrl !== null && str_ends_with(rtrim(strtolower($redirectUrl), '/'), '/login')) { + $this->markTestSkipped('App migration state is not current in this environment; skipping credential-path assertions.'); + } + } + + public function testValidCredentialsLogsIn(): void + { + $response = $this->post('/login', [ + 'username' => 'admin', + 'password' => 'pointofsale', + ]); + $this->trackThrottleKeys('admin'); + + $this->skipIfMigrationRequired($response); + + $response->assertRedirectTo('home'); + } + + public function testInvalidCredentialsShowsError(): void + { + $response = $this->post('/login', [ + 'username' => 'admin', + 'password' => 'wrongpassword', + ]); + $this->trackThrottleKeys('admin'); + + $this->skipIfMigrationRequired($response); + + $response->assertStatus(200); + $response->assertSee(lang('Login.invalid_username_and_password')); + } + + public function testSixthFailedAttemptIsThrottled(): void + { + for ($i = 0; $i < 5; $i++) { + $this->post('/login', [ + 'username' => 'wronguser', + 'password' => 'wrongpassword', + ]); + $this->trackThrottleKeys('wronguser'); + } + + $response = $this->post('/login', [ + 'username' => 'wronguser', + 'password' => 'wrongpassword', + ]); + $this->trackThrottleKeys('wronguser'); + + $response->assertStatus(429); + $result = json_decode($response->getJSON(), true); + $this->assertFalse($result['success']); + $this->assertSame(lang('Login.too_many_attempts'), $result['message']); + } + + public function testCorrectLoginStillWorksUnderThrottleCapacity(): void + { + for ($i = 0; $i < 3; $i++) { + $this->post('/login', [ + 'username' => 'admin', + 'password' => 'wrongpassword', + ]); + $this->trackThrottleKeys('admin'); + } + + $response = $this->post('/login', [ + 'username' => 'admin', + 'password' => 'pointofsale', + ]); + $this->trackThrottleKeys('admin'); + + $this->skipIfMigrationRequired($response); + + $response->assertRedirectTo('home'); + } + + public function testGetRequestToLoginIsNeverThrottled(): void + { + for ($i = 0; $i < 10; $i++) { + $response = $this->get('/login'); + $this->skipIfMigrationRequired($response); + $response->assertStatus(200); + } + } +} diff --git a/tests/Filters/ThrottleTest.php b/tests/Filters/ThrottleTest.php new file mode 100644 index 000000000..d6cfb8e81 --- /dev/null +++ b/tests/Filters/ThrottleTest.php @@ -0,0 +1,128 @@ +filter = new Throttle(); + } + + protected function tearDown(): void + { + $throttler = Services::throttler(); + + foreach ($this->usedKeys as $key) { + $throttler->remove($key); + } + + parent::tearDown(); + } + + private function makeRequest(string $method, string $ip, ?string $username = null): IncomingRequest + { + $request = $this->createMock(IncomingRequest::class); + + $request->method('getMethod')->willReturn($method); + $request->method('getIPAddress')->willReturn($ip); + $request->method('getPost')->with('username')->willReturn($username); + + $this->usedKeys[] = 'login-ip-' . $ip; + + if ($username !== null && $username !== '') { + $this->usedKeys[] = 'login-user-' . strtolower($username); + } + + return $request; + } + + public function testGetRequestsAreNotThrottled(): void + { + $request = $this->makeRequest('GET', '203.0.113.1', 'admin'); + + $result = $this->filter->before($request); + + $this->assertNull($result); + } + + public function testAllowsAttemptsUnderCapacity(): void + { + $ip = '203.0.113.2'; + + for ($i = 0; $i < 5; $i++) { + $request = $this->makeRequest('POST', $ip, 'employee1'); + $result = $this->filter->before($request); + + $this->assertNull($result, "Attempt {$i} should not be throttled"); + } + } + + public function testBlocksAfterCapacityExceededByIp(): void + { + $ip = '203.0.113.3'; + + for ($i = 0; $i < 5; $i++) { + $this->filter->before($this->makeRequest('POST', $ip, "user{$i}")); + } + + $result = $this->filter->before($this->makeRequest('POST', $ip, 'user-final')); + + $this->assertNotNull($result); + $this->assertSame(429, $result->getStatusCode()); + } + + public function testBlocksAfterCapacityExceededByUsername(): void + { + $username = 'sameuser'; + + for ($i = 0; $i < 5; $i++) { + $this->filter->before($this->makeRequest('POST', "203.0.113.{$i}", $username)); + } + + $result = $this->filter->before($this->makeRequest('POST', '203.0.113.99', $username)); + + $this->assertNotNull($result); + $this->assertSame(429, $result->getStatusCode()); + } + + public function testThrottledResponseIsJsonWithMessage(): void + { + $ip = '203.0.113.4'; + + for ($i = 0; $i < 5; $i++) { + $this->filter->before($this->makeRequest('POST', $ip, 'someone')); + } + + $result = $this->filter->before($this->makeRequest('POST', $ip, 'someone')); + + $body = json_decode($result->getJSON(), true); + + $this->assertFalse($body['success']); + $this->assertSame(lang('Login.too_many_attempts'), $body['message']); + } + + public function testMissingUsernameOnlyThrottlesByIp(): void + { + $ip = '203.0.113.5'; + + for ($i = 0; $i < 5; $i++) { + $this->filter->before($this->makeRequest('POST', $ip, null)); + } + + $result = $this->filter->before($this->makeRequest('POST', $ip, null)); + + $this->assertNotNull($result); + $this->assertSame(429, $result->getStatusCode()); + } +}