c39d555e6f
- AuthController::useRecoveryCode перебирает unused codes через Hash::check, нормализация (lowercase + remove dash/space)
- UserRecoveryCode Eloquent (UPDATED_AT=null — schema без updated_at)
- Rate-limit auth:recovery:{pending_user_id}|{ip} (5/15мин)
- Returns recovery_codes_remaining для UI-warning'а (sessionStorage на frontend)
- UseRecoveryCodeView.vue → POST /api/auth/2fa/recovery-use, /recovery-use route, autocomplete=one-time-code
- TwoFactorView "резервный код" ссылка /recovery → /recovery-use
- Pest +6 RecoveryCodeTest (91/91 за 12.77с, 319 assertions)
- Vitest +6 (166/166 за 11.47с)
- TODO: #3 2FA setup wizard (после этого /recovery view получит реальный source данных)
- Регресс: lint+type+format OK; build 849ms; story:build 21/28 за 30.36с; Pint+Stan passed
- CLAUDE.md v1.38→v1.39, реестр v1.47→v1.48
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
125 lines
4.2 KiB
PHP
125 lines
4.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\DB;
|
||
use Illuminate\Support\Facades\Hash;
|
||
use Tests\TestCase;
|
||
|
||
uses(DatabaseTransactions::class);
|
||
|
||
beforeEach(function () {
|
||
$this->tenant = Tenant::factory()->create();
|
||
$this->user = User::factory()->create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'email' => 'recovery-user@example.ru',
|
||
'password_hash' => Hash::make('right-password-1234'),
|
||
'totp_enabled' => true,
|
||
'totp_secret' => 'JBSWY3DPEHPK3PXP', // dummy
|
||
]);
|
||
// 4 неиспользованных кодов + 1 использованный.
|
||
$codes = ['ABCD-1234', 'ZZZZ-9999', 'qqqq-7777', 'XXXX-5555', 'used-code'];
|
||
$this->plainCodes = ['abcd1234', 'zzzz9999', 'qqqq7777', 'xxxx5555', 'usedcode'];
|
||
foreach ($codes as $i => $code) {
|
||
DB::table('user_recovery_codes')->insert([
|
||
'user_id' => $this->user->id,
|
||
'code_hash' => Hash::make(mb_strtolower(str_replace('-', '', $code))),
|
||
'used_at' => $i === 4 ? now() : null,
|
||
]);
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Helper: эмулировать pending-2FA state (как после успешного login).
|
||
*/
|
||
function startPending(TestCase $self, string $email = 'recovery-user@example.ru'): void
|
||
{
|
||
$self->postJson('/api/auth/login', [
|
||
'email' => $email,
|
||
'password' => 'right-password-1234',
|
||
])->assertOk()->assertJsonPath('requires_2fa', true);
|
||
}
|
||
|
||
test('POST /api/auth/2fa/recovery-use завершает login по правильному коду + помечает used + считает remaining', function () {
|
||
startPending($this);
|
||
|
||
$r = $this->postJson('/api/auth/2fa/recovery-use', [
|
||
'code' => 'ABCD-1234',
|
||
]);
|
||
|
||
$r->assertOk();
|
||
expect($r->json('user.email'))->toBe('recovery-user@example.ru');
|
||
expect($r->json('requires_2fa'))->toBeFalse();
|
||
// Осталось 3 неиспользованных (4 минус первый, который мы только что use'нули).
|
||
expect($r->json('recovery_codes_remaining'))->toBe(3);
|
||
|
||
// Code marked as used.
|
||
$usedCount = DB::table('user_recovery_codes')
|
||
->where('user_id', $this->user->id)
|
||
->whereNotNull('used_at')
|
||
->count();
|
||
expect($usedCount)->toBe(2); // 1 был изначально + 1 новый.
|
||
});
|
||
|
||
test('POST /api/auth/2fa/recovery-use 422 при неверном коде', function () {
|
||
startPending($this);
|
||
|
||
$r = $this->postJson('/api/auth/2fa/recovery-use', [
|
||
'code' => 'WRONG-CODE-9999',
|
||
]);
|
||
|
||
$r->assertStatus(422);
|
||
expect($r->json('errors.code'))->not->toBeEmpty();
|
||
});
|
||
|
||
test('POST /api/auth/2fa/recovery-use НЕ принимает уже использованный код', function () {
|
||
startPending($this);
|
||
|
||
$r = $this->postJson('/api/auth/2fa/recovery-use', [
|
||
'code' => 'used-code',
|
||
]);
|
||
|
||
$r->assertStatus(422);
|
||
});
|
||
|
||
test('POST /api/auth/2fa/recovery-use 422 без pending-сессии', function () {
|
||
// Не делаем login — нет auth.pending_user_id в session.
|
||
$r = $this->postJson('/api/auth/2fa/recovery-use', [
|
||
'code' => 'ABCD-1234',
|
||
]);
|
||
|
||
$r->assertStatus(422);
|
||
expect($r->json('message'))->toContain('Сессия 2FA');
|
||
});
|
||
|
||
test('POST /api/auth/2fa/recovery-use принимает разные форматы (с дефисом, без, разный регистр)', function () {
|
||
startPending($this);
|
||
|
||
// 'ZZZZ-9999' нормализуется в 'zzzz9999' → совпадает с stored.
|
||
$r = $this->postJson('/api/auth/2fa/recovery-use', [
|
||
'code' => 'zzzz 9999', // пробел вместо дефиса
|
||
]);
|
||
|
||
$r->assertOk();
|
||
});
|
||
|
||
test('POST /api/auth/2fa/recovery-use rate-limit: 5 неверных → 6-я = 429', function () {
|
||
startPending($this);
|
||
|
||
for ($i = 1; $i <= 5; $i++) {
|
||
$this->postJson('/api/auth/2fa/recovery-use', [
|
||
'code' => 'BAD-FAIL-'.$i,
|
||
])->assertStatus(422);
|
||
}
|
||
|
||
$r = $this->postJson('/api/auth/2fa/recovery-use', [
|
||
'code' => 'ABCD-1234', // правильный, но уже locked.
|
||
]);
|
||
|
||
$r->assertStatus(429);
|
||
expect($r->headers->get('Retry-After'))->not->toBeNull();
|
||
});
|