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());
}
}
}
