From 118dc472c66542d226fef4ad46bc005d2e3ae972 Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 28 Apr 2026 22:30:55 +0200 Subject: [PATCH 1/3] 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. --- src/AuthenticationServiceInterface.php | 12 +++ .../Component/AuthenticationComponent.php | 32 +++++++ .../Component/AuthenticationComponentTest.php | 90 +++++++++++++++++++ 3 files changed, 134 insertions(+) diff --git a/src/AuthenticationServiceInterface.php b/src/AuthenticationServiceInterface.php index 570253ae..0b8d537f 100644 --- a/src/AuthenticationServiceInterface.php +++ b/src/AuthenticationServiceInterface.php @@ -16,6 +16,7 @@ */ namespace Authentication; +use ArrayAccess; use Authentication\Authenticator\AuthenticatorInterface; use Authentication\Authenticator\PersistenceInterface; use Authentication\Authenticator\ResultInterface; @@ -69,6 +70,17 @@ public function getResult(): ?ResultInterface; */ public function getIdentityAttribute(): string; + /** + * Build an identity object from raw identity data. + * + * If the supplied data is already an `IdentityInterface`, it is returned + * unchanged. + * + * @param \ArrayAccess|array $identityData Identity data. + * @return \Authentication\IdentityInterface + */ + public function buildIdentity(ArrayAccess|array $identityData): IdentityInterface; + /** * Return the URL to redirect unauthenticated users to. * diff --git a/src/Controller/Component/AuthenticationComponent.php b/src/Controller/Component/AuthenticationComponent.php index 5ce0d7d3..71aec025 100644 --- a/src/Controller/Component/AuthenticationComponent.php +++ b/src/Controller/Component/AuthenticationComponent.php @@ -322,6 +322,38 @@ public function setIdentity(ArrayAccess|array $identity) return $this; } + /** + * Replace the in-request identity object without persisting it. + * + * Use this when you only need to swap the identity attribute on the + * current request - for example, to attach eager-loaded associations + * or computed flags to the active user for the rest of the request - + * without going through `clearIdentity()` and `persistIdentity()`. + * + * Unlike `setIdentity()`, this does not touch the session and does not + * end an active impersonation, because no authenticator's + * `clearIdentity()` is invoked. + * + * @param \ArrayAccess|array $identity Identity data or an identity object. + * @return $this + */ + public function replaceIdentity(ArrayAccess|array $identity) + { + $controller = $this->getController(); + $service = $this->getAuthenticationService(); + + $identity = $service->buildIdentity($identity); + + $controller->setRequest( + $controller->getRequest()->withAttribute( + $service->getIdentityAttribute(), + $identity, + ), + ); + + return $this; + } + /** * Log a user out. * diff --git a/tests/TestCase/Controller/Component/AuthenticationComponentTest.php b/tests/TestCase/Controller/Component/AuthenticationComponentTest.php index 16b71a0a..2931a552 100644 --- a/tests/TestCase/Controller/Component/AuthenticationComponentTest.php +++ b/tests/TestCase/Controller/Component/AuthenticationComponentTest.php @@ -280,6 +280,96 @@ public function testSetIdentityOverwrite(): void ); } + /** + * Ensure replaceIdentity() swaps the request attribute without + * touching the session. + * + * @return void + */ + public function testReplaceIdentity(): void + { + $request = $this->request->withAttribute('authentication', $this->service); + + $controller = new Controller($request); + $registry = new ComponentRegistry($controller); + $component = new AuthenticationComponent($registry); + + $component->replaceIdentity($this->identityData); + + $result = $component->getIdentity(); + $this->assertInstanceOf(IdentityInterface::class, $result); + $this->assertSame($this->identityData, $result->getOriginalData()); + $this->assertNull( + $controller->getRequest()->getSession()->read('Auth'), + 'Session must not be written by replaceIdentity().', + ); + } + + /** + * Test that replaceIdentity() called with an identity instance keeps the + * exact instance as the request attribute. + * + * @return void + */ + public function testReplaceIdentityInstance(): void + { + $request = $this->request->withAttribute('authentication', $this->service); + + $controller = new Controller($request); + $registry = new ComponentRegistry($controller); + $component = new AuthenticationComponent($registry); + + $identity = new Identity($this->identityData); + $component->replaceIdentity($identity); + + $this->assertSame($identity, $component->getIdentity()); + } + + /** + * Ensure replaceIdentity() does not end an active impersonation, + * unlike setIdentity() which clears identity first. + * + * @return void + */ + public function testReplaceIdentityKeepsImpersonation(): void + { + $impersonator = new ArrayObject(['username' => 'mariano']); + $impersonated = new ArrayObject(['username' => 'larry']); + $this->request->getSession()->write('Auth', $impersonator); + $this->service->authenticate($this->request); + $identity = new Identity($impersonator); + $request = $this->request + ->withAttribute('identity', $identity) + ->withAttribute('authentication', $this->service); + $controller = new Controller($request); + $registry = new ComponentRegistry($controller); + $component = new AuthenticationComponent($registry); + + $component->impersonate($impersonated); + $this->assertEquals($impersonated, $controller->getRequest()->getSession()->read('Auth')); + $this->assertEquals($impersonator, $controller->getRequest()->getSession()->read('AuthImpersonate')); + + $reloaded = new ArrayObject(['username' => 'larry', 'profile' => 'loaded']); + $component->replaceIdentity($reloaded); + + $this->assertSame( + $reloaded, + $component->getIdentity()->getOriginalData(), + 'Request identity should reflect the reloaded user.', + ); + $this->assertEquals( + $impersonated, + $controller->getRequest()->getSession()->read('Auth'), + 'Session Auth slot must be untouched by replaceIdentity().', + ); + $this->assertEquals( + $impersonator, + $controller->getRequest()->getSession()->read('AuthImpersonate'), + 'Impersonation must survive replaceIdentity().', + ); + $this->assertTrue($component->isImpersonating()); + } + /** * testGetIdentity * From c6922eb9d25c4799f6bae22c8de685d9acdf9705 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 29 Apr 2026 13:14:16 +0200 Subject: [PATCH 2/3] 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. --- src/AuthenticationService.php | 22 +++++- src/AuthenticationServiceInterface.php | 15 +--- .../Component/AuthenticationComponent.php | 24 +++++- .../Component/AuthenticationComponentTest.php | 77 +++++++++++++++++++ 4 files changed, 120 insertions(+), 18 deletions(-) diff --git a/src/AuthenticationService.php b/src/AuthenticationService.php index 051a5b79..497e2ee7 100644 --- a/src/AuthenticationService.php +++ b/src/AuthenticationService.php @@ -184,14 +184,26 @@ public function authenticate(ServerRequestInterface $request): ResultInterface * * @param \Psr\Http\Message\ServerRequestInterface $request The request. * @param \Psr\Http\Message\ResponseInterface $response The response. + * @param bool $stopImpersonation Whether to stop an active impersonation + * before clearing each authenticator. Defaults to true (existing + * behavior). Pass false to keep the impersonation session intact - the + * authenticator's `clearIdentity()` is still called, but + * `stopImpersonating()` is not. * @return array Return an array containing the request and response objects. * @return array{request: \Psr\Http\Message\ServerRequestInterface, response: \Psr\Http\Message\ResponseInterface} */ - public function clearIdentity(ServerRequestInterface $request, ResponseInterface $response): array - { + public function clearIdentity( + ServerRequestInterface $request, + ResponseInterface $response, + bool $stopImpersonation = true, + ): array { foreach ($this->authenticators() as $authenticator) { if ($authenticator instanceof PersistenceInterface) { - if ($authenticator instanceof ImpersonationInterface && $authenticator->isImpersonating($request)) { + if ( + $stopImpersonation + && $authenticator instanceof ImpersonationInterface + && $authenticator->isImpersonating($request) + ) { $stopImpersonationResult = $authenticator->stopImpersonating($request, $response); ['request' => $request, 'response' => $response] = $stopImpersonationResult; } @@ -199,7 +211,9 @@ public function clearIdentity(ServerRequestInterface $request, ResponseInterface ['request' => $request, 'response' => $response] = $result; } } - $this->_successfulAuthenticator = null; + if ($stopImpersonation) { + $this->_successfulAuthenticator = null; + } return [ 'request' => $request->withoutAttribute($this->getConfig('identityAttribute')), diff --git a/src/AuthenticationServiceInterface.php b/src/AuthenticationServiceInterface.php index 0b8d537f..917ccb04 100644 --- a/src/AuthenticationServiceInterface.php +++ b/src/AuthenticationServiceInterface.php @@ -16,12 +16,14 @@ */ namespace Authentication; -use ArrayAccess; use Authentication\Authenticator\AuthenticatorInterface; use Authentication\Authenticator\PersistenceInterface; use Authentication\Authenticator\ResultInterface; use Psr\Http\Message\ServerRequestInterface; +/** + * @method \Authentication\IdentityInterface buildIdentity(\ArrayAccess|array $identityData) Build an identity object from raw identity data. + */ interface AuthenticationServiceInterface extends PersistenceInterface { /** @@ -70,17 +72,6 @@ public function getResult(): ?ResultInterface; */ public function getIdentityAttribute(): string; - /** - * Build an identity object from raw identity data. - * - * If the supplied data is already an `IdentityInterface`, it is returned - * unchanged. - * - * @param \ArrayAccess|array $identityData Identity data. - * @return \Authentication\IdentityInterface - */ - public function buildIdentity(ArrayAccess|array $identityData): IdentityInterface; - /** * Return the URL to redirect unauthenticated users to. * diff --git a/src/Controller/Component/AuthenticationComponent.php b/src/Controller/Component/AuthenticationComponent.php index 71aec025..cd2ede60 100644 --- a/src/Controller/Component/AuthenticationComponent.php +++ b/src/Controller/Component/AuthenticationComponent.php @@ -18,6 +18,7 @@ use ArrayAccess; use ArrayObject; +use Authentication\AuthenticationService; use Authentication\AuthenticationServiceInterface; use Authentication\Authenticator\AuthenticatorInterface; use Authentication\Authenticator\ImpersonationInterface; @@ -299,15 +300,34 @@ public function getIdentityData(string $path): mixed * is cleared and then set to ensure that privilege escalation * and de-escalation include side effects like session rotation. * + * Pass `$preserveImpersonation = true` to keep an active impersonation + * session alive while replacing the identity. The active authenticators' + * `clearIdentity()` is still called (so the new identity properly + * overwrites the existing one in storage), but the impersonation slot is + * left intact. Use this when refreshing the active impersonated user + * (for example, attaching eager-loaded associations) without ending the + * impersonation. + * * @param \ArrayAccess|array $identity Identity data to persist. + * @param bool $preserveImpersonation Whether to keep an active + * impersonation alive while replacing the identity. Defaults to false + * (existing behavior). * @return $this */ - public function setIdentity(ArrayAccess|array $identity) + public function setIdentity(ArrayAccess|array $identity, bool $preserveImpersonation = false) { $controller = $this->getController(); $service = $this->getAuthenticationService(); - $service->clearIdentity($controller->getRequest(), $controller->getResponse()); + if ($preserveImpersonation && $service instanceof AuthenticationService) { + $service->clearIdentity( + $controller->getRequest(), + $controller->getResponse(), + stopImpersonation: false, + ); + } else { + $service->clearIdentity($controller->getRequest(), $controller->getResponse()); + } /** @var array{request: \Cake\Http\ServerRequest, response: \Cake\Http\Response} $result */ $result = $service->persistIdentity( diff --git a/tests/TestCase/Controller/Component/AuthenticationComponentTest.php b/tests/TestCase/Controller/Component/AuthenticationComponentTest.php index 2931a552..dfdccacd 100644 --- a/tests/TestCase/Controller/Component/AuthenticationComponentTest.php +++ b/tests/TestCase/Controller/Component/AuthenticationComponentTest.php @@ -370,6 +370,83 @@ public function testReplaceIdentityKeepsImpersonation(): void $this->assertTrue($component->isImpersonating()); } + /** + * Ensure setIdentity($identity, preserveImpersonation: true) persists the + * new identity into the session but does not end an active impersonation, + * unlike the default flow. + * + * @return void + */ + public function testSetIdentityPreserveImpersonation(): void + { + $impersonator = new ArrayObject(['username' => 'mariano']); + $impersonated = new ArrayObject(['username' => 'larry']); + $this->request->getSession()->write('Auth', $impersonator); + $this->service->authenticate($this->request); + $identity = new Identity($impersonator); + $request = $this->request + ->withAttribute('identity', $identity) + ->withAttribute('authentication', $this->service); + $controller = new Controller($request); + $registry = new ComponentRegistry($controller); + $component = new AuthenticationComponent($registry); + + $component->impersonate($impersonated); + $this->assertEquals($impersonated, $controller->getRequest()->getSession()->read('Auth')); + $this->assertEquals($impersonator, $controller->getRequest()->getSession()->read('AuthImpersonate')); + + $reloaded = new ArrayObject(['username' => 'larry', 'profile' => 'loaded']); + $component->setIdentity($reloaded, preserveImpersonation: true); + + $this->assertSame( + $reloaded, + $component->getIdentity()->getOriginalData(), + 'Request identity should reflect the reloaded user.', + ); + $this->assertEquals( + $reloaded, + $controller->getRequest()->getSession()->read('Auth'), + 'Session Auth slot must be persisted with the reloaded user.', + ); + $this->assertEquals( + $impersonator, + $controller->getRequest()->getSession()->read('AuthImpersonate'), + 'Impersonation must survive setIdentity() when preserveImpersonation is set.', + ); + $this->assertTrue($component->isImpersonating()); + } + + /** + * Ensure that `setIdentity()` with the default behavior still ends an + * active impersonation - we do not want to silently change BC. + * + * @return void + */ + public function testSetIdentityDefaultEndsImpersonation(): void + { + $impersonator = new ArrayObject(['username' => 'mariano']); + $impersonated = new ArrayObject(['username' => 'larry']); + $this->request->getSession()->write('Auth', $impersonator); + $this->service->authenticate($this->request); + $identity = new Identity($impersonator); + $request = $this->request + ->withAttribute('identity', $identity) + ->withAttribute('authentication', $this->service); + $controller = new Controller($request); + $registry = new ComponentRegistry($controller); + $component = new AuthenticationComponent($registry); + + $component->impersonate($impersonated); + + $reloaded = new ArrayObject(['username' => 'larry', 'profile' => 'loaded']); + $component->setIdentity($reloaded); + + $this->assertNull( + $controller->getRequest()->getSession()->read('AuthImpersonate'), + 'Default setIdentity() must end an active impersonation.', + ); + } + /** * testGetIdentity * From a733306bc90db85291f7d22557de4f6194b3d56b Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 29 Apr 2026 13:40:30 +0200 Subject: [PATCH 3/3] 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. --- docs/en/authentication-component.md | 54 +++++++++++++++++++++++++++++ docs/en/impersonation.md | 20 +++++++++++ 2 files changed, 74 insertions(+) diff --git a/docs/en/authentication-component.md b/docs/en/authentication-component.md index 909026ab..b298eef0 100644 --- a/docs/en/authentication-component.md +++ b/docs/en/authentication-component.md @@ -102,6 +102,60 @@ The result returned will contain an array like this: > context you're working in you'll have to use these instances from now on if you > want to continue to work with the modified response and request objects. +## Replacing the current identity + +Use `setIdentity()` to change which user is logged in (e.g. after registration +or social-login first-touch). It clears all persisted identity data and writes +the new identity through every persisting authenticator: + +```php +$this->Authentication->setIdentity($user); +``` + +> [!WARNING] +> `setIdentity()` ends an active impersonation session by default, because it +> goes through `clearIdentity()` first, which calls `stopImpersonating()` on +> impersonation-aware authenticators. See the two methods below for the +> non-default cases. + +### Refresh the active identity for the current request only + +When you only need to swap the in-request identity (for example to attach +eager-loaded associations or computed flags in `beforeFilter()`) without +touching the session or persistence, use `replaceIdentity()`: + +```php +// AppController::beforeFilter() +$identity = $this->Authentication->getIdentity(); +if ($identity && !$identity->some_association) { + $reloaded = $this->fetchTable('Users') + ->get($identity->getIdentifier(), finder: 'fullProfile'); + $this->Authentication->replaceIdentity($reloaded); +} +``` + +This rewrites only the request attribute. The session is not touched, so an +active impersonation is preserved and no privilege-escalation side effects +(like session rotation) occur. + +### Persist a refreshed identity while impersonating + +If the refresh has to survive into subsequent requests but you still want to +keep an active impersonation alive, pass `preserveImpersonation: true` to +`setIdentity()`: + +```php +$this->Authentication->setIdentity($reloaded, preserveImpersonation: true); +``` + +The new identity is persisted into the session as usual, but the +impersonation slot (`AuthImpersonate`) and the active authenticator are left +intact. Note that this also skips the session rotation that the default +`setIdentity()` flow performs - it is a refresh, not a privilege transition, +so do not use it for login or role changes. + +See [User Impersonation](impersonation.md) for the broader context. + ## Configure Automatic Identity Checks By default `AuthenticationComponent` will automatically enforce an identity to diff --git a/docs/en/impersonation.md b/docs/en/impersonation.md index 2ed83075..78fcbbb6 100644 --- a/docs/en/impersonation.md +++ b/docs/en/impersonation.md @@ -67,3 +67,23 @@ There are a few limitations to impersonation. 1. Your application must be using the `Session` authenticator. 2. You cannot impersonate another user while impersonation is active. Instead you must `stopImpersonating()` and then start it again. +3. Calling `setIdentity()` or `clearIdentity()` (and therefore `logout()`) + ends impersonation by default. The service's `clearIdentity()` actively + calls `stopImpersonating()` on impersonation-aware authenticators, so any + code path that swaps the persisted identity will revert to the original + user. + + To refresh the active identity without disturbing impersonation, use one + of the dedicated methods on `AuthenticationComponent`: + + - `replaceIdentity($identity)` updates the in-request identity attribute + only. The session is not touched. Use this for the common + `beforeFilter()` case of attaching eager-loaded associations to the + active user for the rest of the request. + - `setIdentity($identity, preserveImpersonation: true)` persists the new + identity into the session like the default flow, but keeps the + impersonation slot intact. Use this when the refresh has to survive + into subsequent requests. + + See [Replacing the current identity](authentication-component.md#replacing-the-current-identity) + for examples.