Skip to content

Commit 9af540e

Browse files
committed
added guest identity
1 parent 1b49d37 commit 9af540e

8 files changed

Lines changed: 285 additions & 8 deletions

File tree

phpstan.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,8 @@ parameters:
3232
identifier: argument.type
3333
count: 1
3434
path: src/Security/User.php
35+
36+
- # getGuestIdentity() is declared via @method as optional; method_exists() guards it at runtime
37+
identifier: function.alreadyNarrowedType
38+
count: 1
39+
path: src/Security/User.php

readme.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,21 @@ If you prefer the identity to be discarded on every logout and expiration, set `
183183
Identity is an object that implements the [Nette\Security\IIdentity](https://api.nette.org/master/Nette/Security/IIdentity.html) interface, the default implementation is [Nette\Security\SimpleIdentity](https://api.nette.org/3.0/Nette/Security/SimpleIdentity.html). And as mentioned, identity is stored in the session, so if, for example, we change the role of some of the logged-in users, old data will be kept in the identity until he logs in again.
184184

185185

186+
Guest Identity
187+
--------------
188+
189+
You can provide an identity even for users who are not logged in. If the authenticator implements `IdentityHandler` and its optional method `getGuestIdentity()`, the returned identity is used as a fallback for `getIdentity()`, `getId()` and `getRoles()`. So anonymous visitors can carry their own roles and data instead of the plain `guest` role:
190+
191+
```php
192+
public function getGuestIdentity(): ?Nette\Security\IIdentity
193+
{
194+
return new Nette\Security\SimpleIdentity('guest', ['guest'], ['name' => 'Guest']);
195+
}
196+
```
197+
198+
The guest identity is resolved only when reading and is never stored.
199+
200+
186201

187202
Authorization
188203
=============

src/Security/IdentityHandler.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
/**
1212
* Serializes and restores identity to/from persistent storage.
13+
* @method ?IIdentity getGuestIdentity()
1314
*/
1415
interface IdentityHandler
1516
{

src/Security/User.php

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ class User
4444
/** @deprecated use User::LogoutInactivity */
4545
public const INACTIVITY = self::LogoutInactivity;
4646

47-
/** default role for unauthenticated user */
47+
/** role for an unauthenticated user, unless a guest identity provides its own roles */
4848
public string $guestRole = 'guest';
4949

5050
/** default role for authenticated user without own identity */
@@ -62,6 +62,8 @@ class User
6262
private ?IIdentity $identity = null;
6363
private ?bool $authenticated = null;
6464
private ?int $logoutReason = null;
65+
private ?IIdentity $guestIdentity = null;
66+
private bool $guestIdentityResolved = false;
6567

6668

6769
public function __construct(
@@ -143,13 +145,13 @@ final public function isLoggedIn(): bool
143145

144146

145147
/**
146-
* Returns the user identity. It may be available even when not logged in (e.g. after logout or expiration)
147-
* unless $persistIdentity is disabled, so its presence does not imply the user is logged in; null if none.
148+
* Returns the user identity. When not logged in, this is the retained identity (unless $persistIdentity
149+
* is disabled) or a guest identity if the authenticator provides one; null otherwise.
148150
*/
149151
final public function getIdentity(): ?IIdentity
150152
{
151153
$this->loadStoredData();
152-
return $this->identity;
154+
return $this->identity ?? $this->resolveGuestIdentity();
153155
}
154156

155157

@@ -173,9 +175,23 @@ private function loadStoredData(): void
173175
}
174176

175177

178+
/** Returns the guest identity provided by the IdentityHandler authenticator, or null. */
179+
private function resolveGuestIdentity(): ?IIdentity
180+
{
181+
if (!$this->guestIdentityResolved) {
182+
$this->guestIdentityResolved = true;
183+
$this->guestIdentity = $this->authenticator instanceof IdentityHandler && method_exists($this->authenticator, 'getGuestIdentity')
184+
? $this->authenticator->getGuestIdentity()
185+
: null;
186+
}
187+
188+
return $this->guestIdentity;
189+
}
190+
191+
176192
/**
177-
* Returns the ID of the identity returned by getIdentity(), so it may be available even when not
178-
* logged in; null if there is no identity.
193+
* Returns the ID of the identity returned by getIdentity(), so it may be the retained or guest
194+
* identity's ID even when not logged in; null if there is no identity.
179195
*/
180196
public function getId(): string|int|null
181197
{
@@ -190,6 +206,8 @@ public function getId(): string|int|null
190206
final public function refreshStorage(): void
191207
{
192208
$this->identity = $this->authenticated = $this->logoutReason = null;
209+
$this->guestIdentity = null;
210+
$this->guestIdentityResolved = false;
193211
}
194212

195213

@@ -199,6 +217,7 @@ final public function refreshStorage(): void
199217
public function setAuthenticator(IAuthenticator $handler): static
200218
{
201219
$this->authenticator = $handler;
220+
$this->guestIdentityResolved = false;
202221
return $this;
203222
}
204223

@@ -257,13 +276,13 @@ final public function getLogoutReason(): ?int
257276

258277
/**
259278
* Returns effective roles derived from the login state, not from the (possibly retained) identity.
260-
* Logged in: the identity's roles, or authenticatedRole. Otherwise: the guestRole.
279+
* Logged in: the identity's roles, or authenticatedRole. Otherwise: the guest identity's roles, or guestRole.
261280
* @return list<string>
262281
*/
263282
public function getRoles(): array
264283
{
265284
if (!$this->isLoggedIn()) {
266-
return [$this->guestRole];
285+
return $this->resolveGuestIdentity()?->getRoles() ?? [$this->guestRole];
267286
}
268287

269288
$identity = $this->getIdentity();
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php declare(strict_types=1);
2+
3+
/**
4+
* Test: Nette\Security\User getRoles() returns an identity's empty roles verbatim,
5+
* without falling back to $authenticatedRole or $guestRole.
6+
*/
7+
8+
use Nette\Security\IIdentity;
9+
use Nette\Security\SimpleIdentity;
10+
use Nette\Security\User;
11+
use Tester\Assert;
12+
13+
14+
require __DIR__ . '/../bootstrap.php';
15+
require __DIR__ . '/MockUserStorage.php';
16+
17+
18+
test('logged-in identity with empty roles is returned as-is (no authenticatedRole)', function () {
19+
$authenticator = new class implements Nette\Security\Authenticator {
20+
public function authenticate(string $username, string $password): IIdentity
21+
{
22+
return new SimpleIdentity('john', []);
23+
}
24+
};
25+
$user = new User(new MockUserStorage, $authenticator);
26+
$user->login('john', 'pass');
27+
28+
Assert::true($user->isLoggedIn());
29+
Assert::same([], $user->getRoles());
30+
});
31+
32+
33+
test('guest identity with empty roles is returned as-is (no guestRole)', function () {
34+
$authenticator = new class implements Nette\Security\Authenticator, Nette\Security\IdentityHandler {
35+
public function authenticate(string $username, string $password): IIdentity
36+
{
37+
return new SimpleIdentity('john', ['admin']);
38+
}
39+
40+
41+
public function sleepIdentity(IIdentity $identity): IIdentity
42+
{
43+
return $identity;
44+
}
45+
46+
47+
public function wakeupIdentity(IIdentity $identity): ?IIdentity
48+
{
49+
return $identity;
50+
}
51+
52+
53+
public function getGuestIdentity(): ?IIdentity
54+
{
55+
return new SimpleIdentity('guest', []);
56+
}
57+
};
58+
$user = new User(new MockUserStorage, $authenticator);
59+
60+
Assert::false($user->isLoggedIn());
61+
Assert::same([], $user->getRoles());
62+
});
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
<?php declare(strict_types=1);
2+
3+
/**
4+
* Test: Nette\Security\User guest identity.
5+
*/
6+
7+
use Nette\Security\IIdentity;
8+
use Nette\Security\SimpleIdentity;
9+
use Nette\Security\User;
10+
use Tester\Assert;
11+
12+
13+
require __DIR__ . '/../bootstrap.php';
14+
require __DIR__ . '/MockUserStorage.php';
15+
16+
17+
class GuestAuthenticator implements Nette\Security\Authenticator, Nette\Security\IdentityHandler
18+
{
19+
public function authenticate(string $username, string $password): IIdentity
20+
{
21+
return new SimpleIdentity('john', ['admin']);
22+
}
23+
24+
25+
public function sleepIdentity(IIdentity $identity): IIdentity
26+
{
27+
return $identity;
28+
}
29+
30+
31+
public function wakeupIdentity(IIdentity $identity): ?IIdentity
32+
{
33+
return $identity;
34+
}
35+
36+
37+
public function getGuestIdentity(): ?IIdentity
38+
{
39+
return new SimpleIdentity('guest', ['guest-role'], ['name' => 'Guest']);
40+
}
41+
}
42+
43+
44+
class RecordingStorage implements Nette\Security\UserStorage
45+
{
46+
/** @var list<IIdentity> */
47+
public array $saved = [];
48+
private bool $auth = false;
49+
private ?IIdentity $identity = null;
50+
51+
52+
public function saveAuthentication(IIdentity $identity): void
53+
{
54+
$this->saved[] = $identity;
55+
$this->auth = true;
56+
$this->identity = $identity;
57+
}
58+
59+
60+
public function clearAuthentication(bool $clearIdentity): void
61+
{
62+
$this->auth = false;
63+
$this->identity = $clearIdentity ? null : $this->identity;
64+
}
65+
66+
67+
public function getState(): array
68+
{
69+
return [$this->auth, $this->identity, null];
70+
}
71+
72+
73+
public function setExpiration(?string $expire, bool $clearIdentity): void
74+
{
75+
}
76+
}
77+
78+
79+
test('guest identity is exposed when not logged in', function () {
80+
$user = new User(new MockUserStorage, new GuestAuthenticator);
81+
82+
Assert::false($user->isLoggedIn());
83+
Assert::equal(new SimpleIdentity('guest', ['guest-role'], ['name' => 'Guest']), $user->getIdentity());
84+
Assert::same('guest', $user->getId());
85+
Assert::same(['guest-role'], $user->getRoles());
86+
Assert::true($user->isInRole('guest-role'));
87+
Assert::false($user->isInRole('admin'));
88+
});
89+
90+
91+
test('login overrides the guest identity', function () {
92+
$user = new User(new MockUserStorage, new GuestAuthenticator);
93+
$user->login('john', 'pass');
94+
95+
Assert::true($user->isLoggedIn());
96+
Assert::same('john', $user->getId());
97+
Assert::same(['admin'], $user->getRoles());
98+
});
99+
100+
101+
test('guest identity returns after logout that clears identity', function () {
102+
$user = new User(new MockUserStorage, new GuestAuthenticator);
103+
$user->login('john', 'pass');
104+
105+
$user->logout(clearIdentity: true);
106+
107+
Assert::false($user->isLoggedIn());
108+
Assert::same('guest', $user->getId());
109+
Assert::same(['guest-role'], $user->getRoles());
110+
});
111+
112+
113+
test('retained identity stays for personalization but roles fall back to the guest identity', function () {
114+
$user = new User(new MockUserStorage, new GuestAuthenticator);
115+
$user->login('john', 'pass');
116+
117+
$user->logout(); // persistIdentity is on by default -> identity is retained
118+
119+
Assert::false($user->isLoggedIn());
120+
Assert::same('john', $user->getId()); // retained real identity for personalization
121+
Assert::same(['guest-role'], $user->getRoles()); // but NOT the real ['admin'] roles
122+
});
123+
124+
125+
test('without a guest identity provider the behaviour is unchanged', function () {
126+
$authenticator = new class implements Nette\Security\Authenticator {
127+
public function authenticate(string $username, string $password): IIdentity
128+
{
129+
return new SimpleIdentity('john', ['admin']);
130+
}
131+
};
132+
$user = new User(new MockUserStorage, $authenticator);
133+
134+
Assert::false($user->isLoggedIn());
135+
Assert::null($user->getIdentity());
136+
Assert::same(['guest'], $user->getRoles());
137+
});
138+
139+
140+
test('guest identity is never written to storage', function () {
141+
$storage = new RecordingStorage;
142+
$user = new User($storage, new GuestAuthenticator);
143+
144+
// acting as a guest must not persist anything
145+
Assert::same('guest', $user->getId());
146+
Assert::same(['guest-role'], $user->getRoles());
147+
Assert::equal(new SimpleIdentity('guest', ['guest-role'], ['name' => 'Guest']), $user->getIdentity());
148+
Assert::same([], $storage->saved);
149+
150+
// login/logout cycle, then guest again
151+
$user->login('john', 'pass');
152+
$user->logout(clearIdentity: true);
153+
Assert::same('guest', $user->getId());
154+
155+
// only the real identity may ever reach the storage
156+
Assert::same(['john'], array_map(fn(IIdentity $i) => $i->getId(), $storage->saved));
157+
});

tests/Security/User.identityHandler.phpt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ class AuthenticatorWithHandler implements Nette\Security\Authenticator, Nette\Se
5252
// Real implementation would fetch fresh data from DB
5353
return new SimpleIdentity($identity->getId(), ['admin', 'user'], ['name' => 'John Doe Updated']);
5454
}
55+
56+
57+
public function getGuestIdentity(): ?IIdentity
58+
{
59+
return null;
60+
}
5561
}
5662

5763

@@ -124,6 +130,12 @@ test('IdentityHandler.wakeupIdentity() returning null logs user out', function (
124130
// Simulate invalid token/expired session
125131
return null;
126132
}
133+
134+
135+
public function getGuestIdentity(): ?IIdentity
136+
{
137+
return null;
138+
}
127139
};
128140

129141
$storage = new MockUserStorage;

tests/Security/User.refreshStorage.phpt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,12 @@ test('refreshStorage() with IdentityHandler triggers wakeup again', function ()
195195
array_merge($identity->getRoles(), ['role' . $this->wakeupCount]),
196196
);
197197
}
198+
199+
200+
public function getGuestIdentity(): ?IIdentity
201+
{
202+
return null;
203+
}
198204
};
199205

200206
$storage = new MutableStorage;

0 commit comments

Comments
 (0)