fix(2fa): TwoFactorSetup back to auth layout; exception-safe User::verifyTotp at all call sites

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 21:40:15 +02:00
parent 61df4497d7
commit 5b1e7e5168
5 changed files with 58 additions and 8 deletions

View File

@ -11,7 +11,6 @@ use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Component; use Livewire\Component;
use PragmaRX\Google2FAQRCode\Google2FA;
#[Layout('layouts.auth')] #[Layout('layouts.auth')]
class ForgotPassword extends Component class ForgotPassword extends Component
@ -61,12 +60,10 @@ class ForgotPassword extends Component
} }
$user = User::where('email', $this->email)->first(); $user = User::where('email', $this->email)->first();
$clean = preg_replace('/\s+/', '', $this->code);
$ok = $user $ok = $user
&& $user->hasTwoFactorEnabled() && $user->hasTwoFactorEnabled()
&& (($user->hasTotp() && (new Google2FA)->verifyKey($user->two_factor_secret, $clean)) && ($user->verifyTotp($this->code) || $user->useRecoveryCode($this->code));
|| $user->useRecoveryCode($this->code));
if (! $ok) { if (! $ok) {
RateLimiter::hit($key, 60); RateLimiter::hit($key, 60);

View File

@ -10,7 +10,6 @@ use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
use PragmaRX\Google2FAQRCode\Google2FA;
#[Layout('layouts.auth')] #[Layout('layouts.auth')]
class TwoFactorChallenge extends Component class TwoFactorChallenge extends Component
@ -67,8 +66,7 @@ class TwoFactorChallenge extends Component
// Accept the TOTP code only when the pending user actually has TOTP (otherwise a null // Accept the TOTP code only when the pending user actually has TOTP (otherwise a null
// secret would break verifyKey); always allow a one-time backup (recovery) code. // secret would break verifyKey); always allow a one-time backup (recovery) code.
$valid = ($user->hasTotp() && (new Google2FA)->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code))) $valid = $user->verifyTotp($this->code) || $user->useRecoveryCode($this->code);
|| $user->useRecoveryCode($this->code);
if (! $valid) { if (! $valid) {
RateLimiter::hit($key, 60); RateLimiter::hit($key, 60);

View File

@ -9,7 +9,7 @@ use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
use PragmaRX\Google2FAQRCode\Google2FA; use PragmaRX\Google2FAQRCode\Google2FA;
#[Layout('layouts.app')] #[Layout('layouts.auth')]
class TwoFactorSetup extends Component class TwoFactorSetup extends Component
{ {
public string $secret = ''; public string $secret = '';

View File

@ -11,6 +11,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use PragmaRX\Google2FAQRCode\Google2FA;
#[Fillable(['name', 'email', 'password', 'must_change_password', 'locale'])] #[Fillable(['name', 'email', 'password', 'must_change_password', 'locale'])]
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])] #[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
@ -88,6 +89,24 @@ class User extends Authenticatable
return ! is_null($this->two_factor_confirmed_at) && ! is_null($this->two_factor_secret); return ! is_null($this->two_factor_confirmed_at) && ! is_null($this->two_factor_secret);
} }
/**
* Verify a TOTP code against the stored secret. Returns false (never throws) when the
* user has no TOTP or the stored secret is malformed callers then fall back to a
* backup code.
*/
public function verifyTotp(string $code): bool
{
if (! $this->hasTotp()) {
return false;
}
try {
return (new Google2FA)->verifyKey($this->two_factor_secret, preg_replace('/\s+/', '', $code) ?? '');
} catch (\Throwable) {
return false;
}
}
/** 2FA is satisfied by EITHER factor — TOTP or a registered security key. */ /** 2FA is satisfied by EITHER factor — TOTP or a registered security key. */
public function hasTwoFactorEnabled(): bool public function hasTwoFactorEnabled(): bool
{ {

View File

@ -0,0 +1,36 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PragmaRX\Google2FAQRCode\Google2FA;
use Tests\TestCase;
class VerifyTotpTest extends TestCase
{
use RefreshDatabase;
public function test_no_totp_user_returns_false_without_throwing(): void
{
$this->assertFalse(User::factory()->create()->verifyTotp('123456'));
}
public function test_malformed_secret_returns_false_without_throwing(): void
{
// A non-null but invalid secret must not bubble a Google2FA exception.
$user = User::factory()->create(['two_factor_secret' => 'x', 'two_factor_confirmed_at' => now()]);
$this->assertFalse($user->verifyTotp('123456'));
}
public function test_valid_secret_accepts_the_current_code(): void
{
$g = new Google2FA;
$secret = $g->generateSecretKey();
$user = User::factory()->create(['two_factor_secret' => $secret, 'two_factor_confirmed_at' => now()]);
$this->assertTrue($user->verifyTotp($g->getCurrentOtp($secret)));
$this->assertFalse($user->verifyTotp('000000'));
}
}