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
parent
f3e595ed45
commit
b2cb94bb32
|
|
@ -43,7 +43,9 @@ class TwoFactorChallenge extends Component
|
||||||
throw ValidationException::withMessages(['code' => __('auth.session_expired')]);
|
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) {
|
if (! $valid) {
|
||||||
RateLimiter::hit($key, 60);
|
RateLimiter::hit($key, 60);
|
||||||
|
|
|
||||||
|
|
@ -71,4 +71,31 @@ return [
|
||||||
// ── Validation attribute names ───────────────────────────────────────
|
// ── Validation attribute names ───────────────────────────────────────
|
||||||
'attr_current_password' => 'aktuelles Passwort',
|
'attr_current_password' => 'aktuelles Passwort',
|
||||||
'attr_new_password' => 'neues 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.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -71,4 +71,31 @@ return [
|
||||||
// ── Validation attribute names ───────────────────────────────────────
|
// ── Validation attribute names ───────────────────────────────────────
|
||||||
'attr_current_password' => 'current password',
|
'attr_current_password' => 'current password',
|
||||||
'attr_new_password' => 'new 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.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,10 @@
|
||||||
<form wire:submit="verify" class="space-y-4">
|
<form wire:submit="verify" class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="code" class="{{ $label }}">{{ __('auth.code') }}</label>
|
<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" />
|
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
|
@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>
|
</div>
|
||||||
|
|
||||||
<x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="verify">
|
<x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="verify">
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue