From 39f7d9c13e67bac49d0fe4b14e157306f50f2fbf Mon Sep 17 00:00:00 2001 From: mscherer Date: Fri, 18 Apr 2025 20:12:48 +0200 Subject: [PATCH 1/3] Add PrimaryKeySessionAuthenticator --- docs/en/authenticators.rst | 19 + .../PrimaryKeySessionAuthenticator.php | 95 ++++ .../PrimaryKeySessionAuthenticatorTest.php | 441 ++++++++++++++++++ 3 files changed, 555 insertions(+) create mode 100644 src/Authenticator/PrimaryKeySessionAuthenticator.php create mode 100644 tests/TestCase/Authenticator/PrimaryKeySessionAuthenticatorTest.php diff --git a/docs/en/authenticators.rst b/docs/en/authenticators.rst index 36b6bca0..cca9096f 100644 --- a/docs/en/authenticators.rst +++ b/docs/en/authenticators.rst @@ -26,6 +26,25 @@ Configuration options: identifier in your user storage. Defaults to ``username``. This option is used when the ``identify`` option is set to true. +PrimaryKeySession +================= + +This is an improved version of Session that will only store the primary key. +This way the data will always be fetched fresh from the DB and issues like +having to update the identity when updating account data should be gone. + +It also helps to avoid session invalidation. +Session itself stores the entity object including nested objects like DateTime or enums. +With only the ID stored, the invalidation due to objects being modified will also dissolve. + +Make sure to match this with a Token identifier with ``id`` keys: + + $service->loadIdentifier('Authentication.Token', [ + 'tokenField' => 'id', + 'dataField' => 'id', + 'resolver' => 'Authentication.Orm', + ]); + Form ==== diff --git a/src/Authenticator/PrimaryKeySessionAuthenticator.php b/src/Authenticator/PrimaryKeySessionAuthenticator.php new file mode 100644 index 00000000..2b98fee2 --- /dev/null +++ b/src/Authenticator/PrimaryKeySessionAuthenticator.php @@ -0,0 +1,95 @@ +getConfig('sessionKey'); + /** @var \Cake\Http\Session $session */ + $session = $request->getAttribute('session'); + + $userId = $session->read($sessionKey); + if (!$userId) { + return new Result(null, Result::FAILURE_IDENTITY_NOT_FOUND); + } + + $user = $this->_identifier->identify(['id' => $userId]); + if (!$user) { + return new Result(null, Result::FAILURE_IDENTITY_NOT_FOUND); + } + + return new Result($user, Result::SUCCESS); + } + + /** + * @inheritDoc + */ + public function persistIdentity(ServerRequestInterface $request, ResponseInterface $response, $identity): array + { + $sessionKey = $this->getConfig('sessionKey'); + /** @var \Cake\Http\Session $session */ + $session = $request->getAttribute('session'); + + if (!$session->check($sessionKey)) { + $session->renew(); + $session->write($sessionKey, $identity['id']); + } + + return [ + 'request' => $request, + 'response' => $response, + ]; + } + + /** + * Impersonates a user + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request + * @param \Psr\Http\Message\ResponseInterface $response The response + * @param \ArrayAccess $impersonator User who impersonates + * @param \ArrayAccess $impersonated User impersonated + * @return array + */ + public function impersonate( + ServerRequestInterface $request, + ResponseInterface $response, + ArrayAccess $impersonator, + ArrayAccess $impersonated, + ): array { + $sessionKey = $this->getConfig('sessionKey'); + $impersonateSessionKey = $this->getConfig('impersonateSessionKey'); + /** @var \Cake\Http\Session $session */ + $session = $request->getAttribute('session'); + if ($session->check($impersonateSessionKey)) { + throw new UnauthorizedException( + 'You are impersonating a user already. ' . + 'Stop the current impersonation before impersonating another user.', + ); + } + $session->write($impersonateSessionKey, $impersonator['id']); + $session->write($sessionKey, $impersonated['id']); + $this->setConfig('identify', true); + + return [ + 'request' => $request, + 'response' => $response, + ]; + } +} diff --git a/tests/TestCase/Authenticator/PrimaryKeySessionAuthenticatorTest.php b/tests/TestCase/Authenticator/PrimaryKeySessionAuthenticatorTest.php new file mode 100644 index 00000000..bcc82cf6 --- /dev/null +++ b/tests/TestCase/Authenticator/PrimaryKeySessionAuthenticatorTest.php @@ -0,0 +1,441 @@ + + */ + protected array $fixtures = [ + 'core.AuthUsers', + ]; + + /** + * @var \Authentication\Identifier\IdentifierCollection + */ + protected $identifiers; + + /** + * @var \Cake\Http\Session&\PHPUnit\Framework\MockObject\MockObject + */ + protected $sessionMock; + + /** + * @inheritDoc + */ + public function setUp(): void + { + parent::setUp(); + + $this->identifiers = new IdentifierCollection([ + 'Authentication.Password' => [ + ], + ]); + + $this->sessionMock = $this->getMockBuilder(Session::class) + ->disableOriginalConstructor() + ->onlyMethods(['read', 'write', 'delete', 'renew', 'check']) + ->getMock(); + } + + /** + * Test authentication + * + * @return void + */ + public function testAuthenticateSuccess() + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/']); + + $this->sessionMock->expects($this->once()) + ->method('read') + ->with('Auth') + ->willReturn(1); + + $request = $request->withAttribute('session', $this->sessionMock); + + $this->identifiers = new IdentifierCollection([ + 'Authentication.Token' => [ + 'tokenField' => 'id', + 'dataField' => 'id', + 'resolver' => [ + 'className' => 'Authentication.Orm', + 'userModel' => 'AuthUsers', + ], + ], + ]); + + $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers); + $result = $authenticator->authenticate($request); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::SUCCESS, $result->getStatus()); + } + + /** + * Test authentication + * + * @return void + */ + public function testAuthenticateSuccessCustomFinder() + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/']); + + $usersTable = $this->fetchTable('AuthUsers'); + $user = $usersTable->find()->firstOrFail(); + + $this->sessionMock->expects($this->once()) + ->method('read') + ->with('Auth') + ->willReturn($user->id); + + $request = $request->withAttribute('session', $this->sessionMock); + + $this->identifiers = new IdentifierCollection([ + 'Authentication.Token' => [ + 'tokenField' => 'id', + 'dataField' => 'id', + 'resolver' => [ + 'className' => 'Authentication.Orm', + 'userModel' => 'AuthUsers', + 'finder' => 'auth', + ], + ], + ]); + + $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers, [ + ]); + $result = $authenticator->authenticate($request); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::SUCCESS, $result->getStatus()); + + $entity = $result->getData(); + $this->assertNotEmpty($entity->username); + } + + /** + * Test authentication + * + * @return void + */ + public function testAuthenticateFailure() + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/']); + + $this->sessionMock->expects($this->once()) + ->method('read') + ->with('Auth') + ->willReturn(null); + + $request = $request->withAttribute('session', $this->sessionMock); + + $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers); + $result = $authenticator->authenticate($request); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::FAILURE_IDENTITY_NOT_FOUND, $result->getStatus()); + } + + /** + * Test session data verification by database lookup failure + * + * @return void + */ + public function testVerifyByDatabaseFailure() + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/']); + + $this->sessionMock->expects($this->once()) + ->method('read') + ->with('Auth') + ->willReturn(999); + + $request = $request->withAttribute('session', $this->sessionMock); + + $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers, [ + ]); + $result = $authenticator->authenticate($request); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::FAILURE_IDENTITY_NOT_FOUND, $result->getStatus()); + } + + /** + * testPersistIdentity + * + * @return void + */ + public function testPersistIdentity() + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/']); + $request = $request->withAttribute('session', $this->sessionMock); + $response = new Response(); + $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers); + + $data = new ArrayObject(['id' => 1]); + + $this->sessionMock + ->expects($this->exactly(2)) + ->method('check') + ->with( + ...static::withConsecutive(['Auth'], ['Auth']), + ) + ->willReturnOnConsecutiveCalls(false, true); + + $this->sessionMock + ->expects($this->once()) + ->method('renew'); + + $this->sessionMock + ->expects($this->once()) + ->method('write') + ->with('Auth', 1); + + $result = $authenticator->persistIdentity($request, $response, $data); + $this->assertIsArray($result); + $this->assertArrayHasKey('request', $result); + $this->assertArrayHasKey('response', $result); + $this->assertInstanceOf(RequestInterface::class, $result['request']); + $this->assertInstanceOf(ResponseInterface::class, $result['response']); + + // Persist again to make sure identity isn't replaced if it exists. + $authenticator->persistIdentity($request, $response, 2); + } + + /** + * testClearIdentity + * + * @return void + */ + public function testClearIdentity() + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/']); + $request = $request->withAttribute('session', $this->sessionMock); + $response = new Response(); + + $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers); + + $this->sessionMock->expects($this->once()) + ->method('delete') + ->with('Auth'); + + $this->sessionMock + ->expects($this->once()) + ->method('renew'); + + $result = $authenticator->clearIdentity($request, $response); + $this->assertIsArray($result); + $this->assertArrayHasKey('request', $result); + $this->assertArrayHasKey('response', $result); + $this->assertInstanceOf(RequestInterface::class, $result['request']); + $this->assertInstanceOf(ResponseInterface::class, $result['response']); + } + + /** + * testImpersonate + * + * @return void + */ + public function testImpersonate() + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/']); + $request = $request->withAttribute('session', $this->sessionMock); + $response = new Response(); + + $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers); + $usersTable = $this->fetchTable('Users'); + $impersonator = $usersTable->newEntity([ + 'username' => 'mariano', + 'password' => 'password', + ]); + $impersonator->id = 123; + $impersonated = $usersTable->newEntity(['username' => 'larry']); + $impersonated->id = 456; + + $this->sessionMock->expects($this->once()) + ->method('check') + ->with('AuthImpersonate'); + + $this->sessionMock + ->expects($this->exactly(2)) + ->method('write') + ->with( + ...static::withConsecutive(['AuthImpersonate', $impersonator->id], ['Auth', $impersonated->id]), + ); + + $result = $authenticator->impersonate($request, $response, $impersonator, $impersonated); + + $this->assertIsArray($result); + $this->assertArrayHasKey('request', $result); + $this->assertArrayHasKey('response', $result); + $this->assertInstanceOf(RequestInterface::class, $result['request']); + $this->assertInstanceOf(ResponseInterface::class, $result['response']); + } + + /** + * testImpersonateAlreadyImpersonating + * + * @return void + */ + public function testImpersonateAlreadyImpersonating() + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/']); + $request = $request->withAttribute('session', $this->sessionMock); + $response = new Response(); + + $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers); + $impersonator = new ArrayObject([ + 'username' => 'mariano', + 'password' => 'password', + ]); + $impersonated = new ArrayObject(['username' => 'larry']); + + $this->sessionMock->expects($this->once()) + ->method('check') + ->with('AuthImpersonate') + ->willReturn(true); + + $this->sessionMock + ->expects($this->never()) + ->method('write'); + + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage( + 'You are impersonating a user already. Stop the current impersonation before impersonating another user.', + ); + $authenticator->impersonate($request, $response, $impersonator, $impersonated); + } + + /** + * testStopImpersonating + * + * @return void + */ + public function testStopImpersonating() + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/']); + $request = $request->withAttribute('session', $this->sessionMock); + $response = new Response(); + + $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers); + + $impersonator = new ArrayObject([ + 'username' => 'mariano', + 'password' => 'password', + ]); + + $this->sessionMock->expects($this->once()) + ->method('check') + ->with('AuthImpersonate') + ->willReturn(true); + + $this->sessionMock + ->expects($this->once()) + ->method('read') + ->with('AuthImpersonate') + ->willReturn($impersonator); + + $this->sessionMock + ->expects($this->once()) + ->method('delete') + ->with('AuthImpersonate'); + + $this->sessionMock + ->expects($this->once()) + ->method('write') + ->with('Auth', $impersonator); + + $result = $authenticator->stopImpersonating($request, $response); + $this->assertIsArray($result); + $this->assertArrayHasKey('request', $result); + $this->assertArrayHasKey('response', $result); + $this->assertInstanceOf(RequestInterface::class, $result['request']); + $this->assertInstanceOf(ResponseInterface::class, $result['response']); + } + + /** + * testStopImpersonatingNotImpersonating + * + * @return void + */ + public function testStopImpersonatingNotImpersonating() + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/']); + $request = $request->withAttribute('session', $this->sessionMock); + $response = new Response(); + + $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers); + + $this->sessionMock->expects($this->once()) + ->method('check') + ->with('AuthImpersonate') + ->willReturn(false); + + $this->sessionMock + ->expects($this->never()) + ->method('read'); + + $this->sessionMock + ->expects($this->never()) + ->method('delete'); + + $this->sessionMock + ->expects($this->never()) + ->method('write'); + + $result = $authenticator->stopImpersonating($request, $response); + $this->assertIsArray($result); + $this->assertArrayHasKey('request', $result); + $this->assertArrayHasKey('response', $result); + $this->assertInstanceOf(RequestInterface::class, $result['request']); + $this->assertInstanceOf(ResponseInterface::class, $result['response']); + } + + /** + * testIsImpersonating + * + * @return void + */ + public function testIsImpersonating() + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/']); + $request = $request->withAttribute('session', $this->sessionMock); + + $authenticator = new PrimaryKeySessionAuthenticator($this->identifiers); + + $this->sessionMock->expects($this->once()) + ->method('check') + ->with('AuthImpersonate'); + + $result = $authenticator->isImpersonating($request); + $this->assertFalse($result); + } +} From 94fd6174ae2e9ea7b4e24654723c7f5cf3ec8699 Mon Sep 17 00:00:00 2001 From: mscherer Date: Fri, 18 Apr 2025 21:44:08 +0200 Subject: [PATCH 2/3] Docs. --- docs/en/authenticators.rst | 16 ++++++++++--- docs/en/url-checkers.rst | 19 ++++++++++++--- .../PrimaryKeySessionAuthenticator.php | 23 +++++++++++++++---- .../PrimaryKeySessionAuthenticatorTest.php | 4 ++-- 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/docs/en/authenticators.rst b/docs/en/authenticators.rst index cca9096f..6e19d374 100644 --- a/docs/en/authenticators.rst +++ b/docs/en/authenticators.rst @@ -37,14 +37,24 @@ It also helps to avoid session invalidation. Session itself stores the entity object including nested objects like DateTime or enums. With only the ID stored, the invalidation due to objects being modified will also dissolve. -Make sure to match this with a Token identifier with ``id`` keys: +Make sure to match this with a Token identifier with ``key``/``id`` keys:: $service->loadIdentifier('Authentication.Token', [ - 'tokenField' => 'id', - 'dataField' => 'id', + 'tokenField' => 'id', // lookup for DB table + 'dataField' => 'key', // incoming data 'resolver' => 'Authentication.Orm', ]); + $service->loadAuthenticator('Authentication.PrimaryKeySession', [ + 'urlChecker' => 'Authentication.CakeRouter', + 'loginUrl' => [ + 'prefix' => false, + 'plugin' => false, + 'controller' => 'Users', + 'action' => 'login', + ], + ]); + Form ==== diff --git a/docs/en/url-checkers.rst b/docs/en/url-checkers.rst index 77a677bd..f27a8532 100644 --- a/docs/en/url-checkers.rst +++ b/docs/en/url-checkers.rst @@ -2,7 +2,7 @@ URL Checkers ############ To provide an abstract and framework agnostic solution there are URL -checkers implemented that allow you to customize the comparision of the +checkers implemented that allow you to customize the comparison of the current URL if needed. For example to another frameworks routing. Included Checkers @@ -24,11 +24,24 @@ Options: CakeRouterUrlChecker -------------------- -Options: - Use this checker if you want to use the array notation of CakePHPs routing system. The checker also works with named routes. + $service->loadAuthenticator('Authentication.Form', [ + 'urlChecker' => 'Authentication.CakeRouter', + 'fields' => [ + AbstractIdentifier::CREDENTIAL_USERNAME => 'email', + AbstractIdentifier::CREDENTIAL_PASSWORD => 'password', + ], + 'loginUrl' => [ + 'prefix' => false, + 'plugin' => false, + 'controller' => 'Users', + 'action' => 'login', + ], + ]); + +Options: - **checkFullUrl**: To compare the full URL, including protocol, host and port or not. Default is ``false`` diff --git a/src/Authenticator/PrimaryKeySessionAuthenticator.php b/src/Authenticator/PrimaryKeySessionAuthenticator.php index 2b98fee2..0de92fbd 100644 --- a/src/Authenticator/PrimaryKeySessionAuthenticator.php +++ b/src/Authenticator/PrimaryKeySessionAuthenticator.php @@ -4,6 +4,7 @@ namespace Authentication\Authenticator; use ArrayAccess; +use Authentication\Identifier\IdentifierInterface; use Cake\Http\Exception\UnauthorizedException; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -13,6 +14,20 @@ */ class PrimaryKeySessionAuthenticator extends SessionAuthenticator { + /** + * @param \Authentication\Identifier\IdentifierInterface $identifier + * @param array $config + */ + public function __construct(IdentifierInterface $identifier, array $config = []) + { + $config += [ + 'identifierKey' => 'key', + 'idField' => 'id', + ]; + + parent::__construct($identifier, $config); + } + /** * Authenticate a user using session data. * @@ -30,7 +45,7 @@ public function authenticate(ServerRequestInterface $request): ResultInterface return new Result(null, Result::FAILURE_IDENTITY_NOT_FOUND); } - $user = $this->_identifier->identify(['id' => $userId]); + $user = $this->_identifier->identify([$this->getConfig('identifierKey') => $userId]); if (!$user) { return new Result(null, Result::FAILURE_IDENTITY_NOT_FOUND); } @@ -49,7 +64,7 @@ public function persistIdentity(ServerRequestInterface $request, ResponseInterfa if (!$session->check($sessionKey)) { $session->renew(); - $session->write($sessionKey, $identity['id']); + $session->write($sessionKey, $identity[$this->getConfig('idField')]); } return [ @@ -83,8 +98,8 @@ public function impersonate( 'Stop the current impersonation before impersonating another user.', ); } - $session->write($impersonateSessionKey, $impersonator['id']); - $session->write($sessionKey, $impersonated['id']); + $session->write($impersonateSessionKey, $impersonator[$this->getConfig('idField')]); + $session->write($sessionKey, $impersonated[$this->getConfig('idField')]); $this->setConfig('identify', true); return [ diff --git a/tests/TestCase/Authenticator/PrimaryKeySessionAuthenticatorTest.php b/tests/TestCase/Authenticator/PrimaryKeySessionAuthenticatorTest.php index bcc82cf6..1330900d 100644 --- a/tests/TestCase/Authenticator/PrimaryKeySessionAuthenticatorTest.php +++ b/tests/TestCase/Authenticator/PrimaryKeySessionAuthenticatorTest.php @@ -84,7 +84,7 @@ public function testAuthenticateSuccess() $this->identifiers = new IdentifierCollection([ 'Authentication.Token' => [ 'tokenField' => 'id', - 'dataField' => 'id', + 'dataField' => 'key', 'resolver' => [ 'className' => 'Authentication.Orm', 'userModel' => 'AuthUsers', @@ -121,7 +121,7 @@ public function testAuthenticateSuccessCustomFinder() $this->identifiers = new IdentifierCollection([ 'Authentication.Token' => [ 'tokenField' => 'id', - 'dataField' => 'id', + 'dataField' => 'key', 'resolver' => [ 'className' => 'Authentication.Orm', 'userModel' => 'AuthUsers', From fba18a20632934219c88523bc4352ad9f0098484 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 30 Apr 2025 14:04:55 +0200 Subject: [PATCH 3/3] Docs. --- docs/en/authenticators.rst | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/en/authenticators.rst b/docs/en/authenticators.rst index 6e19d374..127ff0ed 100644 --- a/docs/en/authenticators.rst +++ b/docs/en/authenticators.rst @@ -39,13 +39,14 @@ With only the ID stored, the invalidation due to objects being modified will als Make sure to match this with a Token identifier with ``key``/``id`` keys:: - $service->loadIdentifier('Authentication.Token', [ - 'tokenField' => 'id', // lookup for DB table - 'dataField' => 'key', // incoming data - 'resolver' => 'Authentication.Orm', - ]); - $service->loadAuthenticator('Authentication.PrimaryKeySession', [ + 'identifier' => [ + 'Authentication.Token' => [ + 'tokenField' => 'id', // lookup for resolver and DB table + 'dataField' => 'key', // incoming data from authenticator + 'resolver' => 'Authentication.Orm', + ], + ], 'urlChecker' => 'Authentication.CakeRouter', 'loginUrl' => [ 'prefix' => false,