From e8bbe21b781ebb3c3eab70bba0af94958709e36d Mon Sep 17 00:00:00 2001 From: boban Date: Sat, 20 Jun 2026 15:31:32 +0200 Subject: [PATCH] feat(auth): dedicated backup-code 2FA view + route Co-Authored-By: Claude Sonnet 4.6 --- app/Livewire/Auth/TwoFactorBackup.php | 55 +++++++ .../livewire/auth/two-factor-backup.blade.php | 39 +++++ routes/web.php | 1 + tests/Feature/TwoFactorBackupTest.php | 146 ++++++++++++++++++ 4 files changed, 241 insertions(+) create mode 100644 app/Livewire/Auth/TwoFactorBackup.php create mode 100644 resources/views/livewire/auth/two-factor-backup.blade.php create mode 100644 tests/Feature/TwoFactorBackupTest.php diff --git a/app/Livewire/Auth/TwoFactorBackup.php b/app/Livewire/Auth/TwoFactorBackup.php new file mode 100644 index 0000000..856e6a5 --- /dev/null +++ b/app/Livewire/Auth/TwoFactorBackup.php @@ -0,0 +1,55 @@ +has('2fa.user')) { + return $this->redirect(route('login'), navigate: true); + } + } + + public function verify() + { + $this->validate(); + + $this->assertNotRateLimited(); + + $user = User::find(session('2fa.user')); + + if (! $user || ! $user->hasTwoFactorEnabled()) { + session()->forget(['2fa.user', '2fa.remember']); + throw ValidationException::withMessages(['code' => __('auth.session_expired')]); + } + + // Backup-only view: a one-time recovery code is the sole accepted credential (no TOTP). + if (! $user->useRecoveryCode($this->code)) { + $this->hitRateLimit(); + throw ValidationException::withMessages(['code' => __('auth.invalid_code')]); + } + + $this->clearRateLimit(); + + return $this->completeLogin($user); + } + + public function render() + { + return view('livewire.auth.two-factor-backup')->title(__('auth.title_challenge')); + } +} diff --git a/resources/views/livewire/auth/two-factor-backup.blade.php b/resources/views/livewire/auth/two-factor-backup.blade.php new file mode 100644 index 0000000..6e97073 --- /dev/null +++ b/resources/views/livewire/auth/two-factor-backup.blade.php @@ -0,0 +1,39 @@ +@php + $fieldText = 'h-12 w-full rounded-md border border-line bg-inset px-3 text-center font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none'; + $label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3'; + $err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline'; +@endphp + +
+
+

{{ __('auth.two_factor') }}

+

{{ __('auth.backup_heading') }}

+

{{ __('auth.backup_subtitle') }}

+
+ +
+
+ + + @error('code')

{{ $message }}

@enderror +
+ + + + {{ __('common.confirm') }} + {{ __('auth.checking') }} + +
+ +
+ @if ($this->pendingHasTotp || $this->webauthnAvailable()) + + {{ __('auth.back_to_options') }} + + @endif + + {{ __('auth.back_to_login') }} + +
+
diff --git a/routes/web.php b/routes/web.php index 9f641be..5f15e58 100644 --- a/routes/web.php +++ b/routes/web.php @@ -47,6 +47,7 @@ Route::middleware('guest')->group(function () { Route::get('/forgot-password', Auth\ForgotPassword::class)->name('password.request'); Route::get('/reset-password/{token}', Auth\ResetPassword::class)->name('password.reset'); Route::get('/two-factor-challenge', Auth\TwoFactorChallenge::class)->name('two-factor.challenge'); + Route::get('/two-factor-challenge/backup', Auth\TwoFactorBackup::class)->name('two-factor.challenge.backup'); }); Route::middleware('auth')->group(function () { diff --git a/tests/Feature/TwoFactorBackupTest.php b/tests/Feature/TwoFactorBackupTest.php new file mode 100644 index 0000000..96b83f8 --- /dev/null +++ b/tests/Feature/TwoFactorBackupTest.php @@ -0,0 +1,146 @@ +create([ + 'two_factor_secret' => (new Google2FA)->generateSecretKey(), + 'two_factor_confirmed_at' => now(), + ]); + } + + private function keyOnlyUser(): User + { + $u = User::factory()->create(); + WebauthnCredential::create(['user_id' => $u->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); + + return $u->fresh(); + } + + public function test_backup_code_logs_in_and_is_consumed(): void + { + $user = $this->enrolledUser(); + $code = $user->replaceRecoveryCodes()[0]; + session()->put('2fa.user', $user->id); + + Livewire::test(TwoFactorBackup::class) + ->set('code', $code) + ->call('verify') + ->assertRedirect(route('dashboard')); + + $this->assertAuthenticatedAs($user); + $this->assertFalse($user->fresh()->useRecoveryCode($code)); // consumed + } + + public function test_invalid_backup_code_fails(): void + { + $user = $this->enrolledUser(); + $user->replaceRecoveryCodes(); + session()->put('2fa.user', $user->id); + + Livewire::test(TwoFactorBackup::class) + ->set('code', 'nope-nope') + ->call('verify') + ->assertHasErrors('code'); + + $this->assertGuest(); + } + + public function test_a_totp_code_is_not_accepted_on_the_backup_view(): void + { + $user = $this->enrolledUser(); + $user->replaceRecoveryCodes(); + $totp = (new Google2FA)->getCurrentOtp($user->two_factor_secret); + session()->put('2fa.user', $user->id); + + Livewire::test(TwoFactorBackup::class) + ->set('code', $totp) + ->call('verify') + ->assertHasErrors('code'); + + $this->assertGuest(); + } + + public function test_requires_a_pending_2fa_session(): void + { + Livewire::test(TwoFactorBackup::class) + ->assertRedirect(route('login')); + } + + public function test_field_and_login_link_render(): void + { + $user = $this->enrolledUser(); + $user->replaceRecoveryCodes(); + session()->put('2fa.user', $user->id); + + Livewire::test(TwoFactorBackup::class) + ->assertSee(__('auth.backup_heading')) + ->assertSee(__('auth.challenge_backup_placeholder')) // the backup field is present + ->assertSee(__('auth.back_to_login')); + } + + public function test_back_to_options_shown_for_a_totp_user(): void + { + $user = $this->enrolledUser(); + $user->replaceRecoveryCodes(); + session()->put('2fa.user', $user->id); + + Livewire::test(TwoFactorBackup::class) + ->assertSee(__('auth.back_to_options')); + } + + public function test_back_to_options_hidden_for_key_only_without_secure_context(): void + { + // No domain configured → webauthnAvailable() false, pendingHasTotp false: backup is the + // only path, so the "back to options" link would loop straight back here and is hidden. + $user = $this->keyOnlyUser(); + $user->replaceRecoveryCodes(); + session()->put('2fa.user', $user->id); + + Livewire::test(TwoFactorBackup::class) + ->assertDontSee(__('auth.back_to_options')) + ->assertSee(__('auth.back_to_login')); + } + + public function test_failed_backup_attempts_share_the_rate_limit_with_the_main_challenge(): void + { + $user = $this->enrolledUser(); + $codes = $user->replaceRecoveryCodes(); + session()->put('2fa.user', $user->id); + + // 5 wrong backup attempts trip the per-(user+IP) bucket (5/60s). + for ($i = 0; $i < 5; $i++) { + Livewire::test(TwoFactorBackup::class)->set('code', 'wrong-'.$i)->call('verify'); + } + + // A genuinely valid, unused backup code is now refused on the MAIN challenge by the + // SAME shared bucket — proving one counter spans both components. + Livewire::test(TwoFactorChallenge::class) + ->set('code', $codes[0]) + ->call('verify') + ->assertHasErrors('code'); + + $this->assertGuest(); + } +}