feat(2fa): pluggable factor semantics on User (hasTotp, either-factor, resetIfNoFactor)
parent
1273d8290d
commit
9a01bfedf7
|
|
@ -82,19 +82,36 @@ class User extends Authenticatable
|
|||
});
|
||||
}
|
||||
|
||||
public function hasTwoFactorEnabled(): bool
|
||||
/** TOTP factor only: an authenticator secret was confirmed. */
|
||||
public function hasTotp(): bool
|
||||
{
|
||||
return ! is_null($this->two_factor_confirmed_at) && ! is_null($this->two_factor_secret);
|
||||
}
|
||||
|
||||
/** 2FA is satisfied by EITHER factor — TOTP or a registered security key. */
|
||||
public function hasTwoFactorEnabled(): bool
|
||||
{
|
||||
return $this->hasTotp() || $this->hasWebauthnCredentials();
|
||||
}
|
||||
|
||||
/**
|
||||
* Completed the security onboarding (rotated the seeded password + enrolled 2FA) —
|
||||
* the same gate EnsureSecurityOnboarded enforces before the panel is reachable.
|
||||
* Authorization (e.g. broadcast channels) must require this, not mere authentication.
|
||||
* Completed the security onboarding. 2FA is now OPTIONAL, so this is just the forced
|
||||
* password rotation — the gate EnsureSecurityOnboarded and the broadcast channels use.
|
||||
*/
|
||||
public function securityOnboarded(): bool
|
||||
{
|
||||
return ! $this->must_change_password && $this->hasTwoFactorEnabled();
|
||||
return ! $this->must_change_password;
|
||||
}
|
||||
|
||||
/**
|
||||
* When neither factor remains (no TOTP, no keys), 2FA is fully off — so drop the backup
|
||||
* codes (nothing left to recover into). Call after removing any factor.
|
||||
*/
|
||||
public function resetIfNoFactor(): void
|
||||
{
|
||||
if (! $this->hasTotp() && ! $this->hasWebauthnCredentials()) {
|
||||
$this->forceFill(['two_factor_recovery_codes' => null])->save();
|
||||
}
|
||||
}
|
||||
|
||||
public function webauthnCredentials(): HasMany
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WebauthnCredential;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class UserFactorSemanticsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function totpUser(): User
|
||||
{
|
||||
return User::factory()->create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]);
|
||||
}
|
||||
|
||||
private function keyUser(): User
|
||||
{
|
||||
$u = User::factory()->create();
|
||||
WebauthnCredential::create(['user_id' => $u->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]);
|
||||
|
||||
return $u->fresh();
|
||||
}
|
||||
|
||||
public function test_has_totp_only_reflects_totp(): void
|
||||
{
|
||||
$this->assertTrue($this->totpUser()->hasTotp());
|
||||
$this->assertFalse($this->keyUser()->hasTotp());
|
||||
}
|
||||
|
||||
public function test_has_two_factor_enabled_is_either_factor(): void
|
||||
{
|
||||
$this->assertTrue($this->totpUser()->hasTwoFactorEnabled());
|
||||
$this->assertTrue($this->keyUser()->hasTwoFactorEnabled());
|
||||
$this->assertFalse(User::factory()->create()->hasTwoFactorEnabled());
|
||||
}
|
||||
|
||||
public function test_security_onboarded_is_password_rotation_only(): void
|
||||
{
|
||||
$this->assertTrue(User::factory()->create(['must_change_password' => false])->securityOnboarded());
|
||||
$this->assertFalse(User::factory()->create(['must_change_password' => true])->securityOnboarded());
|
||||
}
|
||||
|
||||
public function test_reset_if_no_factor_clears_codes_only_when_no_factor_remains(): void
|
||||
{
|
||||
$totp = $this->totpUser();
|
||||
$totp->replaceRecoveryCodes();
|
||||
$totp->resetIfNoFactor();
|
||||
$this->assertTrue($totp->fresh()->hasRecoveryCodes(), 'codes kept while a factor remains');
|
||||
|
||||
$none = User::factory()->create();
|
||||
$none->replaceRecoveryCodes();
|
||||
$none->resetIfNoFactor();
|
||||
$this->assertFalse($none->fresh()->hasRecoveryCodes(), 'codes cleared when no factor remains');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue