diff --git a/app/Livewire/Auth/Login.php b/app/Livewire/Auth/Login.php index 3e63c3f..929f298 100644 --- a/app/Livewire/Auth/Login.php +++ b/app/Livewire/Auth/Login.php @@ -2,7 +2,9 @@ namespace App\Livewire\Auth; +use App\Models\AuditEvent; use App\Models\User; +use App\Services\BruteforceGuard; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\RateLimiter; @@ -64,6 +66,14 @@ class Login extends Component RateLimiter::hit($pairKey, 60); // 5 / minute RateLimiter::hit($ipKey, 60); // 20 / minute RateLimiter::hit($acctKey, 900); // 30 / 15 minutes + app(BruteforceGuard::class)->record(request()->ip(), 'login'); + AuditEvent::create([ + 'user_id' => null, + 'actor' => 'system', + 'action' => 'auth.login_failed', + 'target' => $this->email, + 'ip' => request()->ip(), + ]); throw ValidationException::withMessages(['email' => __('auth.invalid_credentials')]); } diff --git a/app/Livewire/Auth/TwoFactorBackup.php b/app/Livewire/Auth/TwoFactorBackup.php index a40a9c9..30935ba 100644 --- a/app/Livewire/Auth/TwoFactorBackup.php +++ b/app/Livewire/Auth/TwoFactorBackup.php @@ -38,7 +38,7 @@ class TwoFactorBackup extends Component // Backup-only view: a one-time recovery code is the sole accepted credential (no TOTP). if (! $user->useRecoveryCode($this->code)) { - $this->hitRateLimit(); + $this->recordFailedAttempt('2fa'); throw ValidationException::withMessages(['code' => __('auth.invalid_code')]); } diff --git a/app/Livewire/Auth/TwoFactorChallenge.php b/app/Livewire/Auth/TwoFactorChallenge.php index 663426b..1f5ffc4 100644 --- a/app/Livewire/Auth/TwoFactorChallenge.php +++ b/app/Livewire/Auth/TwoFactorChallenge.php @@ -49,7 +49,7 @@ class TwoFactorChallenge extends Component $valid = $user->verifyTotp($this->code) || $user->useRecoveryCode($this->code); if (! $valid) { - $this->hitRateLimit(); + $this->recordFailedAttempt('2fa'); throw ValidationException::withMessages(['code' => __('auth.invalid_code')]); } @@ -75,7 +75,7 @@ class TwoFactorChallenge extends Component $user = User::find(session('2fa.user')); if (! $user || ! $user->hasTwoFactorEnabled() || ! $webauthn->available() || ! $webauthn->verifyAssertion($user, $assertion)) { - $this->hitRateLimit(); + $this->recordFailedAttempt('2fa'); throw ValidationException::withMessages(['code' => __('auth.webauthn_failed')]); } diff --git a/app/Livewire/Concerns/CompletesTwoFactorChallenge.php b/app/Livewire/Concerns/CompletesTwoFactorChallenge.php index 0f386de..2f1d840 100644 --- a/app/Livewire/Concerns/CompletesTwoFactorChallenge.php +++ b/app/Livewire/Concerns/CompletesTwoFactorChallenge.php @@ -2,7 +2,9 @@ namespace App\Livewire\Concerns; +use App\Models\AuditEvent; use App\Models\User; +use App\Services\BruteforceGuard; use App\Services\WebauthnService; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\RateLimiter; @@ -93,6 +95,21 @@ trait CompletesTwoFactorChallenge } } + /** A failed 2FA attempt: throttle (Layer 1) + feed the persistent ban (Layer 2) + audit. */ + protected function recordFailedAttempt(string $reason): void + { + $this->hitRateLimit(); + $ip = request()->ip(); + app(BruteforceGuard::class)->record($ip, $reason); + AuditEvent::create([ + 'user_id' => null, + 'actor' => 'system', + 'action' => 'auth.2fa_failed', + 'target' => (string) session('2fa.user'), + 'ip' => $ip, + ]); + } + /** Log the verified pending user in and finish the challenge (shared success tail). */ protected function completeLogin(User $user): mixed { diff --git a/tests/Feature/BruteforceHooksTest.php b/tests/Feature/BruteforceHooksTest.php new file mode 100644 index 0000000..97b6a5b --- /dev/null +++ b/tests/Feature/BruteforceHooksTest.php @@ -0,0 +1,85 @@ +ip() to a public IP for every Livewire request in this test. + * + * Livewire's RequestBroker creates a fresh Symfony request (REMOTE_ADDR=127.0.0.1) + * for every HTTP call, then the kernel binds it via app()->instance('request', $req). + * The rebinding callback fires on every such binding, letting us mutate the already- + * created request before component code reads request()->ip(). + * + * The app container is re-created between test methods (RefreshDatabase + setUp + * boot a fresh app each time), so rebinding callbacks do not leak across tests. + */ + private function spoofIp(string $ip): void + { + app()->rebinding('request', static function ($app, $request) use ($ip) { + $request->server->set('REMOTE_ADDR', $ip); + }); + } + + public function test_failed_logins_ban_the_ip_at_threshold(): void + { + User::factory()->create(['email' => 'admin@clusev.local', 'password' => Hash::make('correct-horse')]); + + // Drive request()->ip() to a PUBLIC IP (203.0.113.5, RFC-5737 TEST-NET). + // 127.0.0.1 is hard-exempt in BruteforceGuard; using a loopback IP would make + // record() no-op and the ban assertion would never hold. + $this->spoofIp('203.0.113.5'); + + for ($i = 0; $i < 3; $i++) { + Livewire::test(Login::class) + ->set('email', 'admin@clusev.local') + ->set('password', 'wrong') + ->call('authenticate'); + } + + $this->assertDatabaseHas('banned_ips', ['ip' => '203.0.113.5', 'reason' => 'login']); + $this->assertDatabaseHas('audit_events', ['action' => 'auth.login_failed']); + } + + public function test_failed_2fa_codes_ban_the_ip(): void + { + $user = User::factory()->create([ + 'two_factor_secret' => (new Google2FA)->generateSecretKey(), + 'two_factor_confirmed_at' => now(), + ]); + + // 203.0.113.6 — separate public IP to avoid cross-test cache collisions. + // maxretry=3 (setUp) < 2FA per-(user+IP) rate-limit (5), so ban triggers first. + $this->spoofIp('203.0.113.6'); + + for ($i = 0; $i < 3; $i++) { + session()->put('2fa.user', $user->id); + Livewire::test(TwoFactorChallenge::class) + ->set('code', '000000') + ->call('verify'); + } + + $this->assertDatabaseHas('banned_ips', ['ip' => '203.0.113.6', 'reason' => '2fa']); + } +}