Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions docs/en/authentication-component.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions docs/en/impersonation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
22 changes: 18 additions & 4 deletions src/AuthenticationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -184,22 +184,36 @@ 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;
}
$result = $authenticator->clearIdentity($request, $response);
['request' => $request, 'response' => $response] = $result;
}
}
$this->_successfulAuthenticator = null;
if ($stopImpersonation) {
$this->_successfulAuthenticator = null;
}

return [
'request' => $request->withoutAttribute($this->getConfig('identityAttribute')),
Expand Down
3 changes: 3 additions & 0 deletions src/AuthenticationServiceInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
use Authentication\Authenticator\ResultInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
* @method \Authentication\IdentityInterface buildIdentity(\ArrayAccess<string, mixed>|array<string, mixed> $identityData) Build an identity object from raw identity data.
*/
interface AuthenticationServiceInterface extends PersistenceInterface
{
/**
Expand Down
56 changes: 54 additions & 2 deletions src/Controller/Component/AuthenticationComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

use ArrayAccess;
use ArrayObject;
use Authentication\AuthenticationService;
use Authentication\AuthenticationServiceInterface;
use Authentication\Authenticator\AuthenticatorInterface;
use Authentication\Authenticator\ImpersonationInterface;
Expand Down Expand Up @@ -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(
Expand All @@ -322,6 +342,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.
*
Expand Down
167 changes: 167 additions & 0 deletions tests/TestCase/Controller/Component/AuthenticationComponentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,173 @@ 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 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
*
Expand Down
Loading