Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/api/app/Domain/Security/Totp.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,15 @@ 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
{
$code = preg_replace('/\s+/', '', $code);
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;
Expand Down
25 changes: 19 additions & 6 deletions apps/api/tests/Feature/AdminSecurityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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();
}
Expand All @@ -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))
Expand Down
Loading