-
Couldn't load subscription status.
- Fork 110
PASETO authenticator and identifier #538
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5cdc740
ef54f0e
3fcae0d
372f3e9
281b8ee
689c0a3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,219 @@ | ||||||
| <?php | ||||||
| declare(strict_types=1); | ||||||
|
|
||||||
| /** | ||||||
| * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) | ||||||
| * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) | ||||||
| * | ||||||
| * Licensed under The MIT License | ||||||
| * For full copyright and license information, please see the LICENSE.txt | ||||||
| * Redistributions of files must retain the above copyright notice. | ||||||
| * | ||||||
| * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) | ||||||
| * @link https://cakephp.org CakePHP(tm) Project | ||||||
| * @since 3.0.0 | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| * @license https://opensource.org/licenses/mit-license.php MIT License | ||||||
| */ | ||||||
| namespace Authentication\Authenticator; | ||||||
|
|
||||||
| use ArrayObject; | ||||||
| use Authentication\Identifier\IdentifierInterface; | ||||||
| use Exception; | ||||||
| use ParagonIE\Paseto\JsonToken; | ||||||
| use ParagonIE\Paseto\Keys\AsymmetricSecretKey; | ||||||
| use ParagonIE\Paseto\Keys\SymmetricKey; | ||||||
| use ParagonIE\Paseto\Parser; | ||||||
| use ParagonIE\Paseto\Protocol\Version3; | ||||||
| use ParagonIE\Paseto\Protocol\Version4; | ||||||
| use ParagonIE\Paseto\ProtocolCollection; | ||||||
| use ParagonIE\Paseto\ProtocolInterface; | ||||||
| use ParagonIE\Paseto\Purpose; | ||||||
| use Psr\Http\Message\ServerRequestInterface; | ||||||
| use RuntimeException; | ||||||
|
|
||||||
| /** | ||||||
| * PASETO Authenticator | ||||||
| * | ||||||
| * Authenticates an identity based on a PASETO token. | ||||||
| * | ||||||
| * @link https://github.com/paragonie/paseto | ||||||
| * @link https://github.com/paseto-standard/paseto-spec | ||||||
| */ | ||||||
| class PasetoAuthenticator extends TokenAuthenticator | ||||||
| { | ||||||
| public const LOCAL = 'local'; | ||||||
| public const PUBLIC = 'public'; | ||||||
|
|
||||||
| /** | ||||||
| * @inheritDoc | ||||||
| */ | ||||||
| protected $_defaultConfig = [ | ||||||
| 'header' => 'Authorization', | ||||||
| 'queryParam' => 'token', | ||||||
| 'tokenPrefix' => 'bearer', | ||||||
| 'returnPayload' => true, | ||||||
| 'version' => 'v4', | ||||||
| 'purpose' => 'local', | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| 'secretKey' => null, | ||||||
| ]; | ||||||
|
|
||||||
| /** | ||||||
| * Payload data. | ||||||
| * | ||||||
| * @var object|null | ||||||
| */ | ||||||
| protected $payload; | ||||||
|
|
||||||
| /** | ||||||
| * @var \ParagonIE\Paseto\ProtocolInterface|null | ||||||
| */ | ||||||
| private $version; | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
|
||||||
| /** | ||||||
| * @inheritDoc | ||||||
| */ | ||||||
| public function __construct(IdentifierInterface $identifier, array $config = []) | ||||||
| { | ||||||
| parent::__construct($identifier, $config); | ||||||
|
|
||||||
| $this->version = $this->whichVersion(); | ||||||
|
|
||||||
| if ($this->version === null) { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||||||
| 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'); | ||||||
| } | ||||||
|
Comment on lines
+81
to
+87
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are there no reasonably secure defaults we could use? Even 'most secure' is a reasonable defaults. Right now we're forcing a lot of decisions on the developer. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can default to local and v4. |
||||||
|
|
||||||
| if (empty($this->getConfig('secretKey'))) { | ||||||
| if (!class_exists(\Cake\Utility\Security::class)) { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't know how to test this, thinking this should be marked as code coverage ignore. |
||||||
| throw new RuntimeException('PASETO `secretKey` config must be defined'); | ||||||
| } | ||||||
| $this->setConfig('secretKey', \Cake\Utility\Security::getSalt()); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will the application salt always be long enough? Are there length requirements in paseto? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For Symmetric keys between 16 and 64 bytes. sodium_crypto_generichash will throw a For Asymmetric keys it is recommended to use sodium_crypto_sign_keypair to generate the key pairs: $privateKey = new AsymmetricSecretKey(sodium_crypto_sign_keypair());
$publicKey = $privateKey->getPublicKey();
var_dump($privateKey->encode());
var_dump($publicKey->encode());This is where I wish the library provided a cli to easily generate those for developers. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
We could provide that CLI tool 😄 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
That will work. If users get tripped up by the exceptions from libsodium we can always catch errors and rethrow with a better message. |
||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * 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()] | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is the footer used for by application logic? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A token is split into version, purpose, encrypted data, and unencrypted data with dots as the separator. So you can base64 decode the footer segment: I assume to give API clients or any client that doesn't have the secret the ability to read non-sensitive data. |
||||||
| ); | ||||||
|
|
||||||
| 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) { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure I understand this condition, it was borrowed from JwtAuthenticator. How is this scenario possible? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The behavior looks like it wants to reuse the stored |
||||||
| 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( | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems like a reasonable default There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was thinking LOCAL should be the default purpose. Local is similar to the default for JWT which uses an HMAC secret. |
||||||
| $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': | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Defaulting to the highest version seems like a good default value as well. |
||||||
| return new Version4(); | ||||||
| } | ||||||
|
|
||||||
| return null; | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| <?php | ||
| declare(strict_types=1); | ||
|
|
||
| /** | ||
| * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) | ||
| * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) | ||
| * | ||
| * Licensed under The MIT License | ||
| * For full copyright and license information, please see the LICENSE.txt | ||
| * Redistributions of files must retain the above copyright notice. | ||
| * | ||
| * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) | ||
| * @link https://cakephp.org CakePHP(tm) Project | ||
| * @since 3.0.0 | ||
| * @license https://opensource.org/licenses/mit-license.php MIT License | ||
| */ | ||
| namespace Authentication\Command; | ||
|
|
||
| use Authentication\Authenticator\PasetoAuthenticator; | ||
| use Cake\Command\Command; | ||
| use Cake\Console\Arguments; | ||
| use Cake\Console\ConsoleIo; | ||
| use Cake\Console\ConsoleOptionParser; | ||
| use ParagonIE\Paseto\Keys\AsymmetricSecretKey; | ||
| use ParagonIE\Paseto\Keys\SymmetricKey; | ||
| use ParagonIE\Paseto\Protocol\Version3; | ||
| use ParagonIE\Paseto\Protocol\Version4; | ||
| use ParagonIE\Paseto\ProtocolInterface; | ||
|
|
||
| class PasetoCommand extends Command | ||
| { | ||
| /** | ||
| * @param \Cake\Console\ConsoleOptionParser $parser ConsoleOptionParser | ||
| * @return \Cake\Console\ConsoleOptionParser | ||
| */ | ||
| protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser | ||
| { | ||
| $parser | ||
| ->setDescription('Generate keys for PASETO') | ||
| ->addArgument('version', [ | ||
| 'help' => 'The PASETO version', | ||
| 'choices' => ['v3', 'v4'], | ||
| ]) | ||
| ->addArgument('purpose', [ | ||
| 'help' => 'The PASETO purpose', | ||
| 'choices' => [PasetoAuthenticator::LOCAL, PasetoAuthenticator::PUBLIC], | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would be good to have a default value on these too. Good defaults help users succeed with less effort. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can set defaults to v4 local here as well. |
||
| ]) | ||
| ->setEpilog('Example: <info>bin/cake paseto gen v4 local</info>'); | ||
|
|
||
| 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($args->getArgument('version') ?? 'v4'); | ||
| $version = trim($version); | ||
| $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)); | ||
| $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, defaults to Version4 | ||
| * | ||
| * @param string $version Version string (e.g. "v4") | ||
| * @return \ParagonIE\Paseto\ProtocolInterface | ||
| */ | ||
| private function versionFromString(string $version): ProtocolInterface | ||
| { | ||
| switch ($version) { | ||
| case 'v3': | ||
| return new Version3(); | ||
| case 'v4': | ||
| default: | ||
| return new Version4(); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| <?php | ||
| declare(strict_types=1); | ||
|
|
||
| /** | ||
| * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) | ||
| * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) | ||
| * | ||
| * Licensed under The MIT License | ||
| * For full copyright and license information, please see the LICENSE.txt | ||
| * Redistributions of files must retain the above copyright notice. | ||
| * | ||
| * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) | ||
| * @link https://cakephp.org CakePHP(tm) Project | ||
| * @since 3.0.0 | ||
| * @license https://opensource.org/licenses/mit-license.php MIT License | ||
| */ | ||
| namespace Authentication\Identifier; | ||
|
|
||
| /** | ||
| * PASETO Subject aka "sub" identifier. | ||
| * | ||
| * This is mostly a convenience class that just overrides the defaults of the | ||
| * TokenIdentifier. | ||
| */ | ||
| class PasetoSubjectIdentifier extends TokenIdentifier | ||
| { | ||
| /** | ||
| * Default configuration | ||
| * | ||
| * @var array | ||
| */ | ||
| protected $_defaultConfig = [ | ||
| 'tokenField' => 'id', | ||
| 'dataField' => 'sub', | ||
| 'resolver' => 'Authentication.Orm', | ||
| ]; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was required because of prefer-lowest on 7.2. The method needed from the package was not included until 2.2 (current is 2.5).