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
32 changes: 30 additions & 2 deletions src/Auth/File/Passkey.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Statamic\Auth\File;

use Carbon\Carbon;
use Illuminate\Support\Collection;
use Statamic\Auth\WebAuthn\Passkey as BasePasskey;
use Statamic\Auth\WebAuthn\Serializer;

Expand All @@ -12,7 +14,11 @@ public function delete(): bool
/** @var User $user */
$user = $this->user();

$user->setPasskeys($user->passkeys()->except($this->id()));
$remaining = $user->passkeys()->except($this->id());

$user->setPasskeys($remaining);

$this->setLastLogins($user, $remaining);

$user->save();

Expand All @@ -28,6 +34,8 @@ public function save(): bool

$user->setPasskeys($passkeys);

$this->setLastLogins($user, $passkeys);

$user->save();

return true;
Expand All @@ -37,8 +45,28 @@ public function fileData()
{
return [
'name' => $this->name(),
'last_login' => $this->lastLogin()?->timestamp ?? null,
'credential' => app(Serializer::class)->normalize($this->credential()),
];
}

public function lastLogin(): ?Carbon
{
if (! parent::lastLogin()) {
$this->setLastLogin(
$this->user()->getMeta('passkey_last_logins', [])[$this->id()] ?? null
);
}

return parent::lastLogin();
}

private function setLastLogins(User $user, Collection $passkeys): void
{
$timestamps = $passkeys
->mapWithKeys(fn (Passkey $passkey) => [$passkey->id() => $passkey->lastLogin()?->timestamp])
->filter()
->all();

$user->setMeta('passkey_last_logins', $timestamps);
}
}
1 change: 0 additions & 1 deletion src/Stache/Stores/UsersStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ public function makeItemFromFile($path, $contents)
return app(Passkey::class)
->setUser($user)
->setName($keydata['name'])
->setLastLogin($keydata['last_login'])
->setCredential($keydata['credential']);
}));

Expand Down
9 changes: 7 additions & 2 deletions tests/Auth/WebAuthn/EloquentPasskeyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
#[Group('passkeys')]
class EloquentPasskeyTest extends TestCase
{
use RefreshDatabase;
use PasskeyTests, RefreshDatabase;

public static $migrationsGenerated = false;

Expand Down Expand Up @@ -66,7 +66,12 @@ public static function tearDownAfterClass(): void
parent::tearDownAfterClass();
}

private function createTestCredential(string $id = 'test-credential-id-123'): PublicKeyCredentialSource
protected function newPasskey(): \Statamic\Contracts\Auth\Passkey
{
return new Passkey;
}

protected function createTestCredential(string $id = 'test-credential-id-123'): PublicKeyCredentialSource
{
return PublicKeyCredentialSource::create(
publicKeyCredentialId: $id,
Expand Down
51 changes: 46 additions & 5 deletions tests/Auth/WebAuthn/FilePasskeyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Auth\File\Passkey;
use Statamic\Facades\File;
use Statamic\Facades\User;
use Symfony\Component\Uid\Uuid;
use Tests\PreventSavingStacheItemsToDisk;
Expand All @@ -16,9 +17,21 @@
#[Group('passkeys')]
class FilePasskeyTest extends TestCase
{
use PreventSavingStacheItemsToDisk;
use PasskeyTests, PreventSavingStacheItemsToDisk;

private function createTestCredential(string $id = 'test-credential-id-123'): PublicKeyCredentialSource
public function setUp(): void
{
parent::setUp();

File::delete(storage_path('statamic/users'));
}

protected function newPasskey(): \Statamic\Contracts\Auth\Passkey
{
return new Passkey;
}

protected function createTestCredential(string $id = 'test-credential-id-123'): PublicKeyCredentialSource
{
return PublicKeyCredentialSource::create(
publicKeyCredentialId: $id,
Expand Down Expand Up @@ -54,6 +67,25 @@ public function it_saves_passkey_to_user()
$freshUser = User::find('test-user');
$this->assertCount(1, $freshUser->passkeys());
$this->assertEquals('My Passkey', $freshUser->passkeys()->first()->name());
$this->assertEquals([], $user->getMeta('passkey_last_logins'));
}

#[Test]
public function it_reads_last_login_from_meta()
{
$user = User::make()->id('test-user')->email('test@example.com');
$user->save();

$passkey = (new Passkey)
->setName('My Passkey')
->setUser($user)
->setCredential($this->createTestCredential());
$passkey->save();

$lastLogin = Carbon::create(2024, 1, 15, 10, 30, 0);
$user->setMeta('passkey_last_logins', [$passkey->id() => $lastLogin->timestamp]);

$this->assertTrue($passkey->lastLogin()->eq($lastLogin));
}

#[Test]
Expand All @@ -73,7 +105,7 @@ public function it_updates_existing_passkey()

// Update the passkey
$passkey->setName('Updated Passkey Name');
$passkey->setLastLogin(Carbon::create(2024, 1, 15, 10, 30, 0));
$passkey->setLastLogin($lastLogin = Carbon::create(2024, 1, 15, 10, 30, 0));
$result = $passkey->save();

$this->assertTrue($result);
Expand All @@ -83,6 +115,7 @@ public function it_updates_existing_passkey()
$this->assertCount(1, $freshUser->passkeys());
$this->assertEquals('Updated Passkey Name', $freshUser->passkeys()->first()->name());
$this->assertNotNull($freshUser->passkeys()->first()->lastLogin());
$this->assertEquals([$passkey->id() => $lastLogin->timestamp], $user->getMeta('passkey_last_logins'));
}

#[Test]
Expand Down Expand Up @@ -112,6 +145,7 @@ public function it_saves_multiple_passkeys_to_same_user()
$names = $freshUser->passkeys()->map->name()->values();
$this->assertTrue($names->contains('Passkey 1'));
$this->assertTrue($names->contains('Passkey 2'));
$this->assertEquals([], $user->getMeta('passkey_last_logins'));
}

#[Test]
Expand Down Expand Up @@ -139,6 +173,7 @@ public function it_deletes_passkey_from_user()
// Verify passkey was removed
$freshUser = User::find('test-user');
$this->assertCount(0, $freshUser->passkeys());
$this->assertEquals([], $user->getMeta('passkey_last_logins'));
}

#[Test]
Expand All @@ -149,17 +184,20 @@ public function it_deletes_only_specified_passkey()

$credential1 = $this->createTestCredential('credential-1');
$credential2 = $this->createTestCredential('credential-2');
$lastLogin = Carbon::create(2024, 1, 15, 10, 30, 0);

$passkey1 = (new Passkey)
->setName('Passkey 1')
->setUser($user)
->setCredential($credential1);
->setCredential($credential1)
->setLastLogin($lastLogin->timestamp);
$passkey1->save();

$passkey2 = (new Passkey)
->setName('Passkey 2')
->setUser($user)
->setCredential($credential2);
->setCredential($credential2)
->setLastLogin($lastLogin->timestamp);
$passkey2->save();

// Delete only the first passkey
Expand All @@ -168,13 +206,15 @@ public function it_deletes_only_specified_passkey()
$freshUser = User::find('test-user');
$this->assertCount(1, $freshUser->passkeys());
$this->assertEquals('Passkey 2', $freshUser->passkeys()->first()->name());
$this->assertEquals([$passkey2->id() => $lastLogin->timestamp], $user->getMeta('passkey_last_logins'));
}

#[Test]
public function it_persists_all_passkey_data()
{
$user = User::make()->id('test-user')->email('test@example.com');
$user->save();
$this->assertNull($user->getMeta('passkey_last_logins'));

$credential = $this->createTestCredential();
$lastLogin = Carbon::create(2024, 1, 15, 10, 30, 0);
Expand All @@ -193,5 +233,6 @@ public function it_persists_all_passkey_data()
$this->assertEquals('test-credential-id-123', $savedPasskey->credential()->publicKeyCredentialId);
$this->assertEquals('2024-01-15 10:30:00', $savedPasskey->lastLogin()->format('Y-m-d H:i:s'));
$this->assertEquals('test-user', $savedPasskey->user()->id());
$this->assertEquals([$savedPasskey->id() => $lastLogin->timestamp], $user->getMeta('passkey_last_logins'));
}
}
48 changes: 0 additions & 48 deletions tests/Auth/WebAuthn/PasskeyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,54 +96,6 @@ public function it_gets_user()
$this->assertEquals('test@example.com', $passkey->user()->email());
}

#[Test]
public function it_gets_last_login()
{
$user = User::make()->id('test-user')->email('test@example.com');
$credential = $this->createTestCredential();
$lastLogin = Carbon::create(2024, 1, 15, 10, 30, 0);

$passkey = (new Passkey)
->setName('My Passkey')
->setUser($user)
->setCredential($credential)
->setLastLogin($lastLogin);

$this->assertInstanceOf(Carbon::class, $passkey->lastLogin());
$this->assertEquals('2024-01-15 10:30:00', $passkey->lastLogin()->format('Y-m-d H:i:s'));
}

#[Test]
public function it_handles_null_last_login()
{
$user = User::make()->id('test-user')->email('test@example.com');
$credential = $this->createTestCredential();

$passkey = (new Passkey)
->setName('My Passkey')
->setUser($user)
->setCredential($credential);

$this->assertNull($passkey->lastLogin());
}

#[Test]
public function it_sets_last_login_from_timestamp()
{
$user = User::make()->id('test-user')->email('test@example.com');
$credential = $this->createTestCredential();
$timestamp = 1705315800; // 2024-01-15 10:30:00 UTC

$passkey = (new Passkey)
->setName('My Passkey')
->setUser($user)
->setCredential($credential)
->setLastLogin($timestamp);

$this->assertInstanceOf(Carbon::class, $passkey->lastLogin());
$this->assertEquals($timestamp, $passkey->lastLogin()->timestamp);
}

#[Test]
public function it_serializes()
{
Expand Down
64 changes: 64 additions & 0 deletions tests/Auth/WebAuthn/PasskeyTests.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

namespace Tests\Auth\WebAuthn;

use Carbon\Carbon;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Contracts\Auth\Passkey;
use Statamic\Facades\User;
use Webauthn\PublicKeyCredentialSource;

trait PasskeyTests
{
abstract protected function newPasskey(): Passkey;

abstract protected function createTestCredential(string $id = 'test-credential-id-123'): PublicKeyCredentialSource;

#[Test]
public function it_gets_last_login()
{
$user = tap(User::make()->email('test@example.com')->data(['name' => 'John Smith']))->save();
$credential = $this->createTestCredential();
$lastLogin = Carbon::create(2024, 1, 15, 10, 30, 0);

$passkey = $this->newPasskey()
->setName('My Passkey')
->setUser($user)
->setCredential($credential)
->setLastLogin($lastLogin);

$this->assertInstanceOf(Carbon::class, $passkey->lastLogin());
$this->assertEquals('2024-01-15 10:30:00', $passkey->lastLogin()->format('Y-m-d H:i:s'));
}

#[Test]
public function it_handles_null_last_login()
{
$user = tap(User::make()->email('test@example.com')->data(['name' => 'John Smith']))->save();
$credential = $this->createTestCredential();

$passkey = $this->newPasskey()
->setName('My Passkey')
->setUser($user)
->setCredential($credential);

$this->assertNull($passkey->lastLogin());
}

#[Test]
public function it_sets_last_login_from_timestamp()
{
$user = tap(User::make()->email('test@example.com')->data(['name' => 'John Smith']))->save();
$credential = $this->createTestCredential();
$timestamp = 1705315800; // 2024-01-15 10:30:00 UTC

$passkey = $this->newPasskey()
->setName('My Passkey')
->setUser($user)
->setCredential($credential)
->setLastLogin($timestamp);

$this->assertInstanceOf(Carbon::class, $passkey->lastLogin());
$this->assertEquals($timestamp, $passkey->lastLogin()->timestamp);
}
}