From 9e984acb1a9df9fa926b1f0ca96411463b40f31c Mon Sep 17 00:00:00 2001 From: Tsuruta <140517179+tsurutanmen@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:06:32 +0900 Subject: [PATCH 1/3] Exclude two_factor_recovery_codes from augmented user values --- src/Auth/AugmentedUser.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Auth/AugmentedUser.php b/src/Auth/AugmentedUser.php index b4369143432..65cd840e6d3 100644 --- a/src/Auth/AugmentedUser.php +++ b/src/Auth/AugmentedUser.php @@ -25,6 +25,7 @@ public function keys() ->merge($this->roleHandles()) ->merge($this->groupHandles()) ->merge($this->blueprintFields()->keys()) + ->diff(['two_factor_recovery_codes']) ->unique()->sort()->values()->all(); } From 8af74f61cd780164f0810162f950b82d5fa0845f Mon Sep 17 00:00:00 2001 From: Tsuruta <140517179+tsurutanmen@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:08:04 +0900 Subject: [PATCH 2/3] Exclude two_factor_recovery_codes from augmented user values --- tests/Auth/AugmentedUserTest.php | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/Auth/AugmentedUserTest.php b/tests/Auth/AugmentedUserTest.php index 90c6e0fb70f..3d4efb13887 100644 --- a/tests/Auth/AugmentedUserTest.php +++ b/tests/Auth/AugmentedUserTest.php @@ -5,6 +5,8 @@ use Carbon\Carbon; use PHPUnit\Framework\Attributes\Test; use Statamic\Auth\AugmentedUser; +use Statamic\Auth\TwoFactor\EnableTwoFactorAuthentication; +use Statamic\Facades\Antlers; use Statamic\Facades\Blueprint; use Statamic\Facades\User; use Tests\Data\AugmentedTestCase; @@ -89,4 +91,38 @@ public function it_gets_values() $this->assertAugmentedCorrectly($expectations, $augmented); } + + #[Test] + public function two_factor_recovery_codes_are_not_exposed_through_augmented_values() + { + $user = User::make() + ->id('user-id') + ->email('john@example.com') + ->data(['name' => 'John Smith']); + + $user->save(); + + app(EnableTwoFactorAuthentication::class)($user); + + $augmented = new AugmentedUser($user); + + // The recovery codes are credentials, so the handle must not be part of + // the user's augmented data (Antlers templates, the API, GraphQL, ...). + $this->assertNotContains('two_factor_recovery_codes', $augmented->keys()); + $this->assertNotContains('two_factor_recovery_codes', $augmented->select()->keys()->all()); + + $rendered = (string) Antlers::parse( + '{{ current_user.two_factor_recovery_codes }}', + ['current_user' => $user], + true + ); + + $this->assertSame('', $rendered); + $this->assertStringNotContainsString($user->get('two_factor_recovery_codes'), $rendered); + + // The internal accessor used by the control panel and the login flow + // must keep working. It reads the stored value through __get(), which + // intentionally still resolves through the augmented layer. + $this->assertCount(8, $user->twoFactorRecoveryCodes()); + } } From 9203aa56ae5082e052bdac6aee84b32209d60f14 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 28 Oct 2026 09:49:19 +0000 Subject: [PATCH 3/3] exclude all two-factor keys from `AugmentedUser` --- src/Auth/AugmentedUser.php | 2 +- tests/Auth/AugmentedUserTest.php | 40 +++++++++++++------------------- 2 files changed, 17 insertions(+), 25 deletions(-) diff --git a/src/Auth/AugmentedUser.php b/src/Auth/AugmentedUser.php index 65cd840e6d3..9852301dd22 100644 --- a/src/Auth/AugmentedUser.php +++ b/src/Auth/AugmentedUser.php @@ -25,7 +25,7 @@ public function keys() ->merge($this->roleHandles()) ->merge($this->groupHandles()) ->merge($this->blueprintFields()->keys()) - ->diff(['two_factor_recovery_codes']) + ->diff(['two_factor_secret', 'two_factor_recovery_codes', 'two_factor_confirmed_at']) ->unique()->sort()->values()->all(); } diff --git a/tests/Auth/AugmentedUserTest.php b/tests/Auth/AugmentedUserTest.php index 3d4efb13887..bc4dd3b655a 100644 --- a/tests/Auth/AugmentedUserTest.php +++ b/tests/Auth/AugmentedUserTest.php @@ -3,6 +3,7 @@ namespace Tests\Auth; use Carbon\Carbon; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Auth\AugmentedUser; use Statamic\Auth\TwoFactor\EnableTwoFactorAuthentication; @@ -93,36 +94,27 @@ public function it_gets_values() } #[Test] - public function two_factor_recovery_codes_are_not_exposed_through_augmented_values() + #[DataProvider('twoFactorKeysProvider')] + public function it_doesnt_augment_two_factor_keys(string $key) { - $user = User::make() - ->id('user-id') - ->email('john@example.com') - ->data(['name' => 'John Smith']); - - $user->save(); + $user = tap(User::make()->email('john@example.com'))->save(); app(EnableTwoFactorAuthentication::class)($user); + $user->set('two_factor_confirmed_at', now()->timestamp); $augmented = new AugmentedUser($user); - // The recovery codes are credentials, so the handle must not be part of - // the user's augmented data (Antlers templates, the API, GraphQL, ...). - $this->assertNotContains('two_factor_recovery_codes', $augmented->keys()); - $this->assertNotContains('two_factor_recovery_codes', $augmented->select()->keys()->all()); - - $rendered = (string) Antlers::parse( - '{{ current_user.two_factor_recovery_codes }}', - ['current_user' => $user], - true - ); - - $this->assertSame('', $rendered); - $this->assertStringNotContainsString($user->get('two_factor_recovery_codes'), $rendered); + $this->assertNotContains($key, $augmented->keys()); + $this->assertNotContains($key, $augmented->select()->keys()->all()); + $this->assertSame('', (string) Antlers::parse("{{ current_user:{$key} }}", ['current_user' => $user], true)); + } - // The internal accessor used by the control panel and the login flow - // must keep working. It reads the stored value through __get(), which - // intentionally still resolves through the augmented layer. - $this->assertCount(8, $user->twoFactorRecoveryCodes()); + public static function twoFactorKeysProvider(): array + { + return [ + 'secret' => ['two_factor_secret'], + 'recovery codes' => ['two_factor_recovery_codes'], + 'confirmed at' => ['two_factor_confirmed_at'], + ]; } }