diff --git a/docs/en/authentication-component.md b/docs/en/authentication-component.md index 909026ab..9c8ed2ff 100644 --- a/docs/en/authentication-component.md +++ b/docs/en/authentication-component.md @@ -102,6 +102,44 @@ 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. Use `replaceIdentity()` below for the +> request-only refresh case. + +### 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 modified, so an +active impersonation is preserved and no privilege-escalation side effects +(like session rotation) occur. + +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..9fc6f8d6 100644 --- a/docs/en/impersonation.md +++ b/docs/en/impersonation.md @@ -67,3 +67,17 @@ 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 + `replaceIdentity($identity)` on `AuthenticationComponent`. It 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. + + See [Replacing the current identity](authentication-component.md#replacing-the-current-identity) + for examples. diff --git a/src/AuthenticationService.php b/src/AuthenticationService.php index 051a5b79..26e74cbc 100644 --- a/src/AuthenticationService.php +++ b/src/AuthenticationService.php @@ -501,6 +501,10 @@ public function stopImpersonating(ServerRequestInterface $request, ResponseInter */ public function isImpersonating(ServerRequestInterface $request): bool { + if (!$this->getAuthenticationProvider() instanceof AuthenticatorInterface) { + return false; + } + $provider = $this->getImpersonationProvider(); return $provider->isImpersonating($request); diff --git a/src/AuthenticationServiceInterface.php b/src/AuthenticationServiceInterface.php index 570253ae..917ccb04 100644 --- a/src/AuthenticationServiceInterface.php +++ b/src/AuthenticationServiceInterface.php @@ -21,6 +21,9 @@ 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 { /** 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..9e42a170 100644 --- a/tests/TestCase/Controller/Component/AuthenticationComponentTest.php +++ b/tests/TestCase/Controller/Component/AuthenticationComponentTest.php @@ -280,6 +280,128 @@ 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()); + } + + /** + * 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.', + ); + $this->assertFalse($component->isImpersonating()); + } + /** * testGetIdentity *