Skip to content
Closed
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
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,15 @@
"cakephp/cakephp": "^4.4",
"cakephp/cakephp-codesniffer": "^4.0",
"firebase/php-jwt": "^6.2",
"paragonie/paseto": "^2.4",
"paragonie/constant_time_encoding": "^2.2",
Copy link
Contributor Author

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).

"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."
},
Expand Down
219 changes: 219 additions & 0 deletions src/Authenticator/PasetoAuthenticator.php
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @since 3.0.0
* @since 2.10.0

* @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',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
'purpose' => 'local',
'purpose' => self::LOCAL,

'secretKey' => null,
];

/**
* Payload data.
*
* @var object|null
*/
protected $payload;

/**
* @var \ParagonIE\Paseto\ProtocolInterface|null
*/
private $version;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private $version;
protected $version;


/**
* @inheritDoc
*/
public function __construct(IdentifierInterface $identifier, array $config = [])
{
parent::__construct($identifier, $config);

$this->version = $this->whichVersion();

if ($this->version === null) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whichVersion() method itself can throw an exception instead of returning 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');
}
Comment on lines +81 to +87
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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());
Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

@cnizzardini cnizzardini Jun 1, 2022

Choose a reason for hiding this comment

The 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 SodiumException: unsupported key length exception otherwise, unfortunately this is not well documented here: sodium_crypto_generichash. We could add a validation for this in the authenticator.

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.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where I wish the library provided a cli to easily generate those for developers.

We could provide that CLI tool 😄

Copy link
Member

Choose a reason for hiding this comment

The 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 SodiumException: unsupported key length exception otherwise, unfortunately this is not well documented here

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()]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the footer used for by application logic?

Copy link
Contributor Author

@cnizzardini cnizzardini Jun 1, 2022

Choose a reason for hiding this comment

The 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.

v4.local.KQVqiaVJbg_V0W9s-ekQMYyjA5sZCSMUFw6WxLJ_6806uE7ceNad9ABgGwgJ_HDN8zLDC_SF9hzFoESX8lt-v_o1LjsahdoOETFlp884ztE-vz5MZq6CfvaVhH4cjT8Wi6p43H95jegzM3BhbjrJpAiUOWISPxbR5Da90WSkCUVSQfMjCzSrTjEQRdTlbBNpjzEOen6f8Vaw-Wl2eqtmzgVDZ5LDcyR9fatO_p_IhH8cFwLXILoORpjFBgeCDXZqr9jaum6zEvAs3XSfWB86RLUM_QD-elM_2DkaCwqS_1btNYKpahRkLpr6ehM8dSKL.eyJmb290ZXJfZGF0YSI6ImlzIHVuZW5jcnlwdGVkIGJ1dCB0YW1wZXIgcHJvb2YifQ

So you can base64 decode the footer segment: eyJmb290ZXJfZGF0YSI6ImlzIHVuZW5jcnlwdGVkIGJ1dCB0YW1wZXIgcHJvb2YifQ to:

{"footer_data":"is unencrypted but tamper proof"}

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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The behavior looks like it wants to reuse the stored payload property. By passing null in, you get the last parsed result.

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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a reasonable default purpose to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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':
Copy link
Member

Choose a reason for hiding this comment

The 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;
}
}
96 changes: 96 additions & 0 deletions src/Command/PasetoCommand.php
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],
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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();
}
}
}
37 changes: 37 additions & 0 deletions src/Identifier/PasetoSubjectIdentifier.php
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',
];
}
14 changes: 13 additions & 1 deletion src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
*/
namespace Authentication;

use Authentication\Command\PasetoCommand;
use Cake\Console\CommandCollection;
use Cake\Core\BasePlugin;

/**
Expand All @@ -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;
}
}
Loading