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
parent
c19a35e43d
commit
55d513f241
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue