Skip to content

Commit d4d7804

Browse files
Add AuthenticationComponent::replaceIdentity() (#788)
* Add AuthenticationComponent::replaceIdentity() Swaps the in-request identity attribute without going through clearIdentity()/persistIdentity(). Useful for cache-warming the active identity (eager-loaded associations, computed flags) without ending impersonation or rotating the session. Adds AuthenticationServiceInterface::buildIdentity() so the component can build an identity object using the configured identityClass via the public service API. * Address PR review: drop interface BC break, add preserveImpersonation option - AuthenticationServiceInterface: revert added buildIdentity() method declaration and replace with a @method docblock annotation. Adding a method to the interface is BC-breaking for any third-party implementer and cannot ship in 4.x or 4.next. - AuthenticationComponent::setIdentity() gains a $preserveImpersonation flag. When true, the new identity is persisted into the session as usual, but an active impersonation session is left intact (as is the successfully resolved authenticator). - AuthenticationService::clearIdentity() gains an optional third $stopImpersonation parameter that backs the new behavior. The interface signature is unchanged, so external implementers remain compatible. - Adds tests covering both the preserveImpersonation path and the default path that still ends impersonation. * Document replaceIdentity() and setIdentity() preserveImpersonation option - Adds a 'Replacing the current identity' section to authentication-component.md covering setIdentity(), replaceIdentity() and the preserveImpersonation flag with usage examples. - Adds a third 'Limitations' bullet to impersonation.md explaining that setIdentity()/clearIdentity() end impersonation by default and pointing at the two new APIs as the supported workarounds. * Apply suggestions from code review Co-authored-by: Mark Story <mark@mark-story.com> * Fix isImpersonating() throwing after default setIdentity() After a default setIdentity() the successful authenticator is reset to null (via clearIdentity with stopImpersonation). Calling isImpersonating() afterwards then threw InvalidArgumentException 'No AuthenticationProvider present.' instead of reporting that no impersonation is active. Treat a missing authentication provider as not impersonating, since an unauthenticated request cannot be impersonating anyone. The misconfig throw for providers that do not implement ImpersonationInterface is preserved. * Drop setIdentity()/clearIdentity() signature changes, defer to next major Per review: replaceIdentity() already covers the reported request-only refresh case as an opt-in addition, so the preserveImpersonation option on setIdentity() and the stopImpersonation flag on clearIdentity() are not needed on 4.x. Revert both to their original signatures and keep the public API surface unchanged for this minor; the signature expansion can land in the next major instead. Kept: replaceIdentity(), the buildIdentity() interface annotation it relies on, the isImpersonating() null guard, and the default-behavior regression test. --------- Co-authored-by: Mark Story <mark@mark-story.com>
1 parent ee24bd4 commit d4d7804

6 files changed

Lines changed: 213 additions & 0 deletions

File tree

docs/en/authentication-component.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,44 @@ The result returned will contain an array like this:
102102
> context you're working in you'll have to use these instances from now on if you
103103
> want to continue to work with the modified response and request objects.
104104
105+
## Replacing the current identity
106+
107+
Use `setIdentity()` to change which user is logged in (e.g. after registration
108+
or social-login first-touch). It clears all persisted identity data and writes
109+
the new identity through every persisting authenticator:
110+
111+
```php
112+
$this->Authentication->setIdentity($user);
113+
```
114+
115+
> [!WARNING]
116+
> `setIdentity()` ends an active impersonation session by default, because it
117+
> goes through `clearIdentity()` first, which calls `stopImpersonating()` on
118+
> impersonation-aware authenticators. Use `replaceIdentity()` below for the
119+
> request-only refresh case.
120+
121+
### Refresh the active identity for the current request only
122+
123+
When you only need to swap the in-request identity (for example to attach
124+
eager-loaded associations or computed flags in `beforeFilter()`) without
125+
touching the session or persistence, use `replaceIdentity()`:
126+
127+
```php
128+
// AppController::beforeFilter()
129+
$identity = $this->Authentication->getIdentity();
130+
if ($identity && !$identity->some_association) {
131+
$reloaded = $this->fetchTable('Users')
132+
->get($identity->getIdentifier(), finder: 'fullProfile');
133+
$this->Authentication->replaceIdentity($reloaded);
134+
}
135+
```
136+
137+
This rewrites only the request attribute. The session is not modified, so an
138+
active impersonation is preserved and no privilege-escalation side effects
139+
(like session rotation) occur.
140+
141+
See [User Impersonation](impersonation.md) for the broader context.
142+
105143
## Configure Automatic Identity Checks
106144

107145
By default `AuthenticationComponent` will automatically enforce an identity to

docs/en/impersonation.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,17 @@ There are a few limitations to impersonation.
6767
1. Your application must be using the `Session` authenticator.
6868
2. You cannot impersonate another user while impersonation is active. Instead
6969
you must `stopImpersonating()` and then start it again.
70+
3. Calling `setIdentity()` or `clearIdentity()` (and therefore `logout()`)
71+
ends impersonation by default. The service's `clearIdentity()` actively
72+
calls `stopImpersonating()` on impersonation-aware authenticators, so any
73+
code path that swaps the persisted identity will revert to the original
74+
user.
75+
76+
To refresh the active identity without disturbing impersonation, use
77+
`replaceIdentity($identity)` on `AuthenticationComponent`. It updates the
78+
in-request identity attribute only - the session is not touched. Use this
79+
for the common `beforeFilter()` case of attaching eager-loaded
80+
associations to the active user for the rest of the request.
81+
82+
See [Replacing the current identity](authentication-component.md#replacing-the-current-identity)
83+
for examples.

src/AuthenticationService.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,10 @@ public function stopImpersonating(ServerRequestInterface $request, ResponseInter
515515
*/
516516
public function isImpersonating(ServerRequestInterface $request): bool
517517
{
518+
if (!$this->getAuthenticationProvider() instanceof AuthenticatorInterface) {
519+
return false;
520+
}
521+
518522
$provider = $this->getImpersonationProvider();
519523

520524
return $provider->isImpersonating($request);

src/AuthenticationServiceInterface.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
use Authentication\Authenticator\ResultInterface;
2222
use Psr\Http\Message\ServerRequestInterface;
2323

24+
/**
25+
* @method \Authentication\IdentityInterface buildIdentity(\ArrayAccess<string, mixed>|array<string, mixed> $identityData) Build an identity object from raw identity data.
26+
*/
2427
interface AuthenticationServiceInterface extends PersistenceInterface
2528
{
2629
/**

src/Controller/Component/AuthenticationComponent.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,38 @@ public function setIdentity(ArrayAccess|array $identity)
322322
return $this;
323323
}
324324

325+
/**
326+
* Replace the in-request identity object without persisting it.
327+
*
328+
* Use this when you only need to swap the identity attribute on the
329+
* current request - for example, to attach eager-loaded associations
330+
* or computed flags to the active user for the rest of the request -
331+
* without going through `clearIdentity()` and `persistIdentity()`.
332+
*
333+
* Unlike `setIdentity()`, this does not touch the session and does not
334+
* end an active impersonation, because no authenticator's
335+
* `clearIdentity()` is invoked.
336+
*
337+
* @param \ArrayAccess|array $identity Identity data or an identity object.
338+
* @return $this
339+
*/
340+
public function replaceIdentity(ArrayAccess|array $identity)
341+
{
342+
$controller = $this->getController();
343+
$service = $this->getAuthenticationService();
344+
345+
$identity = $service->buildIdentity($identity);
346+
347+
$controller->setRequest(
348+
$controller->getRequest()->withAttribute(
349+
$service->getIdentityAttribute(),
350+
$identity,
351+
),
352+
);
353+
354+
return $this;
355+
}
356+
325357
/**
326358
* Log a user out.
327359
*

tests/TestCase/Controller/Component/AuthenticationComponentTest.php

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,128 @@ public function testSetIdentityOverwrite(): void
280280
);
281281
}
282282

283+
/**
284+
* Ensure replaceIdentity() swaps the request attribute without
285+
* touching the session.
286+
*
287+
* @return void
288+
*/
289+
public function testReplaceIdentity(): void
290+
{
291+
$request = $this->request->withAttribute('authentication', $this->service);
292+
293+
$controller = new Controller($request);
294+
$registry = new ComponentRegistry($controller);
295+
$component = new AuthenticationComponent($registry);
296+
297+
$component->replaceIdentity($this->identityData);
298+
299+
$result = $component->getIdentity();
300+
$this->assertInstanceOf(IdentityInterface::class, $result);
301+
$this->assertSame($this->identityData, $result->getOriginalData());
302+
$this->assertNull(
303+
$controller->getRequest()->getSession()->read('Auth'),
304+
'Session must not be written by replaceIdentity().',
305+
);
306+
}
307+
308+
/**
309+
* Test that replaceIdentity() called with an identity instance keeps the
310+
* exact instance as the request attribute.
311+
*
312+
* @return void
313+
*/
314+
public function testReplaceIdentityInstance(): void
315+
{
316+
$request = $this->request->withAttribute('authentication', $this->service);
317+
318+
$controller = new Controller($request);
319+
$registry = new ComponentRegistry($controller);
320+
$component = new AuthenticationComponent($registry);
321+
322+
$identity = new Identity($this->identityData);
323+
$component->replaceIdentity($identity);
324+
325+
$this->assertSame($identity, $component->getIdentity());
326+
}
327+
328+
/**
329+
* Ensure replaceIdentity() does not end an active impersonation,
330+
* unlike setIdentity() which clears identity first.
331+
*
332+
* @return void
333+
*/
334+
public function testReplaceIdentityKeepsImpersonation(): void
335+
{
336+
$impersonator = new ArrayObject(['username' => 'mariano']);
337+
$impersonated = new ArrayObject(['username' => 'larry']);
338+
$this->request->getSession()->write('Auth', $impersonator);
339+
$this->service->authenticate($this->request);
340+
$identity = new Identity($impersonator);
341+
$request = $this->request
342+
->withAttribute('identity', $identity)
343+
->withAttribute('authentication', $this->service);
344+
$controller = new Controller($request);
345+
$registry = new ComponentRegistry($controller);
346+
$component = new AuthenticationComponent($registry);
347+
348+
$component->impersonate($impersonated);
349+
$this->assertEquals($impersonated, $controller->getRequest()->getSession()->read('Auth'));
350+
$this->assertEquals($impersonator, $controller->getRequest()->getSession()->read('AuthImpersonate'));
351+
352+
$reloaded = new ArrayObject(['username' => 'larry', 'profile' => 'loaded']);
353+
$component->replaceIdentity($reloaded);
354+
355+
$this->assertSame(
356+
$reloaded,
357+
$component->getIdentity()->getOriginalData(),
358+
'Request identity should reflect the reloaded user.',
359+
);
360+
$this->assertEquals(
361+
$impersonated,
362+
$controller->getRequest()->getSession()->read('Auth'),
363+
'Session Auth slot must be untouched by replaceIdentity().',
364+
);
365+
$this->assertEquals(
366+
$impersonator,
367+
$controller->getRequest()->getSession()->read('AuthImpersonate'),
368+
'Impersonation must survive replaceIdentity().',
369+
);
370+
$this->assertTrue($component->isImpersonating());
371+
}
372+
373+
/**
374+
* Ensure that `setIdentity()` with the default behavior still ends an
375+
* active impersonation - we do not want to silently change BC.
376+
*
377+
* @return void
378+
*/
379+
public function testSetIdentityDefaultEndsImpersonation(): void
380+
{
381+
$impersonator = new ArrayObject(['username' => 'mariano']);
382+
$impersonated = new ArrayObject(['username' => 'larry']);
383+
$this->request->getSession()->write('Auth', $impersonator);
384+
$this->service->authenticate($this->request);
385+
$identity = new Identity($impersonator);
386+
$request = $this->request
387+
->withAttribute('identity', $identity)
388+
->withAttribute('authentication', $this->service);
389+
$controller = new Controller($request);
390+
$registry = new ComponentRegistry($controller);
391+
$component = new AuthenticationComponent($registry);
392+
393+
$component->impersonate($impersonated);
394+
395+
$reloaded = new ArrayObject(['username' => 'larry', 'profile' => 'loaded']);
396+
$component->setIdentity($reloaded);
397+
398+
$this->assertNull(
399+
$controller->getRequest()->getSession()->read('AuthImpersonate'),
400+
'Default setIdentity() must end an active impersonation.',
401+
);
402+
$this->assertFalse($component->isImpersonating());
403+
}
404+
283405
/**
284406
* testGetIdentity
285407
*

0 commit comments

Comments
 (0)