Files
portal/app/tests/Feature/Auth/RateLimitTest.php
T

213 lines
7.2 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use PragmaRX\Google2FA\Google2FA;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
// CACHE_STORE=array, но процесс PHPUnit один — на всякий случай чистим.
RateLimiter::clear('auth:login:locked@example.ru|127.0.0.1');
});
test('login: после 5 неуспешных попыток 6-я возвращает 429 + Retry-After', function () {
User::factory()->create([
'tenant_id' => $this->tenant->id,
'email' => 'locked@example.ru',
'password_hash' => Hash::make('right-pass'),
]);
// 5 неудач → каждая 422.
for ($i = 1; $i <= 5; $i++) {
$r = $this->postJson('/api/auth/login', [
'email' => 'locked@example.ru',
'password' => 'wrong-pass-'.$i,
]);
expect($r->status())->toBe(422);
}
// 6-я с правильным паролем — всё равно 429 (lockout).
$r = $this->postJson('/api/auth/login', [
'email' => 'locked@example.ru',
'password' => 'right-pass',
]);
$r->assertStatus(429);
expect($r->headers->get('Retry-After'))->not->toBeNull();
expect((int) $r->headers->get('Retry-After'))->toBeGreaterThan(0);
expect((int) $r->headers->get('Retry-After'))->toBeLessThanOrEqual(900);
expect($r->json('retry_after'))->toBeGreaterThan(0);
expect($r->json('message'))->toContain('Слишком много попыток');
});
test('login: успешный вход чистит throttle-счётчик', function () {
User::factory()->create([
'tenant_id' => $this->tenant->id,
'email' => 'success@example.ru',
'password_hash' => Hash::make('right-pass'),
]);
// 4 неудачи (под лимитом).
for ($i = 1; $i <= 4; $i++) {
$this->postJson('/api/auth/login', [
'email' => 'success@example.ru',
'password' => 'wrong-'.$i,
])->assertStatus(422);
}
// 5-я — успешный → счётчик чистится.
$this->postJson('/api/auth/login', [
'email' => 'success@example.ru',
'password' => 'right-pass',
])->assertOk();
// Logout чтобы не мешать следующему login (Pest cookie-jar).
$this->postJson('/api/auth/logout');
// 5 новых неудач должны проходить (а не сразу падать в 429).
for ($i = 1; $i <= 5; $i++) {
$r = $this->postJson('/api/auth/login', [
'email' => 'success@example.ru',
'password' => 'still-wrong-'.$i,
]);
expect($r->status())->toBe(422);
}
// А вот 6-я уже должна быть 429.
$this->postJson('/api/auth/login', [
'email' => 'success@example.ru',
'password' => 'still-wrong-final',
])->assertStatus(429);
});
test('login: throttle ключ изолирован по email — два разных user не блокируют друг друга', function () {
User::factory()->create([
'tenant_id' => $this->tenant->id,
'email' => 'alice@example.ru',
'password_hash' => Hash::make('alice-pass'),
]);
User::factory()->create([
'tenant_id' => $this->tenant->id,
'email' => 'bob@example.ru',
'password_hash' => Hash::make('bob-pass'),
]);
// Alice — 5 неудач.
for ($i = 1; $i <= 5; $i++) {
$this->postJson('/api/auth/login', [
'email' => 'alice@example.ru',
'password' => 'wrong-pass-attempt',
])->assertStatus(422);
}
$aliceKey = 'auth:login:alice@example.ru|127.0.0.1';
expect(RateLimiter::attempts($aliceKey))->toBe(5);
expect(RateLimiter::tooManyAttempts($aliceKey, 5))->toBeTrue();
// 6-я попытка Alice (даже с правильным паролем) → 429.
$this->postJson('/api/auth/login', [
'email' => 'alice@example.ru',
'password' => 'alice-pass',
])->assertStatus(429);
// Bob — нет.
$this->postJson('/api/auth/login', [
'email' => 'bob@example.ru',
'password' => 'bob-pass',
])->assertOk();
});
test('login: account_locked (is_active=false) тоже расходует попытки', function () {
User::factory()->create([
'tenant_id' => $this->tenant->id,
'email' => 'inactive@example.ru',
'password_hash' => Hash::make('right-pass'),
'is_active' => false,
]);
// 5 раз получаем 422 «Аккаунт заблокирован».
for ($i = 1; $i <= 5; $i++) {
$this->postJson('/api/auth/login', [
'email' => 'inactive@example.ru',
'password' => 'right-pass',
])->assertStatus(422);
}
// 6-я → 429.
$this->postJson('/api/auth/login', [
'email' => 'inactive@example.ru',
'password' => 'right-pass',
])->assertStatus(429);
});
test('2FA verify: после 5 неверных кодов 6-й возвращает 429', function () {
$google2fa = new Google2FA;
$secret = $google2fa->generateSecretKey();
User::factory()->create([
'tenant_id' => $this->tenant->id,
'email' => '2fa-locked@example.ru',
'password_hash' => Hash::make('right-pass'),
'totp_enabled' => true,
'totp_secret' => $secret,
]);
// Login → pending_user_id в session (Pest cookie-jar держит её между запросами).
$this->postJson('/api/auth/login', [
'email' => '2fa-locked@example.ru',
'password' => 'right-pass',
])->assertOk()->assertJsonPath('requires_2fa', true);
// 5 неверных кодов.
for ($i = 0; $i < 5; $i++) {
$r = $this->postJson('/api/auth/2fa/verify', [
'code' => '000000',
]);
expect($r->status())->toBe(422);
}
// 6-я попытка даже с правильным TOTP — 429.
$validCode = $google2fa->getCurrentOtp($secret);
$r = $this->postJson('/api/auth/2fa/verify', [
'code' => $validCode,
]);
$r->assertStatus(429);
expect($r->headers->get('Retry-After'))->not->toBeNull();
});
test('2FA verify: успех чистит throttle', function () {
$google2fa = new Google2FA;
$secret = $google2fa->generateSecretKey();
User::factory()->create([
'tenant_id' => $this->tenant->id,
'email' => '2fa-success@example.ru',
'password_hash' => Hash::make('right-pass'),
'totp_enabled' => true,
'totp_secret' => $secret,
]);
$this->postJson('/api/auth/login', [
'email' => '2fa-success@example.ru',
'password' => 'right-pass',
])->assertOk();
// 3 неудачи (под лимитом).
for ($i = 0; $i < 3; $i++) {
$this->postJson('/api/auth/2fa/verify', ['code' => '000000'])
->assertStatus(422);
}
// Успех.
$this->postJson('/api/auth/2fa/verify', [
'code' => $google2fa->getCurrentOtp($secret),
])->assertOk();
});