diff --git a/app/Models/User.php b/app/Models/User.php index abe5043..74b4f44 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 diff --git a/tests/Feature/UserFactorSemanticsTest.php b/tests/Feature/UserFactorSemanticsTest.php new file mode 100644 index 0000000..c9d9531 --- /dev/null +++ b/tests/Feature/UserFactorSemanticsTest.php @@ -0,0 +1,58 @@ +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'); + } +}