Skip to content

Commit 372d48c

Browse files
authored
[6.x] Store passkey last login in metadata (#13808)
1 parent 8669f52 commit 372d48c

6 files changed

Lines changed: 147 additions & 58 deletions

File tree

src/Auth/File/Passkey.php

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace Statamic\Auth\File;
44

5+
use Carbon\Carbon;
6+
use Illuminate\Support\Collection;
57
use Statamic\Auth\WebAuthn\Passkey as BasePasskey;
68
use Statamic\Auth\WebAuthn\Serializer;
79

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

15-
$user->setPasskeys($user->passkeys()->except($this->id()));
17+
$remaining = $user->passkeys()->except($this->id());
18+
19+
$user->setPasskeys($remaining);
20+
21+
$this->setLastLogins($user, $remaining);
1622

1723
$user->save();
1824

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

2935
$user->setPasskeys($passkeys);
3036

37+
$this->setLastLogins($user, $passkeys);
38+
3139
$user->save();
3240

3341
return true;
@@ -37,8 +45,28 @@ public function fileData()
3745
{
3846
return [
3947
'name' => $this->name(),
40-
'last_login' => $this->lastLogin()?->timestamp ?? null,
4148
'credential' => app(Serializer::class)->normalize($this->credential()),
4249
];
4350
}
51+
52+
public function lastLogin(): ?Carbon
53+
{
54+
if (! parent::lastLogin()) {
55+
$this->setLastLogin(
56+
$this->user()->getMeta('passkey_last_logins', [])[$this->id()] ?? null
57+
);
58+
}
59+
60+
return parent::lastLogin();
61+
}
62+
63+
private function setLastLogins(User $user, Collection $passkeys): void
64+
{
65+
$timestamps = $passkeys
66+
->mapWithKeys(fn (Passkey $passkey) => [$passkey->id() => $passkey->lastLogin()?->timestamp])
67+
->filter()
68+
->all();
69+
70+
$user->setMeta('passkey_last_logins', $timestamps);
71+
}
4472
}

src/Stache/Stores/UsersStore.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ public function makeItemFromFile($path, $contents)
5959
return app(Passkey::class)
6060
->setUser($user)
6161
->setName($keydata['name'])
62-
->setLastLogin($keydata['last_login'])
6362
->setCredential($keydata['credential']);
6463
}));
6564

tests/Auth/WebAuthn/EloquentPasskeyTest.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
#[Group('passkeys')]
2020
class EloquentPasskeyTest extends TestCase
2121
{
22-
use RefreshDatabase;
22+
use PasskeyTests, RefreshDatabase;
2323

2424
public static $migrationsGenerated = false;
2525

@@ -66,7 +66,12 @@ public static function tearDownAfterClass(): void
6666
parent::tearDownAfterClass();
6767
}
6868

69-
private function createTestCredential(string $id = 'test-credential-id-123'): PublicKeyCredentialSource
69+
protected function newPasskey(): \Statamic\Contracts\Auth\Passkey
70+
{
71+
return new Passkey;
72+
}
73+
74+
protected function createTestCredential(string $id = 'test-credential-id-123'): PublicKeyCredentialSource
7075
{
7176
return PublicKeyCredentialSource::create(
7277
publicKeyCredentialId: $id,

tests/Auth/WebAuthn/FilePasskeyTest.php

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use PHPUnit\Framework\Attributes\Group;
77
use PHPUnit\Framework\Attributes\Test;
88
use Statamic\Auth\File\Passkey;
9+
use Statamic\Facades\File;
910
use Statamic\Facades\User;
1011
use Symfony\Component\Uid\Uuid;
1112
use Tests\PreventSavingStacheItemsToDisk;
@@ -16,9 +17,21 @@
1617
#[Group('passkeys')]
1718
class FilePasskeyTest extends TestCase
1819
{
19-
use PreventSavingStacheItemsToDisk;
20+
use PasskeyTests, PreventSavingStacheItemsToDisk;
2021

21-
private function createTestCredential(string $id = 'test-credential-id-123'): PublicKeyCredentialSource
22+
public function setUp(): void
23+
{
24+
parent::setUp();
25+
26+
File::delete(storage_path('statamic/users'));
27+
}
28+
29+
protected function newPasskey(): \Statamic\Contracts\Auth\Passkey
30+
{
31+
return new Passkey;
32+
}
33+
34+
protected function createTestCredential(string $id = 'test-credential-id-123'): PublicKeyCredentialSource
2235
{
2336
return PublicKeyCredentialSource::create(
2437
publicKeyCredentialId: $id,
@@ -54,6 +67,25 @@ public function it_saves_passkey_to_user()
5467
$freshUser = User::find('test-user');
5568
$this->assertCount(1, $freshUser->passkeys());
5669
$this->assertEquals('My Passkey', $freshUser->passkeys()->first()->name());
70+
$this->assertEquals([], $user->getMeta('passkey_last_logins'));
71+
}
72+
73+
#[Test]
74+
public function it_reads_last_login_from_meta()
75+
{
76+
$user = User::make()->id('test-user')->email('test@example.com');
77+
$user->save();
78+
79+
$passkey = (new Passkey)
80+
->setName('My Passkey')
81+
->setUser($user)
82+
->setCredential($this->createTestCredential());
83+
$passkey->save();
84+
85+
$lastLogin = Carbon::create(2024, 1, 15, 10, 30, 0);
86+
$user->setMeta('passkey_last_logins', [$passkey->id() => $lastLogin->timestamp]);
87+
88+
$this->assertTrue($passkey->lastLogin()->eq($lastLogin));
5789
}
5890

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

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

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

88121
#[Test]
@@ -112,6 +145,7 @@ public function it_saves_multiple_passkeys_to_same_user()
112145
$names = $freshUser->passkeys()->map->name()->values();
113146
$this->assertTrue($names->contains('Passkey 1'));
114147
$this->assertTrue($names->contains('Passkey 2'));
148+
$this->assertEquals([], $user->getMeta('passkey_last_logins'));
115149
}
116150

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

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

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

153189
$passkey1 = (new Passkey)
154190
->setName('Passkey 1')
155191
->setUser($user)
156-
->setCredential($credential1);
192+
->setCredential($credential1)
193+
->setLastLogin($lastLogin->timestamp);
157194
$passkey1->save();
158195

159196
$passkey2 = (new Passkey)
160197
->setName('Passkey 2')
161198
->setUser($user)
162-
->setCredential($credential2);
199+
->setCredential($credential2)
200+
->setLastLogin($lastLogin->timestamp);
163201
$passkey2->save();
164202

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

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

179219
$credential = $this->createTestCredential();
180220
$lastLogin = Carbon::create(2024, 1, 15, 10, 30, 0);
@@ -193,5 +233,6 @@ public function it_persists_all_passkey_data()
193233
$this->assertEquals('test-credential-id-123', $savedPasskey->credential()->publicKeyCredentialId);
194234
$this->assertEquals('2024-01-15 10:30:00', $savedPasskey->lastLogin()->format('Y-m-d H:i:s'));
195235
$this->assertEquals('test-user', $savedPasskey->user()->id());
236+
$this->assertEquals([$savedPasskey->id() => $lastLogin->timestamp], $user->getMeta('passkey_last_logins'));
196237
}
197238
}

tests/Auth/WebAuthn/PasskeyTest.php

Lines changed: 0 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -96,54 +96,6 @@ public function it_gets_user()
9696
$this->assertEquals('test@example.com', $passkey->user()->email());
9797
}
9898

