feat(2fa): challenge adapts to factor — TOTP field gated, key-only leads with the key + backup code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 21:10:25 +02:00
parent c19a35e43d
commit 55d513f241
5 changed files with 82 additions and 7 deletions

View File

@ -25,6 +25,12 @@ class TwoFactorChallenge extends Component
}
}
/** Whether the PENDING user has TOTP — the authenticator code field renders only then. */
public function getPendingHasTotpProperty(): bool
{
return (bool) User::find(session('2fa.user'))?->hasTotp();
}
public function verify()
{
$this->validate();
@ -44,8 +50,9 @@ class TwoFactorChallenge extends Component
throw ValidationException::withMessages(['code' => __('auth.session_expired')]);
}
// Accept the TOTP code, or fall back to a one-time backup (recovery) code.
$valid = (new Google2FA)->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code))
// Accept the TOTP code only when the pending user actually has TOTP (otherwise a null
// secret would break verifyKey); always allow a one-time backup (recovery) code.
$valid = ($user->hasTotp() && (new Google2FA)->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code)))
|| $user->useRecoveryCode($this->code);
if (! $valid) {

View File

@ -74,6 +74,10 @@ return [
// ── 2FA backup (recovery) codes ──────────────────────────────────────
'challenge_recovery_hint' => 'Authenticator verloren? Gib einen deiner Backup-Codes ein.',
'challenge_key_subtitle' => 'Bestätige mit deinem Security-Key oder einem Backup-Code.',
'challenge_backup_label' => 'Backup-Code',
'challenge_backup_placeholder' => 'xxxxxxxxxx-xxxxxxxxxx',
'challenge_backup_only_hint' => 'Kein Authenticator nötig — Security-Key oben oder ein Backup-Code.',
'recovery_heading' => 'Backup-Codes',
'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.',

View File

@ -74,6 +74,10 @@ return [
// ── 2FA backup (recovery) codes ──────────────────────────────────────
'challenge_recovery_hint' => 'Lost your authenticator? Enter one of your backup codes.',
'challenge_key_subtitle' => 'Confirm with your security key or a backup code.',
'challenge_backup_label' => 'Backup code',
'challenge_backup_placeholder' => 'xxxxxxxxxx-xxxxxxxxxx',
'challenge_backup_only_hint' => 'No authenticator needed — use the security key above or a backup code.',
'recovery_heading' => 'Backup codes',
'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.',

View File

@ -1,5 +1,6 @@
@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
@ -8,16 +9,29 @@
<div class="space-y-1.5">
<p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">{{ __('auth.two_factor') }}</p>
<h1 class="font-display text-2xl font-bold tracking-tight text-ink">{{ __('auth.challenge_heading') }}</h1>
<p class="font-mono text-xs text-ink-3">{{ __('auth.challenge_subtitle') }}</p>
<p class="font-mono text-xs text-ink-3">{{ $this->pendingHasTotp ? __('auth.challenge_subtitle') : __('auth.challenge_key_subtitle') }}</p>
</div>
@if (! $this->pendingHasTotp && $this->webauthnAvailable())
{{-- Key-only: the security key is the primary path. --}}
<x-btn variant="primary" size="lg" class="w-full" x-data x-on:click="window.clusevWebauthn?.login($wire)">
<x-icon name="shield" class="h-4 w-4" /> {{ __('auth.webauthn_login') }}
</x-btn>
<div class="flex items-center gap-3">
<span class="h-px flex-1 bg-line"></span>
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('common.or') }}</span>
<span class="h-px flex-1 bg-line"></span>
</div>
@endif
<form wire:submit="verify" class="space-y-4">
<div>
<label for="code" class="{{ $label }}">{{ __('auth.code') }}</label>
<label for="code" class="{{ $label }}">{{ $this->pendingHasTotp ? __('auth.code') : __('auth.challenge_backup_label') }}</label>
<input wire:model="code" id="code" inputmode="text" autocomplete="one-time-code" autofocus
class="{{ $field }}" placeholder="000000" />
class="{{ $this->pendingHasTotp ? $field : $fieldText }}"
placeholder="{{ $this->pendingHasTotp ? '000000' : __('auth.challenge_backup_placeholder') }}" />
@error('code') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
<p class="mt-2 text-center font-mono text-[11px] text-ink-4">{{ __('auth.challenge_recovery_hint') }}</p>
<p class="mt-2 text-center font-mono text-[11px] text-ink-4">{{ $this->pendingHasTotp ? __('auth.challenge_recovery_hint') : __('auth.challenge_backup_only_hint') }}</p>
</div>
<x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="verify">
@ -27,7 +41,7 @@
</x-btn>
</form>
@if ($this->webauthnAvailable())
@if ($this->pendingHasTotp && $this->webauthnAvailable())
<div class="flex items-center gap-3">
<span class="h-px flex-1 bg-line"></span>
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('common.or') }}</span>

View File

@ -0,0 +1,46 @@
<?php
namespace Tests\Feature;
use App\Livewire\Auth\TwoFactorChallenge;
use App\Models\User;
use App\Models\WebauthnCredential;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class ChallengeFactorAdaptTest extends TestCase
{
use RefreshDatabase;
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_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)
->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()]);
session(['2fa.user' => $user->id, '2fa.remember' => false]);
Livewire::test(TwoFactorChallenge::class)->assertSet('pendingHasTotp', true);
}
}