Skip to content
Merged
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
38 changes: 38 additions & 0 deletions docs/en/authentication-component.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/en/impersonation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 4 additions & 0 deletions src/AuthenticationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
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
32 changes: 32 additions & 0 deletions src/Controller/Component/AuthenticationComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
122 changes: 122 additions & 0 deletions tests/TestCase/Controller/Component/AuthenticationComponentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
);
Comment thread
dereuromark marked this conversation as resolved.
$this->assertFalse($component->isImpersonating());
}

/**
* testGetIdentity
*
Expand Down
Loading