d1e4237e2f
- AuthController::isIpLockedOut: count login_failed за час с IP, ≥10 → 429 + Retry-After: 3600
- logAuthEvent: 3 ветки failure_reason (invalid_password / unknown_email / account_locked)
- DB::table('auth_log')->insert; hash-chain trigger заполняет log_hash (OPEN-И-15)
- Защита поверх email-rate-limit: один IP не сможет перебирать множество email'ов
- Pest +6 IpLockoutTest (107/107 за 13.86с, 380 assertions)
- Регресс: Pint+Stan passed
- CLAUDE.md v1.40→v1.41, реестр v1.49→v1.50
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
155 lines
5.1 KiB
PHP
155 lines
5.1 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\Tenant;
|
||
use App\Models\User;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Hash;
|
||
|
||
uses(DatabaseTransactions::class);
|
||
|
||
beforeEach(function () {
|
||
$this->tenant = Tenant::factory()->create();
|
||
});
|
||
|
||
test('login_success пишет запись в auth_log', function () {
|
||
User::factory()->create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'email' => 'log-success@example.ru',
|
||
'password_hash' => Hash::make('right-password'),
|
||
]);
|
||
|
||
$this->postJson('/api/auth/login', [
|
||
'email' => 'log-success@example.ru',
|
||
'password' => 'right-password',
|
||
])->assertOk();
|
||
|
||
$row = DB::table('auth_log')
|
||
->where('email', 'log-success@example.ru')
|
||
->where('event', 'login_success')
|
||
->first();
|
||
|
||
expect($row)->not->toBeNull();
|
||
expect($row->actor_type)->toBe('tenant_user');
|
||
expect($row->tenant_id)->toBe($this->tenant->id);
|
||
});
|
||
|
||
test('login_failed (wrong password) пишет в auth_log с failure_reason=invalid_password', function () {
|
||
User::factory()->create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'email' => 'log-fail@example.ru',
|
||
'password_hash' => Hash::make('right-password'),
|
||
]);
|
||
|
||
$this->postJson('/api/auth/login', [
|
||
'email' => 'log-fail@example.ru',
|
||
'password' => 'wrong-pass-attempt',
|
||
])->assertStatus(422);
|
||
|
||
$row = DB::table('auth_log')
|
||
->where('email', 'log-fail@example.ru')
|
||
->where('event', 'login_failed')
|
||
->first();
|
||
|
||
expect($row)->not->toBeNull();
|
||
expect($row->failure_reason)->toBe('invalid_password');
|
||
});
|
||
|
||
test('login_failed для unknown email пишет с failure_reason=unknown_email', function () {
|
||
$this->postJson('/api/auth/login', [
|
||
'email' => 'nobody@example.ru',
|
||
'password' => 'wrong-pass-attempt',
|
||
])->assertStatus(422);
|
||
|
||
$row = DB::table('auth_log')
|
||
->where('email', 'nobody@example.ru')
|
||
->where('event', 'login_failed')
|
||
->first();
|
||
|
||
expect($row)->not->toBeNull();
|
||
expect($row->failure_reason)->toBe('unknown_email');
|
||
expect($row->user_id)->toBeNull();
|
||
});
|
||
|
||
test('IP-lockout: после 10 login_failed с одного IP за час — следующий → 429', function () {
|
||
User::factory()->create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'email' => 'ip-lockout-victim@example.ru',
|
||
'password_hash' => Hash::make('right-password'),
|
||
]);
|
||
|
||
// Эмулируем 10 неудачных попыток с разных email с одного IP
|
||
// (через прямой INSERT в auth_log, чтобы не триггерить email-rate-limit).
|
||
for ($i = 0; $i < 10; $i++) {
|
||
DB::table('auth_log')->insert([
|
||
'actor_type' => 'tenant_user',
|
||
'event' => 'login_failed',
|
||
'email' => "victim{$i}@example.ru",
|
||
'ip_address' => '127.0.0.1',
|
||
'failure_reason' => 'unknown_email',
|
||
'created_at' => now()->subMinutes(5),
|
||
]);
|
||
}
|
||
|
||
// Следующий login (даже с правильным паролем) → 429.
|
||
$r = $this->postJson('/api/auth/login', [
|
||
'email' => 'ip-lockout-victim@example.ru',
|
||
'password' => 'right-password',
|
||
]);
|
||
|
||
$r->assertStatus(429);
|
||
expect($r->json('message'))->toContain('с этого IP');
|
||
expect($r->headers->get('Retry-After'))->toBe('3600');
|
||
});
|
||
|
||
test('IP-lockout не срабатывает на 9 неудач (под порогом)', function () {
|
||
User::factory()->create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'email' => 'ok@example.ru',
|
||
'password_hash' => Hash::make('right-password'),
|
||
]);
|
||
|
||
for ($i = 0; $i < 9; $i++) {
|
||
DB::table('auth_log')->insert([
|
||
'actor_type' => 'tenant_user',
|
||
'event' => 'login_failed',
|
||
'email' => "any{$i}@example.ru",
|
||
'ip_address' => '127.0.0.1',
|
||
'failure_reason' => 'unknown_email',
|
||
'created_at' => now()->subMinutes(5),
|
||
]);
|
||
}
|
||
|
||
$this->postJson('/api/auth/login', [
|
||
'email' => 'ok@example.ru',
|
||
'password' => 'right-password',
|
||
])->assertOk();
|
||
});
|
||
|
||
test('IP-lockout: окно 1 час — старые записи (>1ч) не блокируют', function () {
|
||
User::factory()->create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'email' => 'old-fails@example.ru',
|
||
'password_hash' => Hash::make('right-password'),
|
||
]);
|
||
|
||
// 15 неудач, но старше часа → не учитываются.
|
||
for ($i = 0; $i < 15; $i++) {
|
||
DB::table('auth_log')->insert([
|
||
'actor_type' => 'tenant_user',
|
||
'event' => 'login_failed',
|
||
'email' => "old{$i}@example.ru",
|
||
'ip_address' => '127.0.0.1',
|
||
'failure_reason' => 'unknown_email',
|
||
'created_at' => now()->subHours(2),
|
||
]);
|
||
}
|
||
|
||
$this->postJson('/api/auth/login', [
|
||
'email' => 'old-fails@example.ru',
|
||
'password' => 'right-password',
|
||
])->assertOk();
|
||
});
|