feat(auth): block admin login IP after 4 failures for 20 min, email login alerts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fe68171aa7
commit
e34362b9bd
@@ -4,8 +4,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\Admin;
|
||||
|
||||
use App\Mail\Admin\AdminLoginAlertMail;
|
||||
use App\Models\AdminLoginBlock;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\AdminLoginAlert;
|
||||
use Database\Seeders\TemplateSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class LoginThrottleTest extends TestCase
|
||||
@@ -15,26 +24,79 @@ class LoginThrottleTest extends TestCase
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
// Đảm bảo state rate-limit sạch, không bị ảnh hưởng bởi request từ test khác
|
||||
// chạy cùng tiến trình PHPUnit (cache 'array' không tự reset giữa các test file).
|
||||
Cache::flush();
|
||||
Mail::fake();
|
||||
(new TemplateSeeder())->seedScenarioIfMissing('admin_login_alert');
|
||||
}
|
||||
|
||||
public function test_admin_login_is_throttled_after_5_attempts_per_minute(): void
|
||||
private function login(string $username, string $password)
|
||||
{
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$response = $this->postJson('/api/v1/administrator/login', [
|
||||
'username' => 'nobody',
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
$this->assertNotSame(429, $response->status());
|
||||
return $this->postJson('/api/v1/administrator/login', ['username' => $username, 'password' => $password]);
|
||||
}
|
||||
|
||||
private function staff(): User
|
||||
{
|
||||
return User::factory()->create(['username' => 'staff', 'password' => Hash::make('correct-password-123')]);
|
||||
}
|
||||
|
||||
public function test_ip_is_blocked_for_20_minutes_after_more_than_4_failures(): void
|
||||
{
|
||||
$this->staff();
|
||||
|
||||
for ($i = 4; $i >= 1; $i--) {
|
||||
$this->login('staff', 'wrong')->assertUnauthorized()->assertJsonPath('remaining_attempts', $i);
|
||||
}
|
||||
|
||||
$response = $this->postJson('/api/v1/administrator/login', [
|
||||
'username' => 'nobody',
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
$this->login('staff', 'wrong')->assertStatus(429);
|
||||
$this->login('staff', 'correct-password-123')->assertStatus(429);
|
||||
$this->login('someone-else', 'whatever')->assertStatus(429);
|
||||
|
||||
$response->assertStatus(429);
|
||||
$this->travel(21)->minutes();
|
||||
$this->login('staff', 'correct-password-123')->assertOk();
|
||||
$this->assertSame(0, AdminLoginBlock::count());
|
||||
}
|
||||
|
||||
public function test_successful_login_alerts_configured_recipients(): void
|
||||
{
|
||||
$this->staff();
|
||||
|
||||
$this->login('staff', 'correct-password-123')->assertOk();
|
||||
Mail::assertQueued(AdminLoginAlertMail::class, fn ($mail) => $mail->hasTo('web@tgs.com.vn'));
|
||||
|
||||
Setting::updateOrCreate(['key' => 'email_settings'], ['value' => json_encode([AdminLoginAlert::SETTING_FIELD => ['it@tgs.com.vn']])]);
|
||||
$this->login('staff', 'correct-password-123')->assertOk();
|
||||
Mail::assertQueued(AdminLoginAlertMail::class, fn ($mail) => $mail->hasTo('it@tgs.com.vn'));
|
||||
}
|
||||
|
||||
public function test_empty_recipient_list_disables_alert(): void
|
||||
{
|
||||
$this->staff();
|
||||
Setting::updateOrCreate(['key' => 'email_settings'], ['value' => json_encode([AdminLoginAlert::SETTING_FIELD => []])]);
|
||||
|
||||
$this->login('staff', 'correct-password-123')->assertOk();
|
||||
Mail::assertNothingQueued();
|
||||
}
|
||||
|
||||
public function test_admin_can_list_and_unblock_ip(): void
|
||||
{
|
||||
$this->staff();
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->login('staff', 'wrong');
|
||||
}
|
||||
|
||||
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('Full Administrator');
|
||||
|
||||
$id = $this->actingAs($admin, 'admin')->getJson('/api/v1/administrator/login-blocks')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.items.0.ip', '127.0.0.1')
|
||||
->assertJsonPath('data.items.0.last_username', 'staff')
|
||||
->json('data.items.0.id');
|
||||
|
||||
$this->actingAs($admin, 'admin')->deleteJson("/api/v1/administrator/login-blocks/{$id}")->assertOk();
|
||||
$this->app['auth']->forgetGuards();
|
||||
|
||||
$this->login('staff', 'correct-password-123')->assertOk();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ class PermissionGatingTest extends TestCase
|
||||
return [
|
||||
'settings index' => ['GET', '/api/v1/administrator/settings', 'setting.manager'],
|
||||
'client-menu index' => ['GET', '/api/v1/administrator/client-menu', 'setting.manager'],
|
||||
'login-blocks index' => ['GET', '/api/v1/administrator/login-blocks', 'setting.manager'],
|
||||
'banks index' => ['GET', '/api/v1/administrator/banks', 'setting.manager'],
|
||||
'domain-pricing index' => ['GET', '/api/v1/administrator/domain-pricing', 'setting.manager'],
|
||||
'email-templates index' => ['GET', '/api/v1/administrator/email-templates', 'email-template.manager'],
|
||||
|
||||
Reference in New Issue
Block a user