sqrl-2.0.0-rc1/src/Client.php

src/Client.php
<?php

namespace Drupal\sqrl;

use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Plugin\Exception\PluginException;
use Drupal\Component\Utility\Random;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\ImmutableConfig;
use Drupal\Core\Entity\EntityStorageException;
use Drupal\Core\Entity\EntityTypeManager;
use Drupal\Core\Session\AccountProxy;
use Drupal\sqrl\Entity\IdentityInterface;
use Drupal\sqrl\Exception\ClientException;
use Drupal\user\Entity\User;
use Drupal\user\UserInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;

/**
 * Provides sqrl client services.
 */
class Client {

  use StringManipulation;

  public const CRLF = "\r\n";
  public const VERSION = '1';

  public const FLAG_IDK_MATCH = 0x01;
  public const FLAG_PIDK_MATCH = 0x02;
  public const FLAG_IP_MATCH = 0x04;
  public const FLAG_SQRL_DISABLED = 0x08;
  public const FLAG_FUNCTION_NOT_SUPPORTED = 0x10;
  public const FLAG_TRANSIENT_ERROR = 0x20;
  public const FLAG_COMMAND_FAILURE = 0x40;
  public const FLAG_FAILURE = 0x80;
  public const FLAG_BAD_ID_ASSOCIATION = 0x100;
  public const FLAG_ID_SUPERSEDED = 0x200;

  /**
   * The metadata structure of signatures.
   *
   * @var array<string, array<string, bool|string>>
   */
  private array $signatureMetaData = [
    'ids' => [
      'key' => 'idk',
      'required' => TRUE,
      'validated' => FALSE,
    ],
    'pids' => [
      'key' => 'pidk',
      'required' => FALSE,
      'validated' => FALSE,
    ],
    'urs' => [
      'key' => 'vuk',
      'required' => TRUE,
      'validated' => TRUE,
    ],
  ];

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManager
   */
  protected EntityTypeManager $entityTypeManager;

  /**
   * The identities.
   *
   * @var \Drupal\sqrl\Identities
   */
  protected Identities $identities;

  /**
   * The configuration.
   *
   * @var \Drupal\Core\Config\ImmutableConfig
   */
  protected ImmutableConfig $config;

  /**
   * The state service.
   *
   * @var \Drupal\sqrl\State
   */
  protected State $state;

  /**
   * The request.
   *
   * @var \Symfony\Component\HttpFoundation\Request
   */
  protected Request $request;

  /**
   * The current user.
   *
   * @var \Drupal\Core\Session\AccountProxy
   */
  protected AccountProxy $currentUser;

  /**
   * The sqrl service.
   *
   * @var \Drupal\sqrl\Sqrl
   */
  protected Sqrl $sqrl;

  /**
   * The logger channel.
   *
   * @var \Drupal\sqrl\Log
   */
  protected Log $log;

  /**
   * The random service.
   *
   * @var \Drupal\Component\Utility\Random
   */
  protected Random $random;

  /**
   * The time service.
   *
   * @var \Drupal\Component\Datetime\TimeInterface
   */
  protected TimeInterface $time;

  /**
   * The sqrl action plugin manager.
   *
   * @var \Drupal\sqrl\SqrlActionPluginManager
   */
  protected SqrlActionPluginManager $actionPluginManager;

  /**
   * The identity entity.
   *
   * @var \Drupal\sqrl\Entity\IdentityInterface|null
   */
  protected ?IdentityInterface $identity = NULL;

  /**
   * The nut service.
   *
   * @var \Drupal\sqrl\Nut
   */
  private Nut $nut;

  /**
   * The posted values.
   *
   * @var array|null
   */
  private ?array $postValues = NULL;

  /**
   * The client's signatures.
   *
   * @var array
   */
  private array $clientSignatures;

  /**
   * The client's variables.
   *
   * @var array
   */
  private array $clientVars;

  /**
   * The options.
   *
   * @var array
   */
  private array $options;

