diff --git a/app/Livewire/Auth/TwoFactorChallenge.php b/app/Livewire/Auth/TwoFactorChallenge.php index 9764bf0..1557b97 100644 --- a/app/Livewire/Auth/TwoFactorChallenge.php +++ b/app/Livewire/Auth/TwoFactorChallenge.php @@ -23,6 +23,12 @@ class TwoFactorChallenge extends Component if (! session()->has('2fa.user')) { return $this->redirect(route('login'), navigate: true); } + + // No TOTP and no usable security key (a key-only user over http + bare IP, where WebAuthn + // has no secure context) → the backup code is the only path: go straight to its own view. + if (! $this->pendingHasTotp && ! $this->webauthnAvailable()) { + return $this->redirect(route('two-factor.challenge.backup'), navigate: true); + } } public function verify() diff --git a/resources/views/livewire/auth/two-factor-challenge.blade.php b/resources/views/livewire/auth/two-factor-challenge.blade.php index c474f60..0ffcace 100644 --- a/resources/views/livewire/auth/two-factor-challenge.blade.php +++ b/resources/views/livewire/auth/two-factor-challenge.blade.php @@ -1,6 +1,5 @@ @php $field = 'h-14 w-full rounded-md border border-line bg-inset text-center font-mono text-2xl font-semibold tracking-[0.5em] text-ink caret-accent placeholder:text-ink-4 focus:border-accent/40 focus:outline-none'; - $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 @@ -17,41 +16,42 @@ {{ __('auth.webauthn_login') }} -
- - {{ __('common.or') }} - -
@endif -
-
- - - @error('code')

{{ $message }}

@enderror -

{{ $this->pendingHasTotp ? __('auth.challenge_recovery_hint') : __('auth.challenge_backup_only_hint') }}

-
+ @if ($this->pendingHasTotp) + +
+ + + @error('code')

{{ $message }}

@enderror +
- - - {{ __('common.confirm') }} - {{ __('auth.checking') }} - -
+ + + {{ __('common.confirm') }} + {{ __('auth.checking') }} + + - @if ($this->pendingHasTotp && $this->webauthnAvailable()) -
- - {{ __('common.or') }} - -
- - {{ __('auth.webauthn_login') }} - + @if ($this->webauthnAvailable()) + {{-- TOTP + key: the security key is the alternate path. --}} +
+ + {{ __('common.or') }} + +
+ + {{ __('auth.webauthn_login') }} + + @endif @endif + {{-- Backup code lives on its own view — a deliberate, subordinate step. --}} + + {{ __('auth.challenge_use_backup') }} + +
{{ __('auth.back_to_login') }} diff --git a/tests/Feature/ChallengeFactorAdaptTest.php b/tests/Feature/ChallengeFactorAdaptTest.php index 8e75dd6..f96d3b3 100644 --- a/tests/Feature/ChallengeFactorAdaptTest.php +++ b/tests/Feature/ChallengeFactorAdaptTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature; use App\Livewire\Auth\TwoFactorChallenge; use App\Models\User; use App\Models\WebauthnCredential; +use App\Services\WebauthnService; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; use Tests\TestCase; @@ -21,23 +22,6 @@ class ChallengeFactorAdaptTest extends TestCase return $u->fresh(); } - public function test_key_only_user_has_no_totp_field_but_a_backup_code_works(): void - { - $user = $this->keyOnlyUser(); - $codes = $user->replaceRecoveryCodes(); - session(['2fa.user' => $user->id, '2fa.remember' => false]); - - Livewire::test(TwoFactorChallenge::class) - ->assertSet('pendingHasTotp', false) - ->assertDontSee('000000') - ->assertSee(__('auth.challenge_backup_label')) - ->set('code', $codes[0]) - ->call('verify') - ->assertRedirect(route('dashboard')); - - $this->assertAuthenticatedAs($user); - } - public function test_totp_user_sees_the_field(): void { $user = User::factory()->create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]); @@ -45,4 +29,42 @@ class ChallengeFactorAdaptTest extends TestCase Livewire::test(TwoFactorChallenge::class)->assertSet('pendingHasTotp', true); } + + public function test_totp_user_sees_the_backup_link_but_not_the_backup_field(): void + { + $user = User::factory()->create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]); + session(['2fa.user' => $user->id, '2fa.remember' => false]); + + Livewire::test(TwoFactorChallenge::class) + ->assertSee('000000') // TOTP field present + ->assertSee(__('auth.challenge_use_backup')) // backup link present + ->assertDontSee(__('auth.challenge_backup_placeholder')); // no backup field here + } + + public function test_key_only_user_without_secure_context_is_redirected_to_the_backup_view(): void + { + // No domain configured → webauthnAvailable() false, pendingHasTotp false: the backup code + // is the only usable path, so mount() sends the user straight to the dedicated view. + $user = $this->keyOnlyUser(); + session(['2fa.user' => $user->id, '2fa.remember' => false]); + + Livewire::test(TwoFactorChallenge::class) + ->assertRedirect(route('two-factor.challenge.backup')); + } + + public function test_key_only_user_with_secure_context_stays_and_shows_key_plus_backup(): void + { + $svc = $this->mock(WebauthnService::class); + $svc->shouldReceive('available')->andReturn(true); + + $user = $this->keyOnlyUser(); + session(['2fa.user' => $user->id, '2fa.remember' => false]); + + Livewire::test(TwoFactorChallenge::class) + ->assertNoRedirect() + ->assertSee(__('auth.webauthn_login')) // security-key button + ->assertSee(__('auth.challenge_use_backup')) // backup link + ->assertDontSee('000000') // no TOTP field + ->assertDontSee(__('auth.challenge_backup_placeholder')); // no inline backup field + } }