From d574e530243ed2c8e04c06be08ed6b13a517a7db Mon Sep 17 00:00:00 2001 From: Steve Worley Date: Sun, 8 Aug 2021 15:22:44 +1000 Subject: [PATCH] JWT tokens. --- config/install/quant.token_settings.yml | 2 + quant.install | 62 ++++-------- quant.links.menu.yml | 1 - quant.links.task.yml | 5 + quant.module | 26 ++--- quant.services.yml | 1 + src/Exception/ExpiredTokenException.php | 12 +-- src/Exception/StrictTokenException.php | 63 ++++++++++++ src/Form/TokenForm.php | 29 ++++++ src/TokenManager.php | 126 +++++++++++------------- 10 files changed, 197 insertions(+), 130 deletions(-) create mode 100644 src/Exception/StrictTokenException.php diff --git a/config/install/quant.token_settings.yml b/config/install/quant.token_settings.yml index 0ac3ff5b..0128fe80 100644 --- a/config/install/quant.token_settings.yml +++ b/config/install/quant.token_settings.yml @@ -1,2 +1,4 @@ timeout: '+1 minute' disable: false +secret: null +strict: false diff --git a/quant.install b/quant.install index 20b64a97..2ed7ead4 100644 --- a/quant.install +++ b/quant.install @@ -8,50 +8,12 @@ use Drupal\Core\Database\Database; /** - * Implements hook_schema(). - * - * Defines a simple table to store salted tokens. - * - * @see hook_schema() - * - * @ingroup quant + * Perform setup tasks for Quant. */ -function quant_schema() { - $schema['quant_token'] = [ - 'description' => 'Stores short-lived access tokens.', - 'fields' => [ - 'pid' => [ - 'type' => 'serial', - 'not null' => TRUE, - 'description' => 'Primary key: Unique token ID.', - ], - 'token' => [ - 'type' => 'varchar', - 'not null' => TRUE, - 'length' => 255, - 'default' => '', - 'description' => 'The token value.', - ], - 'route' => [ - 'type' => 'text', - 'size' => 'normal', - 'not null' => FALSE, - 'description' => 'A path to register for the token', - ], - 'created' => [ - 'type' => 'int', - 'size' => 'normal', - 'not null' => TRUE, - 'description' => 'When the token was created.', - ], - ], - 'primary key' => ['pid'], - 'indexes' => [ - 'token' => ['token'], - ], - ]; - - return $schema; +function quant_install() { + $config = \Drupal::configFactory()->getEditable('quant.token_settings'); + $config->set('secret', bin2hex(random_bytes(32))); + $config->save(); } /** @@ -94,3 +56,17 @@ function quant_update_9002(&$sandbox) { $config->set('disable', FALSE); $config->save(); } + +/** + * Support JWT for internal request tokens. + */ +function quant_update_9003(&$sandbox) { + $config = \Drupal::configFactory()->getEditable('quant.token_settings'); + $config->set('secret', bin2hex(random_bytes(32))); + $config->set('strict', FALSE); + $config->save(); + + // Remove the token table. + $schema = \Database::getConnection()->schema(); + $schema->dropTable('quant_token'); +} diff --git a/quant.links.menu.yml b/quant.links.menu.yml index b59b14a1..993e7e9b 100644 --- a/quant.links.menu.yml +++ b/quant.links.menu.yml @@ -19,7 +19,6 @@ quant.token: parent: quant weight: 2 - quant.metadata: title: 'Metadata' route_name: quant.metadata diff --git a/quant.links.task.yml b/quant.links.task.yml index 8d9d85d8..984951d4 100644 --- a/quant.links.task.yml +++ b/quant.links.task.yml @@ -8,6 +8,11 @@ quant.seed: title: 'Seed' base_route: quant.config +quant.token: + route_name: quant.token + title: 'Token' + base_route: quant.config + quant.metadata: route_name: quant.metadata title: Metadata diff --git a/quant.module b/quant.module index ae36e088..c91ef5ef 100644 --- a/quant.module +++ b/quant.module @@ -13,6 +13,7 @@ use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Url; use Drupal\quant\Exception\ExpiredTokenException; use Drupal\quant\Exception\InvalidTokenException; +use Drupal\quant\Exception\StrictTokenException; use Drupal\quant\Exception\TokenValidationDisabledException; use Drupal\quant\Plugin\QueueItem\RouteItem; use Drupal\quant\Seed; @@ -162,17 +163,11 @@ function quant_shutdown(array $context = []) { } } -/** - * Implements hook_cron(). - */ -function quant_cron() { - \Drupal::service('quant.token_manager')->release(); -} - /** * Implements hook_node_access(). */ function quant_node_access(NodeInterface $node, $op, AccountInterface $account) { + $strict = \Drupal::config('quant.token_settings')->get('strict'); $request = \Drupal::request(); if (!$request->headers->has('quant-revision') && !$request->headers->has('quant-token')) { @@ -183,16 +178,25 @@ function quant_node_access(NodeInterface $node, $op, AccountInterface $account) $url = Url::fromRoute('entity.node.canonical', ['node' => $node->id()], $options)->toString(); try { - \Drupal::service('quant.token_manager')->validate($url, TRUE); + \Drupal::service('quant.token_manager')->validate($url, $strict); } catch (TokenValidationDisabledException $e) { - + // Allow access when token validation is disabled. } catch (ExpiredTokenException $e) { - throw new ServiceUnavailableHttpException(NULL, t('Service route is not available.')); + throw new ServiceUnavailableHttpException(NULL, t('Token request: time mismatch. Received [:token_time] expected [:server_time]', [ + ':token_time' => $e->getTime(), + ':server_time' => $e->getServerTime(), + ])); + } + catch (StrictTokenException $e) { + throw new ServiceUnavailableHttpException(NULL, t('Token request: route mismatch. Received [:route] expected [:expected]', [ + ':route' => $e->getTokenRoute(), + ':expected' => $e->getExpectedRoute(), + ])); } catch (InvalidTokenException $e) { - throw new ServiceUnavailableHttpException(NULL, t('Service route is not available.')); + throw new ServiceUnavailableHttpException(NULL, t('Token request: Invalid token')); } // If the token validation didn't trigger an exception - then the diff --git a/quant.services.yml b/quant.services.yml index fcc1f210..e09ec184 100644 --- a/quant.services.yml +++ b/quant.services.yml @@ -29,3 +29,4 @@ services: - '@database' - '@request_stack' - '@config.factory' + - '@datetime.time' diff --git a/src/Exception/ExpiredTokenException.php b/src/Exception/ExpiredTokenException.php index b84b07cb..8fd3e46b 100644 --- a/src/Exception/ExpiredTokenException.php +++ b/src/Exception/ExpiredTokenException.php @@ -24,17 +24,17 @@ class ExpiredTokenException extends \Exception { /** * The matched record. * - * @var object + * @var int */ - protected $record; + protected $sTime; /** * {@inheritdoc} */ - public function __construct(string $token, int $time = 0, $record = [], string $message = "The token has expired", int $code = 0, \Throwable $previous = NULL) { + public function __construct(string $token, int $time = 0, $sTime = 0, string $message = "The token has expired", int $code = 0, \Throwable $previous = NULL) { $this->token = $token; $this->time = $time; - $this->record = $record; + $this->sTime = $sTime; parent::__construct($message, $code, $previous); } @@ -56,8 +56,8 @@ public function getTime() { /** * Getter for the database record. */ - public function getRecord() { - return $this->record; + public function getServerTime() { + return $this->sTime; } } diff --git a/src/Exception/StrictTokenException.php b/src/Exception/StrictTokenException.php new file mode 100644 index 00000000..e6352187 --- /dev/null +++ b/src/Exception/StrictTokenException.php @@ -0,0 +1,63 @@ +token = $token; + $this->tokenRoute = $token_route; + $this->expectedRoute = $expected_route; + + parent::__construct($message, $code, $previous); + } + + /** + * Getter for the token. + */ + public function getToken() { + return $this->token; + } + + /** + * Getter for the time. + */ + public function getTokenRoute() { + return $this->tokenRoute; + } + + /** + * Getter for the database record. + */ + public function getExpectedRoute() { + return $this->expectedRoute; + } + +} diff --git a/src/Form/TokenForm.php b/src/Form/TokenForm.php index 69746587..565b8f6c 100644 --- a/src/Form/TokenForm.php +++ b/src/Form/TokenForm.php @@ -34,6 +34,20 @@ protected function getEditableConfigNames() { public function buildForm(array $form, FormStateInterface $form_state) { $config = $this->config(static::SETTINGS); + $form['disable'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Disable token verification'), + '#description' => $this->t('Not recommended for production environments, this disables token verification'), + '#default_value' => $config->get('disable'), + ]; + + $form['strict'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enable strict tokens'), + '#description' => $this->t('Allow token verificaiton process to perform route validations, this may not work for all Drupal configurations.'), + '#default_value' => $config->get('strict'), + ]; + $form['timeout'] = [ '#type' => 'textfield', '#title' => $this->t('Token timeout'), @@ -42,6 +56,12 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#required' => TRUE, ]; + $form['generate_secret'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Generate a new secret token'), + '#description' => $this->t('Regenerate the secret token used to sign internal requests'), + ]; + return parent::buildForm($form, $form_state); } @@ -64,8 +84,17 @@ public function validateForm(array &$form, FormStateInterface $form_state) { * {@inheritdoc} */ public function submitForm(array &$form, FormStateInterface $form_state) { + $config = $this->config(static::SETTINGS); + $editable = $this->configFactory->getEditable(static::SETTINGS); + + if ($form_state->getValue('generate_secret') || empty($config->get('secret'))) { + $editable->set('secret', bin2hex(random_bytes(32))); + } + $this->configFactory->getEditable(static::SETTINGS) ->set('timeout', $form_state->getValue('timeout')) + ->set('disable', $form_state->getValue('disable')) + ->set('strict', $form_state->get('strict')) ->save(); parent::submitForm($form, $form_state); diff --git a/src/TokenManager.php b/src/TokenManager.php index 02611628..c4dbd7ac 100644 --- a/src/TokenManager.php +++ b/src/TokenManager.php @@ -6,8 +6,10 @@ use Drupal\Core\Database\Connection; use Drupal\quant\Exception\ExpiredTokenException; use Drupal\quant\Exception\InvalidTokenException; +use Drupal\quant\Exception\StrictTokenException; use Drupal\quant\Exception\TokenValidationDisabledException; use Symfony\Component\HttpFoundation\RequestStack; +use Drupal\Component\Datetime\TimeInterface; /** * Simple interface to manage short-lived access tokens. @@ -46,40 +48,35 @@ class TokenManager { * The current request stack. * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory * The config factory. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time interface. */ - public function __construct(Connection $connection, RequestStack $request, ConfigFactoryInterface $config_factory) { + public function __construct(Connection $connection, RequestStack $request, ConfigFactoryInterface $config_factory, TimeInterface $time) { $this->connection = $connection; $this->request = $request; $this->settings = $config_factory->get('quant.token_settings'); + $this->time = $time; } /** - * Generate a token to use. + * Prepare a JWT header part. + * + * @param array|string $part + * The part to encode. * * @return string - * The token. + * base64encoded string. */ - protected function generate() { - if (function_exists('random_bytes')) { - $bytes = random_bytes(ceil(16 / 2)); - $hash = substr(bin2hex($bytes), 0, 16); - } - else { - $hash = bin2hex(random_bytes(16)); + public static function encode($part = []) { + if (is_array($part)) { + $part = json_encode($part); } - return base64_encode($hash); - } - /** - * Delete a token. - * - * @param string $token - * The token to remove. - */ - protected function delete($token) { - $this->connection->delete('quant_token') - ->condition('token', $token) - ->execute(); + return str_replace( + ['+', '/', '='], + ['-', '_', ''], + base64_encode($part) + ); } /** @@ -92,26 +89,21 @@ protected function delete($token) { * The token. */ public function create($route = NULL) { - // @todo table has DEFAULT now() but this was causing - // issues with request time mismatches so for now we just - // insert the request time for create. - $time = new \DateTime(); - $token = $this->generate(); - $query = $this->connection->insert('quant_token') - ->fields([ - 'route' => $route, - 'token' => $token, - 'created' => $time->getTimestamp(), - ]); - - try { - $query->execute(); - } - catch (\Exception $error) { - return FALSE; - } + $secret = $this->settings->get('secret'); + $time = $this->time->getRequestTime(); - return $token; + $header = ['typ' => 'JWT', 'alg' => 'HS256']; + $payload = [ + 'user' => 'quant', + 'route' => $route, + 'expires' => strtotime($this->settings->get('timeout'), $time), + ]; + + $header = self::encode($header); + $payload = self::encode($payload); + $signature = hash_hmac('sha256', "$header.$payload", $secret, TRUE); + + return "$header.$payload." . self::encode($signature); } /** @@ -133,50 +125,46 @@ public function create($route = NULL) { * @throws Drupal\quant\Exception\ExpiredTokenException */ public function validate($route = NULL, $strict = TRUE) { - - $token = $this->request->getCurrentRequest()->headers->get('quant-token'); - $time = new \DateTime(); - $time = $time->getTimestamp(); - if ($this->settings->get('disable')) { + // Allow administrators to completely bypass the token verification + // process. This can be done to test server configuration and is + // not recommended in production. throw new TokenValidationDisabledException(); } + $secret = $this->settings->get('secret'); + $time = $this->time->getRequestTime(); + $token = $this->request->getCurrentRequest()->headers->get('quant-token'); + if (empty($token)) { - return FALSE; + throw new InvalidTokenException($token, $time); } - $query = $this->connection->select('quant_token', 'qt') - ->condition('qt.token', $token) - ->fields('qt', ['route', 'created']) - ->range(0, 1); + $token_parts = explode('.', $token); + $header = json_decode(base64_decode($token_parts[0]), TRUE); + $payload = json_decode(base64_decode($token_parts[1]), TRUE); + $provided_signature = $token_parts[2]; - try { - $record = $query->execute()->fetchObject(); - } - catch (\Exception $error) { + $signature = hash_hmac('sha256', "{$token_parts[0]}.{$token_parts[1]}", $secret, TRUE); + $signature = self::encode($signature); + + if ($signature !== $provided_signature) { throw new InvalidTokenException($token, $time); } - $valid_until = strtotime($this->settings->get('timeout'), $record->created); - $expired = $time > $valid_until; + if (empty($payload['expires'])) { + throw new InvalidTokenException($token, $time); + } - if (!$strict && $expired) { - throw new ExpiredTokenException($token, $time, $record); + if ($payload['expires'] - $time < 0) { + throw new ExpiredTokenException($token, $payload['expires'], $time); } - if ($strict) { - if ($expired || $route != $record->route) { - throw new ExpiredTokenException($token, $time, $record); - } + if ($strict && ($route != $payload['route'])) { + throw new StrictTokenException($token, $payload['route'], $route); } - } - /** - * Release tokens that have been created. - */ - public function release() { - return $this->connection->query('TRUNCATE quant_token'); + return TRUE; } }