  /**
   * The validation string.
   *
   * @var string
   */
  private string $validationString;

  /**
   * The tif.
   *
   * @var int
   */
  private int $tif = 0;

  /**
   * The response.
   *
   * @var array
   */
  private array $response = [];

  /**
   * A user account.
   *
   * @var \Drupal\user\UserInterface|null
   */
  private ?UserInterface $account = NULL;

  /**
   * Constructs the client services.
   *
   * @param \Drupal\Core\Session\AccountProxy $current_user
   *   The current user.
   * @param \Drupal\sqrl\Sqrl $sqrl
   *   The sqrl service.
   * @param \Drupal\sqrl\Log $log
   *   The log channel.
   * @param \Drupal\Component\Datetime\TimeInterface $time
   *   The time service.
   * @param \Drupal\sqrl\SqrlActionPluginManager $sqrl_action_plugin_manager
   *   The sqrl action plugin manager.
   * @param \Drupal\sqrl\State $state
   *   The state service.
   * @param \Symfony\Component\HttpFoundation\RequestStack $request
   *   The request.
   * @param \Drupal\Core\Entity\EntityTypeManager $entity_type_manager
   *   The entity type manager.
   * @param \Drupal\sqrl\Identities $identities
   *   The identities.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
   *   The config factory.
   */
  public function __construct(AccountProxy $current_user, Sqrl $sqrl, Log $log, TimeInterface $time, SqrlActionPluginManager $sqrl_action_plugin_manager, State $state, RequestStack $request, EntityTypeManager $entity_type_manager, Identities $identities, ConfigFactoryInterface $config_factory) {
    $this->currentUser = $current_user;
    $this->sqrl = $sqrl;
    $this->log = $log;
    $this->actionPluginManager = $sqrl_action_plugin_manager;
    $this->time = $time;
    $this->state = $state;
    $this->entityTypeManager = $entity_type_manager;
    $this->identities = $identities;
    $this->config = $config_factory->get('sqrl.settings');

    $this->request = $request->getCurrentRequest();
    $this->random = new Random();

    $this->nut = $sqrl->getNewNut();
    $this->nut->fetch();
  }

  /**
   * Main processing method.
   *
   * @see https://www.grc.com/sqrl/semantics.htm
   *
   * The data to be signed are the two base64url encoded values of the
   * “client=” and “server=” parameters with the “server=” value concatenated
   * to the end of the “client=” value.
   *
   * @return string
   *   The built response.
   */
  public function process(): string {
    try {
      $this->clientSignatures = [
        'ids' => $this->getPostValue('ids'),
        'pids' => $this->getPostValue('pids'),
        'urs' => $this->getPostValue('urs'),
      ];
      $this->clientVars = $this->decodeParameter($this->getPostValue('client'));
      $this->options = isset($this->clientVars['opt']) ? explode('~', $this->clientVars['opt']) : [];
      $this->validationString = $this->getPostValue('client', TRUE) . $this->getPostValue('server', TRUE);

      $this->identities->setIdk($this->getClientVar('idk'));
      $this->identities->setPidk($this->getClientVar('pidk'));

      $this->validate();
    }
    catch (ClientException | \JsonException | \SodiumException $e) {
      $this->tif |= self::FLAG_FAILURE;
      $this->log->error($e->getMessage());
      return $this->buildResponse();
    }

    try {
      $this->validateIpAddress();
      $this->findAccount();
    }
    catch (ClientException $e) {
      $this->log->error($e->getMessage());
      return $this->buildResponse();
    }
    $command = $this->clientVars['cmd'];
    if ($command !== 'query') {
      if ($command !== 'ident' && $this->identity === NULL) {
        $this->tif |= self::FLAG_COMMAND_FAILURE;
      }
      else {
        $this->executeCommand($command);
      }
    }
    return $this->buildResponse();
  }

