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
parent
61df4497d7
commit
5b1e7e5168
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 = '';
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue