From f9b92348a38116ffdc1f8128e846160ee2b477c9 Mon Sep 17 00:00:00 2001 From: codemenschendev Date: Sat, 26 Sep 2026 16:45:04 +0700 Subject: [PATCH] fix(api): freeze the clock in the admin 2FA tests The test "a new sign in asks for a fresh code and refuses a used one" built its TOTP codes from the real clock. When a run crossed a 30 second step boundary between enrolling and reading time() again, the "used" code belonged to the next step and was accepted (200 instead of 422, CI run 36232930244). Totp::verify now reads Laravel's clock (now()) instead of PHP's time(), so a frozen test clock also freezes the window the server checks. The test class travels to a fixed moment 15 seconds into a step and derives every code from that step. The assertions are unchanged: a used step is refused, the next one is accepted. Project: Appwerk --- apps/api/app/Domain/Security/Totp.php | 3 ++- apps/api/tests/Feature/AdminSecurityTest.php | 25 +++++++++++++++----- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/api/app/Domain/Security/Totp.php b/apps/api/app/Domain/Security/Totp.php index ec85d70..1b480f3 100644 --- a/apps/api/app/Domain/Security/Totp.php +++ b/apps/api/app/Domain/Security/Totp.php @@ -53,6 +53,7 @@ public static function at(string $secret, int $step): string * The time step the code belongs to, or null. One step either side is accepted, because a * phone's clock drifts and a code typed in its last second arrives in the next step. A step * at or before $after has been used already and is refused, so an overheard code is worthless. + * The clock is Laravel's, so a test that freezes time freezes the code window with it. */ public static function verify(string $secret, string $code, ?int $after = null, ?int $now = null): ?int { @@ -60,7 +61,7 @@ public static function verify(string $secret, string $code, ?int $after = null, if (! preg_match('/^\d{'.self::DIGITS.'}$/', $code)) { return null; } - $current = intdiv($now ?? time(), self::STEP); + $current = intdiv($now ?? now()->getTimestamp(), self::STEP); foreach ([$current, $current - 1, $current + 1] as $step) { if (($after === null || $step > $after) && hash_equals(self::at($secret, $step), $code)) { return $step; diff --git a/apps/api/tests/Feature/AdminSecurityTest.php b/apps/api/tests/Feature/AdminSecurityTest.php index 37652ea..b3b3446 100644 --- a/apps/api/tests/Feature/AdminSecurityTest.php +++ b/apps/api/tests/Feature/AdminSecurityTest.php @@ -7,12 +7,26 @@ use App\Models\AuditLog; use App\Models\Customer; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Carbon; use Tests\TestCase; class AdminSecurityTest extends TestCase { use RefreshDatabase; + /** The TOTP step the frozen clock sits in (2026-09-21), and a moment 15 seconds into it. */ + private const STEP = 59_666_667; + + private const NOW = self::STEP * 30 + 15; + + protected function setUp(): void + { + parent::setUp(); + // Codes are built from this frozen clock and the server checks against it, so a run can never + // straddle a 30 second step boundary and see a used code turn into a fresh one. + $this->travelTo(Carbon::createFromTimestamp(self::NOW)); + } + private function admin(): Customer { return Customer::create(['email' => 'chef@example.com', 'locale' => 'de', 'is_admin' => true]); @@ -30,7 +44,7 @@ private function bearer(string $token): array private function enrol(Customer $admin, string $token): string { $secret = $this->postJson('/api/admin/2fa/setup', [], $this->bearer($token))->assertOk()->json('secret'); - $this->postJson('/api/admin/2fa/enable', ['code' => Totp::at($secret, intdiv(time(), 30))], $this->bearer($token)) + $this->postJson('/api/admin/2fa/enable', ['code' => Totp::at($secret, self::STEP)], $this->bearer($token)) ->assertOk()->assertJsonCount(10, 'recovery_codes'); return $secret; @@ -76,7 +90,7 @@ public function test_switching_off_needs_a_current_code(): void $secret = $this->enrol($admin, $token); $this->postJson('/api/admin/2fa/disable', ['code' => '000000'], $this->bearer($token))->assertStatus(422); - $this->postJson('/api/admin/2fa/disable', ['code' => Totp::at($secret, intdiv(time(), 30) + 1)], $this->bearer($token)) + $this->postJson('/api/admin/2fa/disable', ['code' => Totp::at($secret, self::STEP + 1)], $this->bearer($token)) ->assertOk()->assertJson(['enabled' => false]); $this->assertNull($admin->fresh()->two_factor_enabled_at); @@ -91,13 +105,12 @@ public function test_a_new_sign_in_asks_for_a_fresh_code_and_refuses_a_used_one( $admin = $this->admin(); $secret = $this->enrol($admin, $admin->createToken('ops')->plainTextToken); $next = $admin->createToken('ops')->plainTextToken; - $now = intdiv(time(), 30); $this->getJson('/api/admin/overview', $this->bearer($next))->assertForbidden()->assertJson(['two_factor' => 'verify']); $this->postJson('/api/admin/2fa/verify', ['code' => '000000'], $this->bearer($next))->assertStatus(422); // The code that switched 2FA on was used; the same step must not open a second session. - $this->postJson('/api/admin/2fa/verify', ['code' => Totp::at($secret, $now)], $this->bearer($next))->assertStatus(422); - $this->postJson('/api/admin/2fa/verify', ['code' => Totp::at($secret, $now + 1)], $this->bearer($next))->assertOk(); + $this->postJson('/api/admin/2fa/verify', ['code' => Totp::at($secret, self::STEP)], $this->bearer($next))->assertStatus(422); + $this->postJson('/api/admin/2fa/verify', ['code' => Totp::at($secret, self::STEP + 1)], $this->bearer($next))->assertOk(); $this->getJson('/api/admin/overview', $this->bearer($next))->assertOk(); } @@ -107,7 +120,7 @@ public function test_a_recovery_code_works_once(): void $admin = $this->admin(); $token = $admin->createToken('ops')->plainTextToken; $secret = $this->postJson('/api/admin/2fa/setup', [], $this->bearer($token))->json('secret'); - $codes = $this->postJson('/api/admin/2fa/enable', ['code' => Totp::at($secret, intdiv(time(), 30))], $this->bearer($token))->json('recovery_codes'); + $codes = $this->postJson('/api/admin/2fa/enable', ['code' => Totp::at($secret, self::STEP)], $this->bearer($token))->json('recovery_codes'); $first = $admin->createToken('ops')->plainTextToken; $this->postJson('/api/admin/2fa/verify', ['code' => strtoupper($codes[0])], $this->bearer($first))