99-
#[Test]
100-
public function it_gets_last_login()
101-
{
102-
$user = User::make()->id('test-user')->email('test@example.com');
103-
$credential = $this->createTestCredential();
104-
$lastLogin = Carbon::create(2024, 1, 15, 10, 30, 0);
105-
106-
$passkey = (new Passkey)
107-
->setName('My Passkey')
108-
->setUser($user)
109-
->setCredential($credential)
110-
->setLastLogin($lastLogin);
111-
112-
$this->assertInstanceOf(Carbon::class, $passkey->lastLogin());
113-
$this->assertEquals('2024-01-15 10:30:00', $passkey->lastLogin()->format('Y-m-d H:i:s'));
114-
}
115-
116-
#[Test]
117-
public function it_handles_null_last_login()
118-
{
119-
$user = User::make()->id('test-user')->email('test@example.com');
120-
$credential = $this->createTestCredential();
121-
122-
$passkey = (new Passkey)
123-
->setName('My Passkey')
124-
->setUser($user)
125-
->setCredential($credential);
126-
127-
$this->assertNull($passkey->lastLogin());
128-
}
129-
130-
#[Test]
131-
public function it_sets_last_login_from_timestamp()
132-
{
133-
$user = User::make()->id('test-user')->email('test@example.com');
134-
$credential = $this->createTestCredential();
135-
$timestamp = 1705315800; // 2024-01-15 10:30:00 UTC
136-
137-
$passkey = (new Passkey)
138-
->setName('My Passkey')
139-
->setUser($user)
140-
->setCredential($credential)
141-
->setLastLogin($timestamp);
142-
143-
$this->assertInstanceOf(Carbon::class, $passkey->lastLogin());
144-
$this->assertEquals($timestamp, $passkey->lastLogin()->timestamp);
145-
}
146-
14799
#[Test]
148100
public function it_serializes()
149101
{
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
namespace Tests\Auth\WebAuthn;
4+
5+
use Carbon\Carbon;
6+
use PHPUnit\Framework\Attributes\Test;
7+
use Statamic\Contracts\Auth\Passkey;
8+
use Statamic\Facades\User;
9+
use Webauthn\PublicKeyCredentialSource;
10+
11+
trait PasskeyTests
12+
{
13+
abstract protected function newPasskey(): Passkey;
14+
15+
abstract protected function createTestCredential(string $id = 'test-credential-id-123'): PublicKeyCredentialSource;
16+
17+
#[Test]
18+
public function it_gets_last_login()
19+
{
20+
$user = tap(User::make()->email('test@example.com')->data(['name' => 'John Smith']))->save();
21+
$credential = $this->createTestCredential();
22+
$lastLogin = Carbon::create(2024, 1, 15, 10, 30, 0);
23+
24+
$passkey = $this->newPasskey()
25+
->setName('My Passkey')
26+
->setUser($user)
27+
->setCredential($credential)
28+
->setLastLogin($lastLogin);
29+
30+
$this->assertInstanceOf(Carbon::class, $passkey->lastLogin());
31+
$this->assertEquals('2024-01-15 10:30:00', $passkey->lastLogin()->format('Y-m-d H:i:s'));
32+
}
33+
34+
#[Test]
35+
public function it_handles_null_last_login()
36+
{
37+
$user = tap(User::make()->email('test@example.com')->data(['name' => 'John Smith']))->save();
38+
$credential = $this->createTestCredential();
39+
40+
$passkey = $this->newPasskey()
41+
->setName('My Passkey')
42+
->setUser($user)
43+
->setCredential($credential);
44+
45+
$this->assertNull($passkey->lastLogin());
46+
}
47+
48+
#[Test]
49+
public function it_sets_last_login_from_timestamp()
50+
{
51+
$user = tap(User::make()->email('test@example.com')->data(['name' => 'John Smith']))->save();
52+
$credential = $this->createTestCredential();
53+
$timestamp = 1705315800; // 2024-01-15 10:30:00 UTC
54+
55+
$passkey = $this->newPasskey()
56+
->setName('My Passkey')
57+
->setUser($user)
58+
->setCredential($credential)
59+
->setLastLogin($timestamp);
60+
61+
$this->assertInstanceOf(Carbon::class, $passkey->lastLogin());
62+
$this->assertEquals($timestamp, $passkey->lastLogin()->timestamp);
63+
}
64+
}

0 commit comments

Comments
 (0)