From 61df4497d791c0d91983ac5c4068c72a2c6c5771 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 21:26:59 +0200 Subject: [PATCH] feat(2fa): forgot-password is key-only safe + states the no-2FA recovery path Guard the TOTP verifyKey call behind hasTotp() so a key-only user's null two_factor_secret never reaches Google2FA::verifyKey(); they reset via a backup code. Also adds the reset_no_2fa_note lang key (EN + DE) rendered on the forgot-password form explaining the no-2FA recovery options. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Livewire/Auth/ForgotPassword.php | 3 +- lang/de/auth.php | 1 + lang/en/auth.php | 1 + .../livewire/auth/forgot-password.blade.php | 2 ++ tests/Feature/ForgotPasswordKeyOnlyTest.php | 33 +++++++++++++++++++ 5 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/ForgotPasswordKeyOnlyTest.php diff --git a/app/Livewire/Auth/ForgotPassword.php b/app/Livewire/Auth/ForgotPassword.php index 5f249cb..7b6422a 100644 --- a/app/Livewire/Auth/ForgotPassword.php +++ b/app/Livewire/Auth/ForgotPassword.php @@ -65,7 +65,8 @@ class ForgotPassword extends Component $ok = $user && $user->hasTwoFactorEnabled() - && ((new Google2FA)->verifyKey($user->two_factor_secret, $clean) || $user->useRecoveryCode($this->code)); + && (($user->hasTotp() && (new Google2FA)->verifyKey($user->two_factor_secret, $clean)) + || $user->useRecoveryCode($this->code)); if (! $ok) { RateLimiter::hit($key, 60); diff --git a/lang/de/auth.php b/lang/de/auth.php index 2f97319..9d56823 100644 --- a/lang/de/auth.php +++ b/lang/de/auth.php @@ -101,6 +101,7 @@ return [ '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.', + 'reset_no_2fa_note' => 'Ohne 2FA ist die Wiederherstellung nur per E-Mail-Link (falls SMTP konfiguriert) oder über den Befehl clusev:reset-admin möglich.', // ── WebAuthn / Security-Keys ───────────────────────────────────────── 'webauthn_login' => 'Mit Security-Key anmelden', diff --git a/lang/en/auth.php b/lang/en/auth.php index 9248f68..16008f6 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -101,6 +101,7 @@ return [ '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.', + 'reset_no_2fa_note' => 'Without 2FA, recovery is only possible via the email link (if SMTP is configured) or the clusev:reset-admin command.', // ── WebAuthn / security keys ───────────────────────────────────────── 'webauthn_login' => 'Sign in with a security key', diff --git a/resources/views/livewire/auth/forgot-password.blade.php b/resources/views/livewire/auth/forgot-password.blade.php index 789c32e..2dc1443 100644 --- a/resources/views/livewire/auth/forgot-password.blade.php +++ b/resources/views/livewire/auth/forgot-password.blade.php @@ -41,5 +41,7 @@ {{ __('auth.forgot_send_email') }} @endif +

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

+ {{ __('auth.back_to_login') }} diff --git a/tests/Feature/ForgotPasswordKeyOnlyTest.php b/tests/Feature/ForgotPasswordKeyOnlyTest.php new file mode 100644 index 0000000..e68711c --- /dev/null +++ b/tests/Feature/ForgotPasswordKeyOnlyTest.php @@ -0,0 +1,33 @@ +create(['must_change_password' => false]); + WebauthnCredential::create(['user_id' => $user->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); + $codes = $user->fresh()->replaceRecoveryCodes(); + + Livewire::test(ForgotPassword::class) + ->set('email', $user->email) + ->set('code', $codes[0]) + ->set('password', 'brand-New-Pass-9') + ->set('password_confirmation', 'brand-New-Pass-9') + ->call('resetPassword') + ->assertRedirect(route('login')); + + $this->assertTrue(Hash::check('brand-New-Pass-9', $user->fresh()->password)); + } +}