73e64128dc
- TwoFactorSetupController (auth:sanctum): /api/2fa/{init,confirm,disable,regenerate-recovery-codes}
- init секрет в session (не в БД), QR-URL otpauth://; confirm активирует 2FA + 8 recovery codes
- disable/regenerate требуют password-confirmation
- User.casts: totp_secret => encrypted
Schema v8.7→v8.8: users.totp_secret VARCHAR(255) → TEXT (encrypted ~256 chars)
Migration fix: explicit ALTER TABLE webhook_dedup_keys ADD FK после DB::unprepared (PDO глотал FK на partitioned)
PartitionsCreateMonthsTest fix: DETACH PARTITION + DROP вместо DROP CASCADE
Frontend: SecurityTab реальная логика (setup wizard 3 шага, disable, regenerate dialogs)
- Pest +10 (101/101 за 13.37с, 364 assertions)
- Vitest 166/166
- CLAUDE.md v1.39→v1.40, реестр v1.48→v1.49, schema v8.7→v8.8
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
156 lines
5.8 KiB
PHP
156 lines
5.8 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\Tenant;
|
||
use App\Models\User;
|
||
use App\Models\UserRecoveryCode;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Hash;
|
||
use PragmaRX\Google2FA\Google2FA;
|
||
|
||
uses(DatabaseTransactions::class);
|
||
|
||
beforeEach(function () {
|
||
$this->tenant = Tenant::factory()->create();
|
||
$this->user = User::factory()->create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'email' => 'setup-2fa@example.ru',
|
||
'password_hash' => Hash::make('master-password-9876'),
|
||
'totp_enabled' => false,
|
||
'totp_secret' => null,
|
||
]);
|
||
$this->actingAs($this->user);
|
||
});
|
||
|
||
test('POST /api/2fa/init возвращает secret + qr_url и кладёт secret в session', function () {
|
||
$r = $this->postJson('/api/2fa/init');
|
||
|
||
$r->assertOk();
|
||
expect($r->json('secret'))->not->toBeEmpty();
|
||
expect($r->json('qr_url'))->toContain('otpauth://totp/');
|
||
expect($r->json('qr_url'))->toContain(rawurlencode('Лидерра'));
|
||
});
|
||
|
||
test('POST /api/2fa/init 422 если 2FA уже включена', function () {
|
||
$this->user->forceFill(['totp_enabled' => true, 'totp_secret' => 'EXISTSECRET'])->save();
|
||
|
||
$r = $this->postJson('/api/2fa/init');
|
||
|
||
$r->assertStatus(422);
|
||
});
|
||
|
||
test('POST /api/2fa/confirm с правильным кодом активирует 2FA + возвращает 8 recovery-кодов', function () {
|
||
$this->postJson('/api/2fa/init')->assertOk();
|
||
$secret = session('auth.pending_totp_secret');
|
||
expect($secret)->not->toBeEmpty();
|
||
|
||
$google2fa = new Google2FA;
|
||
$code = $google2fa->getCurrentOtp($secret);
|
||
|
||
$r = $this->postJson('/api/2fa/confirm', ['code' => $code]);
|
||
|
||
$r->assertOk();
|
||
expect($r->json('recovery_codes'))->toHaveCount(8);
|
||
foreach ($r->json('recovery_codes') as $plainCode) {
|
||
// Формат XXXX-XXXX (8 hex + дефис посередине = 9 chars).
|
||
expect($plainCode)->toMatch('/^[a-z0-9]{4}-[a-z0-9]{4}$/');
|
||
}
|
||
|
||
// Запись в users применилась.
|
||
$this->user->refresh();
|
||
expect($this->user->totp_enabled)->toBeTrue();
|
||
// totp_secret через encrypted-cast возвращает plain.
|
||
expect($this->user->totp_secret)->toBe($secret);
|
||
|
||
// Ровно 8 неиспользованных recovery-кодов в БД.
|
||
expect(UserRecoveryCode::query()->where('user_id', $this->user->id)->count())->toBe(8);
|
||
});
|
||
|
||
test('POST /api/2fa/confirm с неверным кодом → 422 и 2FA остаётся выключенной', function () {
|
||
$this->postJson('/api/2fa/init')->assertOk();
|
||
|
||
$r = $this->postJson('/api/2fa/confirm', ['code' => '000000']);
|
||
|
||
$r->assertStatus(422);
|
||
$this->user->refresh();
|
||
expect($this->user->totp_enabled)->toBeFalse();
|
||
});
|
||
|
||
test('POST /api/2fa/confirm без init → 422', function () {
|
||
$r = $this->postJson('/api/2fa/confirm', ['code' => '123456']);
|
||
|
||
$r->assertStatus(422);
|
||
expect($r->json('message'))->toContain('Не начат setup');
|
||
});
|
||
|
||
test('POST /api/2fa/disable с правильным паролем выключает 2FA + удаляет recovery codes', function () {
|
||
// Включаем 2FA через wizard.
|
||
$this->postJson('/api/2fa/init')->assertOk();
|
||
$secret = session('auth.pending_totp_secret');
|
||
$code = (new Google2FA)->getCurrentOtp($secret);
|
||
$this->postJson('/api/2fa/confirm', ['code' => $code])->assertOk();
|
||
|
||
expect(UserRecoveryCode::query()->where('user_id', $this->user->id)->count())->toBe(8);
|
||
|
||
$r = $this->postJson('/api/2fa/disable', ['password' => 'master-password-9876']);
|
||
|
||
$r->assertOk();
|
||
$this->user->refresh();
|
||
expect($this->user->totp_enabled)->toBeFalse();
|
||
expect($this->user->totp_secret)->toBeNull();
|
||
expect(UserRecoveryCode::query()->where('user_id', $this->user->id)->count())->toBe(0);
|
||
});
|
||
|
||
test('POST /api/2fa/disable с неверным паролем → 422', function () {
|
||
$this->user->forceFill(['totp_enabled' => true, 'totp_secret' => 'XXX'])->save();
|
||
|
||
$r = $this->postJson('/api/2fa/disable', ['password' => 'wrong-password']);
|
||
|
||
$r->assertStatus(422);
|
||
});
|
||
|
||
test('POST /api/2fa/regenerate-recovery-codes возвращает новые 8 кодов', function () {
|
||
// Сначала setup 2FA.
|
||
$this->postJson('/api/2fa/init')->assertOk();
|
||
$secret = session('auth.pending_totp_secret');
|
||
$code = (new Google2FA)->getCurrentOtp($secret);
|
||
$confirm = $this->postJson('/api/2fa/confirm', ['code' => $code])->assertOk();
|
||
|
||
$oldCodes = $confirm->json('recovery_codes');
|
||
$oldHashes = DB::table('user_recovery_codes')
|
||
->where('user_id', $this->user->id)
|
||
->pluck('code_hash')
|
||
->toArray();
|
||
|
||
$r = $this->postJson('/api/2fa/regenerate-recovery-codes', ['password' => 'master-password-9876']);
|
||
|
||
$r->assertOk();
|
||
expect($r->json('recovery_codes'))->toHaveCount(8);
|
||
|
||
// Старые хеши удалены, новые отличаются.
|
||
$newHashes = DB::table('user_recovery_codes')
|
||
->where('user_id', $this->user->id)
|
||
->pluck('code_hash')
|
||
->toArray();
|
||
expect($newHashes)->toHaveCount(8);
|
||
expect(array_intersect($oldHashes, $newHashes))->toBe([]);
|
||
expect($r->json('recovery_codes'))->not->toBe($oldCodes);
|
||
});
|
||
|
||
test('POST /api/2fa/regenerate-recovery-codes 422 если 2FA не включена', function () {
|
||
$r = $this->postJson('/api/2fa/regenerate-recovery-codes', ['password' => 'master-password-9876']);
|
||
|
||
$r->assertStatus(422);
|
||
});
|
||
|
||
test('Все 4 эндпоинта /api/2fa/* требуют auth:sanctum (401 без login)', function () {
|
||
auth()->logout();
|
||
|
||
foreach (['init', 'confirm', 'disable', 'regenerate-recovery-codes'] as $endpoint) {
|
||
$this->postJson('/api/2fa/'.$endpoint, ['code' => '000000', 'password' => 'x'])
|
||
->assertStatus(401);
|
||
}
|
||
});
|