From 5cdc7400558ca78a4803484737dbeaf572852a6d Mon Sep 17 00:00:00 2001 From: chris cnizzardini Date: Tue, 7 Jun 2022 17:42:05 -0400 Subject: [PATCH 1/4] Adds PASETO authenticator, identifier, and console command. --- composer.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index fb2938e6..1984f08e 100644 --- a/composer.json +++ b/composer.json @@ -20,13 +20,16 @@ "require-dev": { "cakephp/cakephp": "^4.0", "cakephp/cakephp-codesniffer": "^4.0", - "firebase/php-jwt": "^5.5", + "firebase/php-jwt": "^6.2", + "paragonie/paseto": "^2.4", + "paragonie/constant_time_encoding": "^2.2", "phpunit/phpunit": "^8.5 || ^9.3" }, "suggest": { "cakephp/orm": "To use \"OrmResolver\" (Not needed separately if using full CakePHP framework).", "cakephp/cakephp": "Install full core to use \"CookieAuthenticator\".", "firebase/php-jwt": "If you want to use the JWT adapter add this dependency", + "paragonie/paseto": "If you want to use PASETO add this dependency", "ext-ldap": "Make sure this php extension is installed and enabled on your system if you want to use the built-in LDAP adapter for \"LdapIdentifier\".", "cakephp/utility": "Provides CakePHP security methods. Required for the JWT adapter and Legacy password hasher." }, From 3fcae0d7a35232938706f0bb12274a3b23ad2ce6 Mon Sep 17 00:00:00 2001 From: chris cnizzardini Date: Wed, 8 Jun 2022 08:57:58 -0400 Subject: [PATCH 2/4] fixes bad rebase --- src/Authenticator/PasetoAuthenticator.php | 219 +++++++ src/Command/PasetoCommand.php | 96 +++ src/Identifier/PasetoSubjectIdentifier.php | 37 ++ src/Plugin.php | 14 +- .../Authenticator/PasetoAuthenticatorTest.php | 553 ++++++++++++++++++ tests/TestCase/Command/PasetoCommandTest.php | 58 ++ tests/test_app/config/bootstrap.php | 1 + 7 files changed, 977 insertions(+), 1 deletion(-) create mode 100644 src/Authenticator/PasetoAuthenticator.php create mode 100644 src/Command/PasetoCommand.php create mode 100644 src/Identifier/PasetoSubjectIdentifier.php create mode 100644 tests/TestCase/Authenticator/PasetoAuthenticatorTest.php create mode 100644 tests/TestCase/Command/PasetoCommandTest.php create mode 100644 tests/test_app/config/bootstrap.php diff --git a/src/Authenticator/PasetoAuthenticator.php b/src/Authenticator/PasetoAuthenticator.php new file mode 100644 index 00000000..509e6253 --- /dev/null +++ b/src/Authenticator/PasetoAuthenticator.php @@ -0,0 +1,219 @@ + 'Authorization', + 'queryParam' => 'token', + 'tokenPrefix' => 'bearer', + 'returnPayload' => true, + 'version' => null, + 'purpose' => null, + 'secretKey' => null, + ]; + + /** + * Payload data. + * + * @var object|null + */ + protected $payload; + + /** + * @var \ParagonIE\Paseto\ProtocolInterface|null + */ + private $version; + + /** + * @inheritDoc + */ + public function __construct(IdentifierInterface $identifier, array $config = []) + { + parent::__construct($identifier, $config); + + $this->version = $this->whichVersion(); + + if ($this->version === null) { + throw new RuntimeException('PASETO `version` must be one of: v3 or v4'); + } + + if (!in_array($this->getConfig('purpose'), [self::PUBLIC, self::LOCAL])) { + throw new RuntimeException('PASETO `purpose` config must one of: local or public'); + } + + if (empty($this->getConfig('secretKey'))) { + if (!class_exists(\Cake\Utility\Security::class)) { + throw new RuntimeException('PASETO `secretKey` config must be defined'); + } + $this->setConfig('secretKey', \Cake\Utility\Security::getSalt()); + } + } + + /** + * Authenticates the identity based on a PASETO token contained in a request. + * + * @link https://paseto.io/ + * @param \Psr\Http\Message\ServerRequestInterface $request The request that contains login information. + * @return \Authentication\Authenticator\ResultInterface + * @throws \Exception + */ + public function authenticate(ServerRequestInterface $request): ResultInterface + { + try { + $result = $this->getPayload($request); + } catch (Exception $e) { + return new Result( + null, + Result::FAILURE_CREDENTIALS_INVALID, + [ + 'message' => $e->getMessage(), + 'exception' => $e, + ] + ); + } + + if (!$result instanceof JsonToken) { + return new Result(null, Result::FAILURE_CREDENTIALS_INVALID); + } + + if (empty($result->getSubject())) { + return new Result(null, Result::FAILURE_CREDENTIALS_MISSING); + } + + if ($this->getConfig('returnPayload')) { + $array = array_merge( + $result->getClaims(), + ['footer' => $result->getFooterArray()] + ); + + return new Result(new ArrayObject($array), Result::SUCCESS); + } + + $user = $this->_identifier->identify([ + 'sub' => $result->getSubject(), + ]); + + if (empty($user)) { + return new Result(null, Result::FAILURE_IDENTITY_NOT_FOUND, $this->_identifier->getErrors()); + } + + return new Result($user, Result::SUCCESS); + } + + /** + * Get payload data. + * + * @param \Psr\Http\Message\ServerRequestInterface|null $request Request to get authentication information from. + * @return object|null Payload object on success, null on failure + * @throws \Exception + */ + public function getPayload(?ServerRequestInterface $request = null): ?object + { + if (!$request) { + return $this->payload; + } + + $payload = null; + $token = $this->getToken($request); + + if ($token !== null) { + $payload = $this->decodeToken($token); + } + + $this->payload = $payload; + + return $this->payload; + } + + /** + * Decode PASETO token. + * + * @param string $token PASETO token to decode. + * @return null|\ParagonIE\Paseto\JsonToken The PASETO payload as a JsonToken object, null on failure. + * @throws \Exception + */ + protected function decodeToken(string $token): ?JsonToken + { + if ($this->getConfig('purpose') === self::PUBLIC) { + $receivingKey = AsymmetricSecretKey::fromEncodedString( + $this->getConfig('secretKey'), + $this->version + )->getPublicKey(); + } else { + $receivingKey = new SymmetricKey( + $this->getConfig('secretKey'), + $this->version + ); + } + + $parser = new Parser( + new ProtocolCollection($receivingKey->getProtocol()), + new Purpose($this->getConfig('purpose')), + $receivingKey + ); + + return $parser->parse($token); + } + + /** + * Returns instance of ProtocolInterface from configured version. + * + * @return \ParagonIE\Paseto\ProtocolInterface|null + */ + private function whichVersion(): ?ProtocolInterface + { + switch ($this->getConfig('version')) { + case 'v3': + return new Version3(); + case 'v4': + return new Version4(); + } + + return null; + } +} diff --git a/src/Command/PasetoCommand.php b/src/Command/PasetoCommand.php new file mode 100644 index 00000000..145bdda2 --- /dev/null +++ b/src/Command/PasetoCommand.php @@ -0,0 +1,96 @@ +setDescription('Generate keys for PASETO') + ->addArgument('version', [ + 'help' => 'The PASETO version', + 'required' => true, + 'choices' => ['v3', 'v4'], + ]) + ->addArgument('purpose', [ + 'help' => 'The PASETO purpose', + 'required' => true, + 'choices' => [PasetoAuthenticator::LOCAL, PasetoAuthenticator::PUBLIC], + ]) + ->setEpilog('Example: bin/cake paseto gen v4 local'); + + return $parser; + } + + /** + * @param \Cake\Console\Arguments $args Arguments + * @param \Cake\Console\ConsoleIo $io ConsoleIo + * @return int|void|null + * @throws \ParagonIE\Paseto\Exception\PasetoException + * @throws \Exception + */ + public function execute(Arguments $args, ConsoleIo $io) + { + $version = strtolower((string)$args->getArgument('version')); + $version = trim($version); + switch (strtolower((string)$args->getArgument('purpose'))) { + case PasetoAuthenticator::LOCAL: + $io->info('Generating base64 ' . $version . ' local secret...'); + $key = SymmetricKey::generate($this->versionFromString($version)); + $io->out($key->encode()); + break; + case PasetoAuthenticator::PUBLIC: + $io->info('Generating base64 ' . $version . ' public keypair...'); + $key = AsymmetricSecretKey::generate($this->versionFromString($version)); + $io->out('Public: ' . $key->getPublicKey()->encode()); + $io->out('Private: ' . $key->encode()); + break; + } + } + + /** + * Return an instance of ProtocolInterface (Version) from a string. + * + * @param string $version Version string (e.g. "v4") + * @return \ParagonIE\Paseto\ProtocolInterface|null + */ + private function versionFromString(string $version): ?ProtocolInterface + { + $versions = [ + 'v3' => new Version3(), + 'v4' => new Version4(), + ]; + + return $versions[$version] ?? null; + } +} diff --git a/src/Identifier/PasetoSubjectIdentifier.php b/src/Identifier/PasetoSubjectIdentifier.php new file mode 100644 index 00000000..33c55461 --- /dev/null +++ b/src/Identifier/PasetoSubjectIdentifier.php @@ -0,0 +1,37 @@ + 'id', + 'dataField' => 'sub', + 'resolver' => 'Authentication.Orm', + ]; +} diff --git a/src/Plugin.php b/src/Plugin.php index f747894d..eba91436 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -15,6 +15,8 @@ */ namespace Authentication; +use Authentication\Command\PasetoCommand; +use Cake\Console\CommandCollection; use Cake\Core\BasePlugin; /** @@ -41,5 +43,15 @@ class Plugin extends BasePlugin * * @var bool */ - protected $consoleEnabled = false; + protected $consoleEnabled = true; + + /** + * @inheritDoc + */ + public function console(CommandCollection $commands): CommandCollection + { + $commands->add('paseto gen', PasetoCommand::class); + + return $commands; + } } diff --git a/tests/TestCase/Authenticator/PasetoAuthenticatorTest.php b/tests/TestCase/Authenticator/PasetoAuthenticatorTest.php new file mode 100644 index 00000000..5c143b25 --- /dev/null +++ b/tests/TestCase/Authenticator/PasetoAuthenticatorTest.php @@ -0,0 +1,553 @@ +identifiers = new IdentifierCollection([]); + } + + /** + * Test authentication with local purpose via header token. + * + * @dataProvider dataProviderForVersions + * @param string $version The PASETO version + * @param ProtocolInterface $protocol Instance of Version + * @return void + * @throws \ParagonIE\Paseto\Exception\InvalidKeyException + * @throws \ParagonIE\Paseto\Exception\InvalidPurposeException + * @throws \ParagonIE\Paseto\Exception\PasetoException + */ + public function testLocalAuthenticationViaHeaderToken(string $version, ProtocolInterface $protocol): void + { + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/'] + ); + $token = $this->buildLocalToken($protocol); + $request = $request->withAddedHeader('Authorization', 'Bearer ' . $token->toString()); + + $authenticator = new PasetoAuthenticator($this->identifiers, [ + 'secretKey' => self::LOCAL_SECRET_KEY, + 'purpose' => PasetoAuthenticator::LOCAL, + 'version' => $version, + ]); + + $result = $authenticator->authenticate($request); + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::SUCCESS, $result->getStatus()); + $this->assertInstanceOf(ArrayAccess::class, $result->getData()); + $this->assertIsArray($result->getData()['footer']); + $this->assertEquals('larry', $result->getData()['username']); + } + + /** + * Test authentication with local purpose via header token. + * + * @dataProvider dataProviderForVersions + * @param string $version The PASETO version + * @param ProtocolInterface $protocol Instance of Version + * @return void + * @throws \ParagonIE\Paseto\Exception\InvalidKeyException + * @throws \ParagonIE\Paseto\Exception\InvalidPurposeException + * @throws \ParagonIE\Paseto\Exception\PasetoException + */ + public function testPublicAuthenticationViaHeaderToken(string $version, ProtocolInterface $protocol): void + { + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/'] + ); + /** @var Builder $token extracted */ + /** @var AsymmetricSecretKey $privateKey extracted */ + extract($this->buildPublicToken($protocol)); + $request = $request->withAddedHeader('Authorization', 'Bearer ' . $token->toString()); + + $authenticator = new PasetoAuthenticator($this->identifiers, [ + 'secretKey' => $privateKey->encode(), + 'purpose' => PasetoAuthenticator::PUBLIC, + 'version' => $version, + ]); + + $result = $authenticator->authenticate($request); + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::SUCCESS, $result->getStatus()); + $this->assertInstanceOf(ArrayAccess::class, $result->getData()); + $this->assertIsArray($result->getData()['footer']); + $this->assertEquals('larry', $result->getData()['username']); + } + + /** + * Test authentication via query parameter. + * + * @dataProvider dataProviderForPurpose + * @param string $purpose Either local or public + * @return void + * @throws \ParagonIE\Paseto\Exception\InvalidKeyException + * @throws \ParagonIE\Paseto\Exception\InvalidPurposeException + * @throws \ParagonIE\Paseto\Exception\PasetoException + */ + public function testAuthenticationViaQueryParamToken(string $purpose): void + { + switch ($purpose) { + case PasetoAuthenticator::LOCAL: + $token = $this->buildLocalToken(new Version4()); + break; + case PasetoAuthenticator::PUBLIC: + /** @var Builder $token extracted */ + /** @var AsymmetricSecretKey $privateKey extracted */ + extract($this->buildPublicToken(new Version4())); + $secretKey = $privateKey->encode(); + break; + default: + $token = null; + $this->markAsRisky(); + } + + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/'], + ['token' => $token->toString()] + ); + + $authenticator = new PasetoAuthenticator($this->identifiers, [ + 'secretKey' => $secretKey ?? self::LOCAL_SECRET_KEY, + 'purpose' => $purpose, + 'version' => 'v4', + ]); + + $result = $authenticator->authenticate($request); + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::SUCCESS, $result->getStatus()); + $this->assertInstanceOf(ArrayAccess::class, $result->getData()); + } + + /** + * Test Authentication when `returnPayload` is false. + * + * @return void + * @throws \ParagonIE\Paseto\Exception\InvalidKeyException + * @throws \ParagonIE\Paseto\Exception\InvalidPurposeException + * @throws \ParagonIE\Paseto\Exception\PasetoException + */ + public function testAuthenticationViaIdentifierAndSub(): void + { + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/'] + ); + $token = $this->buildLocalToken(new Version4()); + $request = $request->withAddedHeader('Authorization', 'Bearer ' . $token->toString()); + + $this->identifiers = $this->createMock(IdentifierCollection::class); + $this->identifiers->expects($this->once()) + ->method('identify') + ->with([ + 'sub' => 3, + ]) + ->willReturn(new ArrayObject([ + 'username' => 'larry', + 'firstname' => 'larry', + ])); + + $authenticator = new PasetoAuthenticator($this->identifiers, [ + 'secretKey' => self::LOCAL_SECRET_KEY, + 'purpose' => PasetoAuthenticator::LOCAL, + 'version' => 'v4', + 'returnPayload' => false, + ]); + + $result = $authenticator->authenticate($request); + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::SUCCESS, $result->getStatus()); + $this->assertInstanceOf(ArrayAccess::class, $result->getData()); + } + + /** + * Test Authentication fails when token is invalid. + * + * @return void + * @throws \ParagonIE\Paseto\Exception\InvalidKeyException + * @throws \ParagonIE\Paseto\Exception\InvalidPurposeException + * @throws \ParagonIE\Paseto\Exception\PasetoException + */ + public function testAuthenticationFailsWithInvalidToken(): void + { + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/'] + ); + $token = $this->buildLocalToken(new Version4(), 'a-very-invalid-key'); + $request = $request->withAddedHeader('Authorization', 'Bearer ' . $token->toString()); + + $authenticator = new PasetoAuthenticator($this->identifiers, [ + 'secretKey' => self::LOCAL_SECRET_KEY, + 'purpose' => PasetoAuthenticator::LOCAL, + 'version' => 'v4', + 'returnPayload' => false, + ]); + + $result = $authenticator->authenticate($request); + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::FAILURE_CREDENTIALS_INVALID, $result->getStatus()); + $this->assertNUll($result->getData()); + $errors = $result->getErrors(); + $this->assertArrayHasKey('message', $errors); + $this->assertArrayHasKey('exception', $errors); + $this->assertInstanceOf(Exception::class, $errors['exception']); + } + + /** + * Test getPayLoad throws an Exception + * + * @return void + * @throws \ParagonIE\Paseto\Exception\InvalidKeyException + * @throws \ParagonIE\Paseto\Exception\InvalidPurposeException + * @throws \ParagonIE\Paseto\Exception\PasetoException + */ + public function testAuthenticationInvalidPayloadNotAnObject(): void + { + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/'] + ); + $token = $this->buildLocalToken(new Version4()); + $request = $request->withAddedHeader('Authorization', 'Bearer ' . $token->toString()); + + $authenticator = $this->getMockBuilder(PasetoAuthenticator::class) + ->setConstructorArgs([ + $this->identifiers, + [ + 'purpose' => PasetoAuthenticator::LOCAL, + 'version' => 'v4', + ], + ]) + ->onlyMethods([ + 'getPayLoad', + ]) + ->getMock(); + + $authenticator->expects($this->once()) + ->method('getPayLoad') + ->willThrowException(new Exception()); + + $result = $authenticator->authenticate($request); + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::FAILURE_CREDENTIALS_INVALID, $result->getStatus()); + $this->assertNull($result->getData()); + } + + /** + * Test getPayLoad returns null + * + * @return void + * @throws \ParagonIE\Paseto\Exception\InvalidKeyException + * @throws \ParagonIE\Paseto\Exception\InvalidPurposeException + * @throws \ParagonIE\Paseto\Exception\PasetoException + */ + public function testAuthenticationPayloadIsNull(): void + { + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/'] + ); + $token = $this->buildLocalToken(new Version4()); + $request = $request->withAddedHeader('Authorization', 'Bearer ' . $token->toString()); + + $authenticator = $this->getMockBuilder(PasetoAuthenticator::class) + ->setConstructorArgs([ + $this->identifiers, + [ + 'purpose' => PasetoAuthenticator::LOCAL, + 'version' => 'v4', + ], + ]) + ->onlyMethods([ + 'getPayLoad', + ]) + ->getMock(); + + $authenticator->expects($this->once()) + ->method('getPayLoad') + ->willReturn(null); + + $result = $authenticator->authenticate($request); + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::FAILURE_CREDENTIALS_INVALID, $result->getStatus()); + $this->assertNull($result->getData()); + } + + /** + * Test getPayLoad returns null + * + * @return void + * @throws \ParagonIE\Paseto\Exception\InvalidKeyException + * @throws \ParagonIE\Paseto\Exception\InvalidPurposeException + * @throws \ParagonIE\Paseto\Exception\PasetoException + */ + public function testAuthenticationReturnsNullPayload(): void + { + $authenticator = new PasetoAuthenticator($this->identifiers, [ + 'secretKey' => self::LOCAL_SECRET_KEY, + 'purpose' => PasetoAuthenticator::LOCAL, + 'version' => 'v4', + ]); + + $this->assertNull($authenticator->getPayload()); + } + + /** + * Test getPayLoad returns a JsonObject with no subject. + * + * @return void + * @throws \ParagonIE\Paseto\Exception\InvalidKeyException + * @throws \ParagonIE\Paseto\Exception\InvalidPurposeException + * @throws \ParagonIE\Paseto\Exception\PasetoException + */ + public function testAuthenticationReturnsJsonObjectWithoutSub(): void + { + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/'] + ); + $token = $this->buildLocalToken(new Version4()); + $request = $request->withAddedHeader('Authorization', 'Bearer ' . $token->toString()); + + $authenticator = $this->getMockBuilder(PasetoAuthenticator::class) + ->setConstructorArgs([ + $this->identifiers, + [ + 'purpose' => PasetoAuthenticator::LOCAL, + 'version' => 'v4', + ], + ]) + ->onlyMethods([ + 'getPayLoad', + ]) + ->getMock(); + + $authenticator->expects($this->once()) + ->method('getPayLoad') + ->willReturn((new JsonToken())->setSubject('')); + + $result = $authenticator->authenticate($request); + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::FAILURE_CREDENTIALS_MISSING, $result->getStatus()); + $this->assertNull($result->getData()); + } + + /** + * Test authentication when identity is not found. + * + * @return void + * @throws \ParagonIE\Paseto\Exception\InvalidKeyException + * @throws \ParagonIE\Paseto\Exception\InvalidPurposeException + * @throws \ParagonIE\Paseto\Exception\PasetoException + */ + public function testLocalAuthenticationIdentityNotFound(): void + { + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/'] + ); + $token = $this->buildLocalToken(new Version4(), null, '400123'); + $request = $request->withAddedHeader('Authorization', 'Bearer ' . $token->toString()); + + $authenticator = new PasetoAuthenticator($this->identifiers, [ + 'secretKey' => self::LOCAL_SECRET_KEY, + 'purpose' => PasetoAuthenticator::LOCAL, + 'version' => 'v4', + 'returnPayload' => false, + ]); + + $result = $authenticator->authenticate($request); + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::FAILURE_IDENTITY_NOT_FOUND, $result->getStatus()); + $this->assertNull($result->getData()); + } + + /** + * Test constructor validations throw RunTimeException. + * + * @return void + */ + public function testConstructorThrowsExceptionWhenVersionIsInvalid(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/PASETO `version` must be one of: v3 or v4/'); + new PasetoAuthenticator($this->identifiers, [ + 'secretKey' => self::LOCAL_SECRET_KEY, + 'version' => 'invalid', + 'purpose' => PasetoAuthenticator::LOCAL, + ]); + } + + /** + * Test constructor validations throw RunTimeException. + * + * @return void + */ + public function testConstructorThrowsExceptionWhenPurposeIsInvalid(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/PASETO `purpose` config must one of: local or public/'); + new PasetoAuthenticator($this->identifiers, [ + 'secretKey' => self::LOCAL_SECRET_KEY, + 'version' => 'v4', + 'purpose' => 'invalid', + ]); + } + + /** + * Builds a local PASETO token. + * + * @param ProtocolInterface $version The PASETO version + * @param null|string $keyMaterial [optional] If null self::LOCAL_SECRET_KEY is used + * @param string $sub [optional] Defaults to "3" + * @return Builder + * @throws \ParagonIE\Paseto\Exception\InvalidKeyException + * @throws \ParagonIE\Paseto\Exception\InvalidPurposeException + * @throws \ParagonIE\Paseto\Exception\PasetoException + */ + private function buildLocalToken( + ProtocolInterface $version, + ?string $keyMaterial = null, + string $sub = '3' + ): Builder { + $key = new SymmetricKey($keyMaterial ?? self::LOCAL_SECRET_KEY, $version); + + return (new Builder()) + ->setKey($key) + ->setSubject($sub) + ->setVersion($version) + ->setPurpose(Purpose::local()) + ->setIssuedAt() + ->setNotBefore() + ->setExpiration( + (new DateTime())->add(new DateInterval('P01D')) + ) + ->setClaims([ + 'claim_data' => 'is encrypted', + 'username' => 'larry', + 'firstname' => 'larry', + ]) + ->setFooterArray([ + 'footer_data' => 'is unencrypted but tamper proof', + ]); + } + + /** + * Builds a public PASETO token and turns a key-value array of `token` and `privateKey`. + * + * @param ProtocolInterface $version The PASETO version + * @return array + * @throws \ParagonIE\Paseto\Exception\InvalidKeyException + * @throws \ParagonIE\Paseto\Exception\InvalidPurposeException + * @throws \ParagonIE\Paseto\Exception\PasetoException + */ + private function buildPublicToken(ProtocolInterface $version): array + { + $privateKey = AsymmetricSecretKey::generate($version); + + return [ + 'token' => (new Builder()) + ->setKey($privateKey) + ->setSubject('3') + ->setVersion($version) + ->setPurpose(Purpose::public()) + ->setIssuedAt() + ->setNotBefore() + ->setExpiration( + (new DateTime())->add(new DateInterval('P01D')) + ) + ->setClaims([ + 'claim_data' => 'additional claims', + 'username' => 'larry', + 'firstname' => 'larry', + ]) + ->setFooterArray([ + 'footer_data' => 'some footer data', + ]), + 'privateKey' => $privateKey, + ]; + } + + /** + * Returns an array of version and purpose args. + * + * @return array + */ + public function dataProviderForVersions(): array + { + return [ + ['v3', new Version3()], + ['v4', new Version4()], + ]; + } + + /** + * Returns purpose arguments. + * + * @return array + */ + public function dataProviderForPurpose(): array + { + return [ + [PasetoAuthenticator::PUBLIC], + [PasetoAuthenticator::LOCAL], + ]; + } +} diff --git a/tests/TestCase/Command/PasetoCommandTest.php b/tests/TestCase/Command/PasetoCommandTest.php new file mode 100644 index 00000000..a3e9c84b --- /dev/null +++ b/tests/TestCase/Command/PasetoCommandTest.php @@ -0,0 +1,58 @@ +useCommandRunner(); + } + + /** + * @dataProvider dataProviderForVersions + * @param string $version + * @return void + */ + public function testGenLocal(string $version): void + { + $this->exec("paseto gen $version local"); + $this->assertArrayHasKey(1, $this->_out->messages()); + $length = strlen($this->_out->messages()[1]); + $this->assertGreaterThanOrEqual(16, $length); + $this->assertLessThanOrEqual(64, $length); + } + + /** + * @dataProvider dataProviderForVersions + * @param string $version + * @return void + */ + public function testGenPublic(string $version): void + { + $this->exec("paseto gen $version public"); + $this->assertCount(3, $this->_out->messages()); + $this->assertGreaterThanOrEqual(32, strlen($this->_out->messages()[1])); + $this->assertGreaterThanOrEqual(32, strlen($this->_out->messages()[2])); + } + + /** + * Returns an array of version and purpose args. + * + * @return array + */ + public function dataProviderForVersions(): array + { + return [ + ['v3'], + ['v4'], + ]; + } +} diff --git a/tests/test_app/config/bootstrap.php b/tests/test_app/config/bootstrap.php new file mode 100644 index 00000000..b3d9bbc7 --- /dev/null +++ b/tests/test_app/config/bootstrap.php @@ -0,0 +1 @@ + Date: Tue, 21 Jun 2022 17:47:12 -0400 Subject: [PATCH 3/4] set v4 and local as default version and purpose and remove unnecessary mocks from tests --- src/Authenticator/PasetoAuthenticator.php | 4 +- src/Command/PasetoCommand.php | 26 +++--- .../Authenticator/PasetoAuthenticatorTest.php | 92 ++++++------------- 3 files changed, 44 insertions(+), 78 deletions(-) diff --git a/src/Authenticator/PasetoAuthenticator.php b/src/Authenticator/PasetoAuthenticator.php index 509e6253..ace32d5a 100644 --- a/src/Authenticator/PasetoAuthenticator.php +++ b/src/Authenticator/PasetoAuthenticator.php @@ -52,8 +52,8 @@ class PasetoAuthenticator extends TokenAuthenticator 'queryParam' => 'token', 'tokenPrefix' => 'bearer', 'returnPayload' => true, - 'version' => null, - 'purpose' => null, + 'version' => 'v4', + 'purpose' => 'local', 'secretKey' => null, ]; diff --git a/src/Command/PasetoCommand.php b/src/Command/PasetoCommand.php index 145bdda2..50c9e62c 100644 --- a/src/Command/PasetoCommand.php +++ b/src/Command/PasetoCommand.php @@ -39,12 +39,10 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption ->setDescription('Generate keys for PASETO') ->addArgument('version', [ 'help' => 'The PASETO version', - 'required' => true, 'choices' => ['v3', 'v4'], ]) ->addArgument('purpose', [ 'help' => 'The PASETO purpose', - 'required' => true, 'choices' => [PasetoAuthenticator::LOCAL, PasetoAuthenticator::PUBLIC], ]) ->setEpilog('Example: bin/cake paseto gen v4 local'); @@ -61,9 +59,10 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption */ public function execute(Arguments $args, ConsoleIo $io) { - $version = strtolower((string)$args->getArgument('version')); + $version = strtolower($args->getArgument('version') ?? 'v4'); $version = trim($version); - switch (strtolower((string)$args->getArgument('purpose'))) { + $purpose = strtolower($args->getArgument('purpose') ?? PasetoAuthenticator::LOCAL); + switch ($purpose) { case PasetoAuthenticator::LOCAL: $io->info('Generating base64 ' . $version . ' local secret...'); $key = SymmetricKey::generate($this->versionFromString($version)); @@ -79,18 +78,19 @@ public function execute(Arguments $args, ConsoleIo $io) } /** - * Return an instance of ProtocolInterface (Version) from a string. + * Return an instance of ProtocolInterface (Version) from a string, defaults to Version4 * * @param string $version Version string (e.g. "v4") - * @return \ParagonIE\Paseto\ProtocolInterface|null + * @return \ParagonIE\Paseto\ProtocolInterface */ - private function versionFromString(string $version): ?ProtocolInterface + private function versionFromString(string $version): ProtocolInterface { - $versions = [ - 'v3' => new Version3(), - 'v4' => new Version4(), - ]; - - return $versions[$version] ?? null; + switch ($version) { + case 'v3': + return new Version3(); + case 'v4': + default: + return new Version4(); + } } } diff --git a/tests/TestCase/Authenticator/PasetoAuthenticatorTest.php b/tests/TestCase/Authenticator/PasetoAuthenticatorTest.php index 5c143b25..317d170f 100644 --- a/tests/TestCase/Authenticator/PasetoAuthenticatorTest.php +++ b/tests/TestCase/Authenticator/PasetoAuthenticatorTest.php @@ -27,7 +27,6 @@ use DateTime; use Exception; use ParagonIE\Paseto\Builder; -use ParagonIE\Paseto\JsonToken; use ParagonIE\Paseto\Keys\AsymmetricSecretKey; use ParagonIE\Paseto\Keys\SymmetricKey; use ParagonIE\Paseto\Protocol\Version3; @@ -118,9 +117,12 @@ public function testPublicAuthenticationViaHeaderToken(string $version, Protocol $request = ServerRequestFactory::fromGlobals( ['REQUEST_URI' => '/'] ); - /** @var Builder $token extracted */ - /** @var AsymmetricSecretKey $privateKey extracted */ - extract($this->buildPublicToken($protocol)); + /** @var Builder $token */ + /** @var AsymmetricSecretKey $privateKey */ + $publicData = $this->buildPublicToken($protocol); + $token = $publicData['token']; + $privateKey = $publicData['privateKey']; + $request = $request->withAddedHeader('Authorization', 'Bearer ' . $token->toString()); $authenticator = new PasetoAuthenticator($this->identifiers, [ @@ -154,9 +156,11 @@ public function testAuthenticationViaQueryParamToken(string $purpose): void $token = $this->buildLocalToken(new Version4()); break; case PasetoAuthenticator::PUBLIC: - /** @var Builder $token extracted */ - /** @var AsymmetricSecretKey $privateKey extracted */ - extract($this->buildPublicToken(new Version4())); + /** @var Builder $token */ + /** @var AsymmetricSecretKey $privateKey */ + $publicData = $this->buildPublicToken(new Version4()); + $token = $publicData['token']; + $privateKey = $publicData['privateKey']; $secretKey = $privateKey->encode(); break; default: @@ -267,25 +271,12 @@ public function testAuthenticationInvalidPayloadNotAnObject(): void $request = ServerRequestFactory::fromGlobals( ['REQUEST_URI' => '/'] ); - $token = $this->buildLocalToken(new Version4()); - $request = $request->withAddedHeader('Authorization', 'Bearer ' . $token->toString()); - - $authenticator = $this->getMockBuilder(PasetoAuthenticator::class) - ->setConstructorArgs([ - $this->identifiers, - [ - 'purpose' => PasetoAuthenticator::LOCAL, - 'version' => 'v4', - ], - ]) - ->onlyMethods([ - 'getPayLoad', - ]) - ->getMock(); - - $authenticator->expects($this->once()) - ->method('getPayLoad') - ->willThrowException(new Exception()); + $request = $request->withAddedHeader('Authorization', 'Bearer 123'); + $authenticator = new PasetoAuthenticator($this->identifiers, [ + 'secretKey' => self::LOCAL_SECRET_KEY, + 'purpose' => PasetoAuthenticator::LOCAL, + 'version' => 'v4', + ]); $result = $authenticator->authenticate($request); $this->assertInstanceOf(Result::class, $result); @@ -306,25 +297,12 @@ public function testAuthenticationPayloadIsNull(): void $request = ServerRequestFactory::fromGlobals( ['REQUEST_URI' => '/'] ); - $token = $this->buildLocalToken(new Version4()); - $request = $request->withAddedHeader('Authorization', 'Bearer ' . $token->toString()); - - $authenticator = $this->getMockBuilder(PasetoAuthenticator::class) - ->setConstructorArgs([ - $this->identifiers, - [ - 'purpose' => PasetoAuthenticator::LOCAL, - 'version' => 'v4', - ], - ]) - ->onlyMethods([ - 'getPayLoad', - ]) - ->getMock(); - - $authenticator->expects($this->once()) - ->method('getPayLoad') - ->willReturn(null); + $request = $request->withAddedHeader('Authorization', 'Bearer '); + $authenticator = new PasetoAuthenticator($this->identifiers, [ + 'secretKey' => self::LOCAL_SECRET_KEY, + 'purpose' => PasetoAuthenticator::LOCAL, + 'version' => 'v4', + ]); $result = $authenticator->authenticate($request); $this->assertInstanceOf(Result::class, $result); @@ -364,25 +342,13 @@ public function testAuthenticationReturnsJsonObjectWithoutSub(): void $request = ServerRequestFactory::fromGlobals( ['REQUEST_URI' => '/'] ); - $token = $this->buildLocalToken(new Version4()); + $token = $this->buildLocalToken(new Version4(), null, ''); $request = $request->withAddedHeader('Authorization', 'Bearer ' . $token->toString()); - - $authenticator = $this->getMockBuilder(PasetoAuthenticator::class) - ->setConstructorArgs([ - $this->identifiers, - [ - 'purpose' => PasetoAuthenticator::LOCAL, - 'version' => 'v4', - ], - ]) - ->onlyMethods([ - 'getPayLoad', - ]) - ->getMock(); - - $authenticator->expects($this->once()) - ->method('getPayLoad') - ->willReturn((new JsonToken())->setSubject('')); + $authenticator = new PasetoAuthenticator($this->identifiers, [ + 'secretKey' => self::LOCAL_SECRET_KEY, + 'purpose' => PasetoAuthenticator::LOCAL, + 'version' => 'v4', + ]); $result = $authenticator->authenticate($request); $this->assertInstanceOf(Result::class, $result); From 689c0a31bd7be61d4d8bf4d2aa8d3acddf5804b2 Mon Sep 17 00:00:00 2001 From: chris cnizzardini Date: Tue, 21 Jun 2022 18:00:07 -0400 Subject: [PATCH 4/4] increase coverage --- .../Authenticator/PasetoAuthenticatorTest.php | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/tests/TestCase/Authenticator/PasetoAuthenticatorTest.php b/tests/TestCase/Authenticator/PasetoAuthenticatorTest.php index 317d170f..41902a6f 100644 --- a/tests/TestCase/Authenticator/PasetoAuthenticatorTest.php +++ b/tests/TestCase/Authenticator/PasetoAuthenticatorTest.php @@ -243,8 +243,6 @@ public function testAuthenticationFailsWithInvalidToken(): void $authenticator = new PasetoAuthenticator($this->identifiers, [ 'secretKey' => self::LOCAL_SECRET_KEY, - 'purpose' => PasetoAuthenticator::LOCAL, - 'version' => 'v4', 'returnPayload' => false, ]); @@ -274,8 +272,6 @@ public function testAuthenticationInvalidPayloadNotAnObject(): void $request = $request->withAddedHeader('Authorization', 'Bearer 123'); $authenticator = new PasetoAuthenticator($this->identifiers, [ 'secretKey' => self::LOCAL_SECRET_KEY, - 'purpose' => PasetoAuthenticator::LOCAL, - 'version' => 'v4', ]); $result = $authenticator->authenticate($request); @@ -297,11 +293,8 @@ public function testAuthenticationPayloadIsNull(): void $request = ServerRequestFactory::fromGlobals( ['REQUEST_URI' => '/'] ); - $request = $request->withAddedHeader('Authorization', 'Bearer '); $authenticator = new PasetoAuthenticator($this->identifiers, [ 'secretKey' => self::LOCAL_SECRET_KEY, - 'purpose' => PasetoAuthenticator::LOCAL, - 'version' => 'v4', ]); $result = $authenticator->authenticate($request); @@ -322,8 +315,6 @@ public function testAuthenticationReturnsNullPayload(): void { $authenticator = new PasetoAuthenticator($this->identifiers, [ 'secretKey' => self::LOCAL_SECRET_KEY, - 'purpose' => PasetoAuthenticator::LOCAL, - 'version' => 'v4', ]); $this->assertNull($authenticator->getPayload()); @@ -346,8 +337,6 @@ public function testAuthenticationReturnsJsonObjectWithoutSub(): void $request = $request->withAddedHeader('Authorization', 'Bearer ' . $token->toString()); $authenticator = new PasetoAuthenticator($this->identifiers, [ 'secretKey' => self::LOCAL_SECRET_KEY, - 'purpose' => PasetoAuthenticator::LOCAL, - 'version' => 'v4', ]); $result = $authenticator->authenticate($request); @@ -374,8 +363,6 @@ public function testLocalAuthenticationIdentityNotFound(): void $authenticator = new PasetoAuthenticator($this->identifiers, [ 'secretKey' => self::LOCAL_SECRET_KEY, - 'purpose' => PasetoAuthenticator::LOCAL, - 'version' => 'v4', 'returnPayload' => false, ]);