diff --git a/app/Livewire/Auth/TwoFactorChallenge.php b/app/Livewire/Auth/TwoFactorChallenge.php index 32187db..96a25be 100644 --- a/app/Livewire/Auth/TwoFactorChallenge.php +++ b/app/Livewire/Auth/TwoFactorChallenge.php @@ -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); diff --git a/lang/de/auth.php b/lang/de/auth.php index 8ebbb27..fdbe49e 100644 --- a/lang/de/auth.php +++ b/lang/de/auth.php @@ -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.', ]; diff --git a/lang/en/auth.php b/lang/en/auth.php index 5368873..0bbf85a 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -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.', ]; diff --git a/resources/views/livewire/auth/two-factor-challenge.blade.php b/resources/views/livewire/auth/two-factor-challenge.blade.php index deeb336..901347d 100644 --- a/resources/views/livewire/auth/two-factor-challenge.blade.php +++ b/resources/views/livewire/auth/two-factor-challenge.blade.php @@ -14,9 +14,10 @@
- @error('code')

{{ $message }}

@enderror +

{{ __('auth.challenge_recovery_hint') }}

diff --git a/tests/Feature/TwoFactorChallengeRecoveryTest.php b/tests/Feature/TwoFactorChallengeRecoveryTest.php new file mode 100644 index 0000000..d4a616b --- /dev/null +++ b/tests/Feature/TwoFactorChallengeRecoveryTest.php @@ -0,0 +1,51 @@ +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(); + } +}