  /**
   * Gets the identity.
   *
   * @return \Drupal\sqrl\Entity\IdentityInterface|null
   *   The identity or NULL.
   */
  public function getIdentity(): ?IdentityInterface {
    return $this->identity;
  }

  /**
   * Gets the account.
   *
   * @return \Drupal\user\UserInterface|null
   *   The account or NULL.
   */
  public function getAccount(): ?UserInterface {
    return $this->account;
  }

  /**
   * Gets the nut.
   *
   * @return \Drupal\sqrl\Nut
   *   The nut.
   */
  public function getNut(): Nut {
    return $this->nut;
  }

  /**
   * Gets the tif.
   *
   * @return int
   *   The tif.
   */
  public function getTif(): int {
    return $this->tif;
  }

  /**
   * Gets a client variable.
   *
   * @param string $key
   *   The key for the variable.
   *
   * @return string
   *   The client variable.
   */
  public function getClientVar(string $key): string {
    return $this->clientVars[$key] ?? '';
  }

  /**
   * Gets a client option.
   *
   * @param string $key
   *   The key for the option.
   *
   * @return bool
   *   TRUE, if the key is set in the options, FALSE otherwise.
   */
  private function getClientOpt(string $key): bool {
    return in_array($key, $this->options, TRUE) !== FALSE;
  }

  /**
   * Adds a key and value to the response.
   *
   * @param string $key
   *   The key.
   * @param string $value
   *   The value.
   *
   * @return self
   *   This client service.
   */
  private function setResponse(string $key, string $value): Client {
    $this->response[$key] = $value;
    return $this;
  }

  /**
   * Validates the request.
   *
   * @throws \Drupal\sqrl\Exception\ClientException
   * @throws \JsonException
   * @throws \SodiumException   */
  private function validate(): void {
    $this->validateSignatures();
    $this->validateHeader();
    $this->validateClientVars();
    $this->validateServerVars();
    $this->validateNut();
  }

  /**
   * Validates the signatures.
   *
   * @throws \Drupal\sqrl\Exception\ClientException
   * @throws \JsonException
   * @throws \SodiumException
   */
  private function validateSignatures(): void {
    $msg = $this->validationString;
    // Is the message present?
    if (empty($msg)) {
      throw new ClientException('Missing validation string');
    }
    if ($this->identity !== NULL && in_array($this->clientVars['cmd'], ['enable', 'remove']) !== FALSE) {
      $this->signatureMetaData['urs']['required'] = TRUE;
      $this->signatureMetaData['urs']['validated'] = FALSE;
      $this->clientVars['vuk'] = $this->identity->getVuk();
    }
    foreach ($this->signatureMetaData as $sig_key => $data) {
      if ($data['validated']) {
        continue;
      }
      $sig = $this->clientSignatures[$sig_key] ?? '';
      $pk  = $this->clientVars[$data['key']] ?? '';
      if ($data['required'] && (empty($sig) || empty($pk))) {
        // Is the signature present?
        if (empty($sig)) {
          throw new ClientException('Missing signature');
        }
        // Is the public key present?
        if (empty($pk)) {
          throw new ClientException('Missing public key');
        }
      }
      if (!empty($sig) && !empty($pk)) {
        // Validate signature.
        $this->validateSignature($msg, $sig, $pk);
        $this->signatureMetaData[$sig_key]['validated'] = TRUE;
      }
    }
  }

