feat(2fa): recovery codes become a modal; drop the dedicated page + route
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>feat/v1-foundation
parent
82d5644767
commit
e9a6a6c083
|
|
@ -1,15 +1,28 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Livewire\Auth;
|
namespace App\Livewire\Modals;
|
||||||
|
|
||||||
use App\Models\AuditEvent;
|
use App\Models\AuditEvent;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Livewire\Attributes\Layout;
|
use LivewireUI\Modal\ModalComponent;
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
#[Layout('layouts.auth')]
|
/**
|
||||||
class RecoveryCodes extends Component
|
* Backup (recovery) codes, shown once in a modal. Opened on first-factor enrollment and
|
||||||
|
* from Settings → Security ("Backup-Codes verwalten"). The download endpoint stays a route.
|
||||||
|
*/
|
||||||
|
class RecoveryCodes extends ModalComponent
|
||||||
{
|
{
|
||||||
|
public static function modalMaxWidth(): string
|
||||||
|
{
|
||||||
|
return 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, string> */
|
||||||
|
public function codes(): array
|
||||||
|
{
|
||||||
|
return Auth::user()->recoveryCodes();
|
||||||
|
}
|
||||||
|
|
||||||
/** Regenerate the current user's backup codes (invalidates the old set). */
|
/** Regenerate the current user's backup codes (invalidates the old set). */
|
||||||
public function regenerate(): void
|
public function regenerate(): void
|
||||||
{
|
{
|
||||||
|
|
@ -28,8 +41,6 @@ class RecoveryCodes extends Component
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.auth.recovery-codes', [
|
return view('livewire.modals.recovery-codes', ['codes' => $this->codes()]);
|
||||||
'codes' => Auth::user()->recoveryCodes(),
|
|
||||||
])->title(__('auth.title_recovery'));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +74,6 @@ return [
|
||||||
|
|
||||||
// ── 2FA backup (recovery) codes ──────────────────────────────────────
|
// ── 2FA backup (recovery) codes ──────────────────────────────────────
|
||||||
'challenge_recovery_hint' => 'Authenticator verloren? Gib einen deiner Backup-Codes ein.',
|
'challenge_recovery_hint' => 'Authenticator verloren? Gib einen deiner Backup-Codes ein.',
|
||||||
'title_recovery' => 'Backup-Codes — Clusev',
|
|
||||||
'recovery_heading' => 'Backup-Codes',
|
'recovery_heading' => 'Backup-Codes',
|
||||||
'recovery_subtitle' => 'Mit diesen Einmal-Codes meldest du dich an, wenn du keinen Authenticator hast.',
|
'recovery_subtitle' => 'Mit diesen Einmal-Codes meldest du dich an, wenn du keinen Authenticator hast.',
|
||||||
'recovery_warning' => 'Speichere diese Codes jetzt sicher ab — jeder Code funktioniert genau einmal. Lade sie herunter, solange sie angezeigt werden.',
|
'recovery_warning' => 'Speichere diese Codes jetzt sicher ab — jeder Code funktioniert genau einmal. Lade sie herunter, solange sie angezeigt werden.',
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,6 @@ return [
|
||||||
|
|
||||||
// ── 2FA backup (recovery) codes ──────────────────────────────────────
|
// ── 2FA backup (recovery) codes ──────────────────────────────────────
|
||||||
'challenge_recovery_hint' => 'Lost your authenticator? Enter one of your backup codes.',
|
'challenge_recovery_hint' => 'Lost your authenticator? Enter one of your backup codes.',
|
||||||
'title_recovery' => 'Backup codes — Clusev',
|
|
||||||
'recovery_heading' => 'Backup codes',
|
'recovery_heading' => 'Backup codes',
|
||||||
'recovery_subtitle' => 'Use these one-time codes to sign in when you do not have your authenticator.',
|
'recovery_subtitle' => 'Use these one-time codes to sign in when you do not have your authenticator.',
|
||||||
'recovery_warning' => 'Save these codes somewhere safe now — each works exactly once. Download them while they are shown.',
|
'recovery_warning' => 'Save these codes somewhere safe now — each works exactly once. Download them while they are shown.',
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<div class="space-y-5">
|
|
||||||
<div>
|
|
||||||
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('auth.two_factor') }}</p>
|
|
||||||
<h1 class="mt-1 font-display text-2xl font-semibold text-ink">{{ __('auth.recovery_heading') }}</h1>
|
|
||||||
<p class="mt-1.5 text-sm text-ink-2">{{ __('auth.recovery_subtitle') }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-start gap-2.5 rounded-md border border-warning/25 bg-warning/10 px-4 py-3">
|
|
||||||
<x-icon name="alert" class="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
|
||||||
<p class="text-sm text-ink-2">{{ __('auth.recovery_warning') }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-2 rounded-md border border-line bg-inset p-4 sm:grid-cols-2">
|
|
||||||
@foreach ($codes as $c)
|
|
||||||
<span class="font-mono text-sm tracking-wide text-ink">{{ $c }}</span>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<x-btn href="{{ route('two-factor.recovery.download') }}" variant="accent">
|
|
||||||
<x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('auth.recovery_download') }}
|
|
||||||
</x-btn>
|
|
||||||
<x-btn variant="secondary" wire:click="regenerate" wire:confirm="{{ __('auth.recovery_regenerate_confirm') }}">
|
|
||||||
<x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('auth.recovery_regenerate') }}
|
|
||||||
</x-btn>
|
|
||||||
<x-btn href="{{ route('dashboard') }}" variant="primary" class="ml-auto">{{ __('auth.recovery_done') }}</x-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<div class="p-5 sm:p-6">
|
||||||
|
<div class="flex items-start gap-3.5">
|
||||||
|
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-accent/30 bg-accent/10 text-accent-text">
|
||||||
|
<x-icon name="shield" class="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h2 class="font-display text-base font-semibold text-ink">{{ __('auth.recovery_heading') }}</h2>
|
||||||
|
<p class="mt-1 text-sm leading-relaxed text-ink-2">{{ __('auth.recovery_subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-start gap-2.5 rounded-md border border-warning/25 bg-warning/10 px-4 py-3">
|
||||||
|
<x-icon name="alert" class="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
||||||
|
<p class="text-sm text-ink-2">{{ __('auth.recovery_warning') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid grid-cols-1 gap-2 rounded-md border border-line bg-inset p-4 sm:grid-cols-2">
|
||||||
|
@foreach ($codes as $c)
|
||||||
|
<span class="font-mono text-sm tracking-wide text-ink">{{ $c }}</span>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-wrap items-center gap-2">
|
||||||
|
<x-btn href="{{ route('two-factor.recovery.download') }}" variant="secondary">
|
||||||
|
<x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('auth.recovery_download') }}
|
||||||
|
</x-btn>
|
||||||
|
<x-btn variant="secondary" wire:click="regenerate" wire:target="regenerate" wire:loading.attr="disabled">
|
||||||
|
<x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('auth.recovery_regenerate') }}
|
||||||
|
</x-btn>
|
||||||
|
<x-btn variant="primary" class="ml-auto" wire:click="$dispatch('closeModal')">{{ __('auth.recovery_done') }}</x-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -107,7 +107,7 @@
|
||||||
</div>
|
</div>
|
||||||
@if ($twoFactorEnabled)
|
@if ($twoFactorEnabled)
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<x-btn variant="secondary" :href="route('two-factor.recovery')" wire:navigate>{{ __('auth.recovery_manage') }}</x-btn>
|
<x-btn variant="secondary" wire:click="$dispatch('openModal', { component: 'modals.recovery-codes' })">{{ __('auth.recovery_manage') }}</x-btn>
|
||||||
<x-btn variant="danger-soft" wire:click="confirmDisableTwoFactor">{{ __('common.disable') }}</x-btn>
|
<x-btn variant="danger-soft" wire:click="confirmDisableTwoFactor">{{ __('common.disable') }}</x-btn>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
|
|
|
||||||
|
|
@ -53,9 +53,7 @@ Route::middleware('auth')->group(function () {
|
||||||
Route::get('/password', Auth\PasswordChange::class)->name('password.change');
|
Route::get('/password', Auth\PasswordChange::class)->name('password.change');
|
||||||
Route::get('/two-factor-setup', Auth\TwoFactorSetup::class)->name('two-factor.setup');
|
Route::get('/two-factor-setup', Auth\TwoFactorSetup::class)->name('two-factor.setup');
|
||||||
|
|
||||||
// 2FA backup codes — shown right after enrolling (before the onboarding gate) and
|
// 2FA backup-codes download — the codes themselves are shown in a modal (Modals\RecoveryCodes).
|
||||||
// manageable later from Settings.
|
|
||||||
Route::get('/two-factor/recovery-codes', Auth\RecoveryCodes::class)->name('two-factor.recovery');
|
|
||||||
Route::get('/two-factor/recovery-codes/download', function () {
|
Route::get('/two-factor/recovery-codes/download', function () {
|
||||||
$codes = AuthFacade::user()->recoveryCodes();
|
$codes = AuthFacade::user()->recoveryCodes();
|
||||||
|
|
||||||
|
|
@ -74,7 +72,7 @@ Route::middleware('auth')->group(function () {
|
||||||
return redirect()->route('login');
|
return redirect()->route('login');
|
||||||
})->name('logout');
|
})->name('logout');
|
||||||
|
|
||||||
// The panel — requires completed onboarding (password rotated + 2FA enrolled).
|
// The panel — requires completed onboarding (seeded password rotated).
|
||||||
Route::middleware(EnsureSecurityOnboarded::class)->group(function () {
|
Route::middleware(EnsureSecurityOnboarded::class)->group(function () {
|
||||||
Route::get('/', Dashboard::class)->name('dashboard');
|
Route::get('/', Dashboard::class)->name('dashboard');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Livewire\Modals\RecoveryCodes;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class RecoveryCodesModalTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_modal_lists_the_current_codes(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$codes = $user->replaceRecoveryCodes();
|
||||||
|
|
||||||
|
Livewire::actingAs($user)->test(RecoveryCodes::class)
|
||||||
|
->assertSee($codes[0])
|
||||||
|
->assertSee($codes[7]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_regenerate_replaces_the_codes(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$old = $user->replaceRecoveryCodes();
|
||||||
|
|
||||||
|
Livewire::actingAs($user)->test(RecoveryCodes::class)
|
||||||
|
->call('regenerate')
|
||||||
|
->assertDontSee($old[0]);
|
||||||
|
|
||||||
|
$this->assertNotEquals($old, $user->fresh()->recoveryCodes());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_old_recovery_route_is_gone(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse(Route::has('two-factor.recovery'));
|
||||||
|
$this->assertTrue(Route::has('two-factor.recovery.download'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature;
|
|
||||||
|
|
||||||
use App\Livewire\Auth\RecoveryCodes;
|
|
||||||
use App\Livewire\Auth\TwoFactorSetup;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
use PragmaRX\Google2FAQRCode\Google2FA;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class RecoveryCodesViewTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_confirming_2fa_generates_codes_and_redirects_to_recovery(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->create(['two_factor_secret' => null, 'two_factor_confirmed_at' => null]);
|
|
||||||
$this->actingAs($user);
|
|
||||||
$secret = (new Google2FA)->generateSecretKey();
|
|
||||||
$code = (new Google2FA)->getCurrentOtp($secret);
|
|
||||||
|
|
||||||
Livewire::test(TwoFactorSetup::class)
|
|
||||||
->set('secret', $secret)
|
|
||||||
->set('code', $code)
|
|
||||||
->call('confirm')
|
|
||||||
->assertRedirect(route('two-factor.recovery'));
|
|
||||||
|
|
||||||
$this->assertCount(8, $user->fresh()->recoveryCodes());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_regenerate_replaces_codes(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->create(['two_factor_secret' => 'x', 'two_factor_confirmed_at' => now()]);
|
|
||||||
$old = $user->replaceRecoveryCodes();
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
Livewire::test(RecoveryCodes::class)->call('regenerate');
|
|
||||||
|
|
||||||
$this->assertNotSame($old, $user->fresh()->recoveryCodes());
|
|
||||||
$this->assertCount(8, $user->fresh()->recoveryCodes());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_download_returns_codes_as_text(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->replaceRecoveryCodes();
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$res = $this->get(route('two-factor.recovery.download'));
|
|
||||||
$res->assertOk();
|
|
||||||
$this->assertStringContainsString($user->recoveryCodes()[0], $res->streamedContent());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue