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) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 21:26:59 +02:00
parent 00337e038c
commit 61df4497d7
5 changed files with 39 additions and 1 deletions

View File

@ -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);

View File

@ -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',

View File

@ -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',

View File

@ -41,5 +41,7 @@
<x-btn variant="secondary" class="w-full" wire:click="sendResetLink">{{ __('auth.forgot_send_email') }}</x-btn>
@endif
<p class="font-mono text-[11px] text-ink-4">{{ __('auth.reset_no_2fa_note') }}</p>
<a href="{{ route('login') }}" wire:navigate class="block text-center font-mono text-[11px] text-ink-3 transition-colors hover:text-ink-2">{{ __('auth.back_to_login') }}</a>
</div>

View File

@ -0,0 +1,33 @@
<?php
namespace Tests\Feature;
use App\Livewire\Auth\ForgotPassword;
use App\Models\User;
use App\Models\WebauthnCredential;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Livewire\Livewire;
use Tests\TestCase;
class ForgotPasswordKeyOnlyTest extends TestCase
{
use RefreshDatabase;
public function test_key_only_user_resets_with_a_backup_code(): void
{
$user = User::factory()->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));
}
}