  /**
   * Validate a specific signature.
   *
   * @param string $msg
   *   The plain text of signed message.
   * @param string $sig
   *   The base64url-encoded signature.
   * @param string $pk
   *   The base64url-encoded Public Key.
   *
   * @throws \Drupal\sqrl\Exception\ClientException
   * @throws \JsonException
   * @throws \SodiumException
   */
  private function validateSignature(string $msg, string $sig, string $pk): void {
    $this->log->debug('Validate signature %values', [
      '%values' => json_encode([
        'msg' => $msg,
        'sig' => $sig,
        'key' => $pk,
      ], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
    ]);
    if (!sodium_crypto_sign_verify_detached($this->base64Decode($sig), $msg, $this->base64Decode($pk))) {
      throw new ClientException('Invalid signature');
    }
  }

  /**
   * Validate required header values.
   */
  private function validateHeader(): void {
    // No validation required at this point.
  }

  /**
   * Validate required client values.
   *
   * @throws \Drupal\sqrl\Exception\ClientException
   */
  private function validateClientVars(): void {
    if ($this->clientVars['ver'] !== self::VERSION) {
      throw new ClientException('Unsupported version');
    }
  }

  /**
   * Validate required server values.
   */
  private function validateServerVars(): void {
    // No validation required at this point.
  }

  /**
   * Validate nut.
   *
   * @throws \Drupal\sqrl\Exception\ClientException
   */
  private function validateNut(): void {
    if (!$this->getNut()->isValid()) {
      if ($this->getNut()->isExpired()) {
        $this->tif |= self::FLAG_TRANSIENT_ERROR;
      }
      throw new ClientException('Invalid nut');
    }
  }

  /**
   * Validate IP address.
   *
   * @throws \Drupal\sqrl\Exception\ClientException
   */
  private function validateIpAddress(): void {
    if ($this->getNut()->isIpValid()) {
      $this->tif |= self::FLAG_IP_MATCH;
      return;
    }
    if ($this->getClientOpt('noiptest')) {
      return;
    }
    throw new ClientException('Invalid nut');
  }

  /**
   * Builds the response.
   *
   * @return string
   *   The response string.
   */
  private function buildResponse(): string {
    $values = [
      'ver' => self::VERSION,
      'nut' => $this->sqrl->getNut()->getPublicNut(),
      'tif' => dechex($this->tif),
      'qry' => $this->sqrl->getPath('sqrl.client', [], TRUE, FALSE),
    ];
    if ($this->identity !== NULL && $this->getClientOpt('suk')) {
      $this->response['suk'] = $this->identity->getSuk();
    }
    foreach ($this->response as $key => $value) {
      $values[$key] = $value;
    }

    $output = [];
    foreach ($values as $key => $value) {
      $output[] = $key . '=' . $value;
    }
    return $this->base64Encode(implode(self::CRLF, $output) . self::CRLF);
  }

  /**
   * Gets a posted value from the client.
   *
   * @param string $key
   *   The key.
   * @param bool $plain
   *   Set to TRUE, to receive a base64 encoded result, FALSE otherwise.
   *
   * @return string
   *   The posted value.
   *
   * @throws \JsonException
   */
  private function getPostValue(string $key, bool $plain = FALSE): string {
    if ($this->postValues === NULL) {
      $this->postValues = [];
      foreach (explode('&', $this->request->getContent()) as $entry) {
        [$name, $value] = explode('=', $entry);
        $this->postValues[$name] = (in_array($name, ['ids', 'pids', 'urs']) === FALSE) ? $this->base64Decode($value) : $value;
      }
      $this->log->debug('POST %values', ['%values' => json_encode($this->postValues, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)]);
    }
    $value = $this->postValues[$key] ?? '';
    if ($plain) {
      return $this->base64Encode($value);
    }
    return $value;
  }

  /**
   * Decodes a parameter.
   *
   * @param string $param
   *   The parameter.
   *
   * @return array
   *   The decoded parameter.
   */
  private function decodeParameter(string $param): array {
    $values = explode(self::CRLF, $param);
    $vars = [];
    foreach ($values as $value) {
      if (!empty($value)) {
        $parts = explode('=', $value);
        $k = array_shift($parts);
        $vars[$k] = implode('=', $parts);
      }
    }
    return $vars;
  }

  /**
   * Validates the identity entity.
   *
   * @param \Drupal\sqrl\Entity\IdentityInterface $identity
   *   The identity entity.
   *
   * @throws \Drupal\sqrl\Exception\ClientException
   */
  private function validateIdentity(IdentityInterface $identity): void {
    if (!$identity->isEnabled()) {
      $this->tif |= self::FLAG_SQRL_DISABLED;
      $this->setResponse('suk', $identity->getSuk());
    }
    if ($identity->hasSuccessor()) {
      $this->tif |= self::FLAG_ID_SUPERSEDED;
      if ($this->clientVars['cmd'] !== 'query') {
        $this->tif |= self::FLAG_COMMAND_FAILURE;
      }
      throw new ClientException('SQRL identity is out of date');
    }
  }

  /**
   * Searches for an account.
   *
   * @throws \Drupal\sqrl\Exception\ClientException
   */
  private function findAccount(): void {
    if ($identity = $this->identities->getIdentityByIdk()) {
      $this->tif |= self::FLAG_IDK_MATCH;
      $this->validateIdentity($identity);
    }
    elseif ($identity = $this->identities->getIdentityByPidk()) {
      $this->tif |= self::FLAG_PIDK_MATCH;
      $this->validateIdentity($identity);
    }
    else {
      return;
    }

    $sqrlonly = $this->getClientOpt('sqrlonly');
    $hardlock = $this->getClientOpt('hardlock');
    $changed = FALSE;
    if ($sqrlonly !== $identity->isSqrlOnly()) {
      if ($this->getClientVar('ins')) {
        $identity->setSqrlOnly($sqrlonly);
        $changed = TRUE;
      }
      else {
        $this->setResponse('sin', 'drupal');
      }
    }
    if ($hardlock !== $identity->isHardLocked()) {
      if ($this->getClientVar('ins')) {
        $identity->setHardLocked($hardlock);
        $changed = TRUE;
      }
      else {
        $this->setResponse('sin', 'drupal');
      }
    }
    if ($changed) {
      try {
        $identity->save();
        $this->identities->updatePassword($identity, $this->getClientVar('ins'), $sqrlonly || $hardlock);
      }
      catch (EntityStorageException) {
        $this->log->error('Unable to save updated identity with changed sqrlonly or hardlock.');
      }
    }

    $this->identity = $identity;
    if ($uid = $this->nut->getClientUid()) {
      $op = $this->getNut()->getClientOperation();
      if ($op === 'link' && !$this->config->get('allow_multiple_accounts_per_id') && $identity->getUser($uid) === NULL) {
        $this->tif |= self::FLAG_BAD_ID_ASSOCIATION;
        throw new ClientException('This site does not allow multiple user accounts per ID');
      }
      if (in_array($op, ['link', 'unlink', 'profile']) !== FALSE) {
        $this->account = User::load($uid);
      }
      elseif ($account = $this->identity->getUser($uid)) {
        $this->account = $account;
      }
      else {
        $this->tif |= self::FLAG_BAD_ID_ASSOCIATION;
        throw new ClientException('SQRL ID does not match logged in user');
      }
    }
  }

  /**
   * Executes a command.
   *
   * @param string $command
   *   The command.
   */
  private function executeCommand(string $command): void {
    $this->log->debug('Execute START: ' . $this->getNut()->getPublicNut());
    $this->log->debug('Client operation: ' . $this->getNut()->getClientOperation());
    try {
      $this->log->debug('Execute ' . $command);
      /** @var \Drupal\sqrl\SqrlActionInterface $plugin */
      $plugin = $this->actionPluginManager->createInstance($command);
      if ($plugin->requiresSignatureRevalidation()) {
        $this->validateSignatures();
      }
      if ($plugin->run()) {
        $this->log->debug('Command successful');
        $plugin->rememberSuccess();
        $this->setResponse('url', $this->sqrl->getPath('sqrl.cps.url.login', ['token' => $this->getNut()->getLoginToken()]));
      }
    }
    catch (\Exception $e) {
      if ($e instanceof PluginException) {
        $this->tif |= self::FLAG_FUNCTION_NOT_SUPPORTED;
      }
      $this->tif |= self::FLAG_COMMAND_FAILURE;
      $this->log->error($e->getMessage());
    }
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc