feat(auth): feed BruteforceGuard from login + 2FA failures, audit them
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>feat/v1-foundation
parent
2936af82bd
commit
968d8c48c9
|
|
@ -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')]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Auth\Login;
|
||||
use App\Livewire\Auth\TwoFactorChallenge;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Livewire;
|
||||
use PragmaRX\Google2FAQRCode\Google2FA;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BruteforceHooksTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
Setting::put('bruteforce_maxretry', '3');
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive request()->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']);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue