128 lines
4.9 KiB
PHP
128 lines
4.9 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Livewire\Auth\Login;
|
|
use App\Livewire\Auth\PasswordChange;
|
|
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;
|
|
|
|
/**
|
|
* Brute-force / rate-limit hardening (security audit follow-up).
|
|
*
|
|
* Each test starts with a cleared cache so the array-store RateLimiter buckets (keyed partly
|
|
* on the fixed test IP) don't leak between tests. The "still blocked with the CORRECT password"
|
|
* assertions are the point: a working limiter must refuse even valid credentials once tripped,
|
|
* which a non-working one would let straight through.
|
|
*/
|
|
class BruteForceHardeningTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
Cache::flush();
|
|
}
|
|
|
|
private const PW = 'Sup3rStr0ngPass!';
|
|
|
|
private const NEW_PW = 'An0therStr0ngPass!';
|
|
|
|
public function test_totp_code_cannot_be_replayed(): void
|
|
{
|
|
$user = User::factory()->create([
|
|
'two_factor_secret' => (new Google2FA)->generateSecretKey(),
|
|
'two_factor_confirmed_at' => now(),
|
|
]);
|
|
|
|
$otp = (new Google2FA)->getCurrentOtp($user->two_factor_secret);
|
|
|
|
$this->assertTrue($user->verifyTotp($otp), 'first use of a fresh code must succeed');
|
|
$this->assertFalse($user->fresh()->verifyTotp($otp), 'the same code must be rejected on replay');
|
|
}
|
|
|
|
public function test_per_account_login_throttle_blocks_even_a_correct_password(): void
|
|
{
|
|
User::factory()->create(['email' => 'admin@clusev.local', 'password' => Hash::make(self::PW)]);
|
|
|
|
for ($i = 0; $i < 5; $i++) {
|
|
Livewire::test(Login::class)
|
|
->set('email', 'admin@clusev.local')->set('password', 'wrong')->call('authenticate');
|
|
}
|
|
|
|
// 6th attempt with the RIGHT password must still be refused (per-(email+IP) cap = 5).
|
|
Livewire::test(Login::class)
|
|
->set('email', 'admin@clusev.local')->set('password', self::PW)->call('authenticate')
|
|
->assertHasErrors('email');
|
|
|
|
$this->assertGuest();
|
|
}
|
|
|
|
public function test_per_ip_login_throttle_blocks_a_flood_across_distinct_emails(): void
|
|
{
|
|
User::factory()->create(['email' => 'admin@clusev.local', 'password' => Hash::make(self::PW)]);
|
|
|
|
// 20 failures spread over distinct emails — the per-(email+IP) bucket never trips (1 each),
|
|
// but the per-IP bucket (20/min) does.
|
|
for ($i = 0; $i < 20; $i++) {
|
|
Livewire::test(Login::class)
|
|
->set('email', "flood{$i}@clusev.local")->set('password', 'wrong')->call('authenticate');
|
|
}
|
|
|
|
// A correct login for the real admin from the same IP is now blocked by the per-IP cap.
|
|
Livewire::test(Login::class)
|
|
->set('email', 'admin@clusev.local')->set('password', self::PW)->call('authenticate')
|
|
->assertHasErrors('email');
|
|
|
|
$this->assertGuest();
|
|
}
|
|
|
|
public function test_unknown_and_known_wrong_email_are_rejected_identically(): void
|
|
{
|
|
User::factory()->create(['email' => 'admin@clusev.local', 'password' => Hash::make(self::PW)]);
|
|
|
|
// Both a known-wrong-password and a non-existent address fail on the SAME field with the
|
|
// SAME generic auth.invalid_credentials message (by construction), and neither logs in —
|
|
// no account-existence signal in the response. (The matching bcrypt cost on the unknown
|
|
// branch, via DUMMY_HASH, closes the timing oracle; that is not asserted here because the
|
|
// test env uses BCRYPT_ROUNDS=4 while DUMMY_HASH is cost-12.)
|
|
foreach (['admin@clusev.local', 'ghost@clusev.local'] as $email) {
|
|
Livewire::test(Login::class)
|
|
->set('email', $email)->set('password', 'wrong')->call('authenticate')
|
|
->assertHasErrors('email');
|
|
}
|
|
|
|
$this->assertGuest();
|
|
}
|
|
|
|
public function test_password_reauth_is_throttled_against_a_hijacked_session(): void
|
|
{
|
|
$user = User::factory()->create([
|
|
'password' => Hash::make(self::PW),
|
|
'must_change_password' => true,
|
|
]);
|
|
$this->actingAs($user);
|
|
|
|
for ($i = 0; $i < 5; $i++) {
|
|
Livewire::test(PasswordChange::class)
|
|
->set('current', 'wrong')->set('password', self::NEW_PW)->set('password_confirmation', self::NEW_PW)
|
|
->call('update');
|
|
}
|
|
|
|
// 6th try with the CORRECT current password is still refused (reauth bucket tripped).
|
|
Livewire::test(PasswordChange::class)
|
|
->set('current', self::PW)->set('password', self::NEW_PW)->set('password_confirmation', self::NEW_PW)
|
|
->call('update')
|
|
->assertHasErrors('current');
|
|
|
|
// Password unchanged.
|
|
$this->assertTrue(Hash::check(self::PW, $user->fresh()->password));
|
|
}
|
|
}
|