213 lines
7.2 KiB
PHP
213 lines
7.2 KiB
PHP
|
|
<?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();
|
||
|
|
});
|