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
parent
00337e038c
commit
61df4497d7
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue