feat(auth): accept a 2FA backup code at the login challenge

The challenge now falls back to consuming a one-time recovery code when the TOTP
check fails. Relaxed the code input (was numeric, maxlength 6) so a backup code
is typable, and added a hint. Bilingual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 16:50:36 +02:00
parent f3e595ed45
commit b2cb94bb32
5 changed files with 110 additions and 2 deletions

View File

@ -43,7 +43,9 @@ class TwoFactorChallenge extends Component
throw ValidationException::withMessages(['code' => __('auth.session_expired')]);
}
$valid = (new Google2FA)->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code));
// 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))
|| $user->useRecoveryCode($this->code);
if (! $valid) {
RateLimiter::hit($key, 60);

View File

@ -71,4 +71,31 @@ return [
// ── Validation attribute names ───────────────────────────────────────
'attr_current_password' => 'aktuelles Passwort',
'attr_new_password' => 'neues Passwort',
// ── 2FA backup (recovery) codes ──────────────────────────────────────
'challenge_recovery_hint' => 'Authenticator verloren? Gib einen deiner Backup-Codes ein.',
'title_recovery' => 'Backup-Codes — Clusev',
'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.',
'recovery_download' => 'Als Datei herunterladen',
'recovery_regenerate' => 'Neu erzeugen',
'recovery_regenerate_confirm' => 'Neue Codes erzeugen? Die alten werden ungültig.',
'recovery_regenerated' => 'Neue Backup-Codes erzeugt.',
'recovery_done' => 'Gespeichert — weiter',
'recovery_manage' => 'Backup-Codes verwalten',
// ── Forgot / reset password ──────────────────────────────────────────
'title_forgot' => 'Passwort vergessen — Clusev',
'forgot_link' => 'Passwort vergessen?',
'forgot_heading' => 'Passwort zurücksetzen',
'forgot_subtitle' => 'Setze dein Passwort mit deinem 2FA-Code oder einem Backup-Code zurück.',
'forgot_code' => '2FA-Code oder Backup-Code',
'forgot_code_hint' => '6-stelliger Authenticator-Code oder einer deiner Backup-Codes.',
'forgot_submit' => 'Passwort zurücksetzen',
'forgot_send_email' => 'Reset-Link per E-Mail senden',
'reset_invalid' => 'E-Mail oder Code ungültig.',
'reset_done' => 'Passwort geändert. Bitte neu anmelden.',
'reset_link_sent' => 'Falls die E-Mail existiert, wurde ein Reset-Link versendet.',
'reset_token_invalid' => 'Reset-Link ungültig oder abgelaufen.',
];

View File

@ -71,4 +71,31 @@ return [
// ── Validation attribute names ───────────────────────────────────────
'attr_current_password' => 'current password',
'attr_new_password' => 'new password',
// ── 2FA backup (recovery) codes ──────────────────────────────────────
'challenge_recovery_hint' => 'Lost your authenticator? Enter one of your backup codes.',
'title_recovery' => 'Backup codes — Clusev',
'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.',
'recovery_download' => 'Download as file',
'recovery_regenerate' => 'Regenerate',
'recovery_regenerate_confirm' => 'Generate new codes? The old ones stop working.',
'recovery_regenerated' => 'New backup codes generated.',
'recovery_done' => 'Saved — continue',
'recovery_manage' => 'Manage backup codes',
// ── Forgot / reset password ──────────────────────────────────────────
'title_forgot' => 'Forgot password — Clusev',
'forgot_link' => 'Forgot password?',
'forgot_heading' => 'Reset password',
'forgot_subtitle' => 'Reset your password with your 2FA code or a backup code.',
'forgot_code' => '2FA code or backup code',
'forgot_code_hint' => 'Your 6-digit authenticator code or one of your backup codes.',
'forgot_submit' => 'Reset password',
'forgot_send_email' => 'Email me a reset link',
'reset_invalid' => 'Invalid email or code.',
'reset_done' => 'Password changed. Please sign in again.',
'reset_link_sent' => 'If the email exists, a reset link has been sent.',
'reset_token_invalid' => 'Reset link is invalid or expired.',
];

View File

@ -14,9 +14,10 @@
<form wire:submit="verify" class="space-y-4">
<div>
<label for="code" class="{{ $label }}">{{ __('auth.code') }}</label>
<input wire:model="code" id="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" autofocus
<input wire:model="code" id="code" inputmode="text" autocomplete="one-time-code" autofocus
class="{{ $field }}" placeholder="000000" />
@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>
</div>
<x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="verify">

View File

@ -0,0 +1,51 @@
<?php
namespace Tests\Feature;
use App\Livewire\Auth\TwoFactorChallenge;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use PragmaRX\Google2FAQRCode\Google2FA;
use Tests\TestCase;
class TwoFactorChallengeRecoveryTest extends TestCase
{
use RefreshDatabase;
private function enrolledUser(): User
{
return User::factory()->create([
'two_factor_secret' => (new Google2FA)->generateSecretKey(),
'two_factor_confirmed_at' => now(),
]);
}
public function test_recovery_code_logs_in_and_is_consumed(): void
{
$user = $this->enrolledUser();
$code = $user->replaceRecoveryCodes()[0];
session()->put('2fa.user', $user->id);
Livewire::test(TwoFactorChallenge::class)
->set('code', $code)
->call('verify');
$this->assertAuthenticatedAs($user);
$this->assertFalse($user->fresh()->useRecoveryCode($code)); // consumed
}
public function test_invalid_code_fails(): void
{
$user = $this->enrolledUser();
$user->replaceRecoveryCodes();
session()->put('2fa.user', $user->id);
Livewire::test(TwoFactorChallenge::class)
->set('code', '000000')
->call('verify')
->assertHasErrors('code');
$this->assertGuest();
}
}