Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.
   1  <?php
   2  
   3  declare(strict_types=1);
   4  
   5  /*
   6   * The MIT License (MIT)
   7   *
   8   * Copyright (c) 2014-2018 Spomky-Labs
   9   *
  10   * This software may be modified and distributed under the terms
  11   * of the MIT license.  See the LICENSE file for details.
  12   */
  13  
  14  namespace OTPHP;
  15  
  16  use Assert\Assertion;
  17  use ParagonIE\ConstantTime\Base32;
  18  
  19  abstract class OTP implements OTPInterface
  20  {
  21      use ParameterTrait;
  22  
  23      /**
  24       * OTP constructor.
  25       *
  26       * @param string|null $secret
  27       * @param string      $digest
  28       * @param int         $digits
  29       */
  30      protected function __construct($secret, string $digest, int $digits)
  31      {
  32          $this->setSecret($secret);
  33          $this->setDigest($digest);
  34          $this->setDigits($digits);
  35      }
  36  
  37      /**
  38       * {@inheritdoc}
  39       */
  40      public function getQrCodeUri(string $uri = 'https://chart.googleapis.com/chart?chs=200x200&chld=M|0&cht=qr&chl={PROVISIONING_URI}', string $placeholder = '{PROVISIONING_URI}'): string
  41      {
  42          $provisioning_uri = urlencode($this->getProvisioningUri());
  43  
  44          return str_replace($placeholder, $provisioning_uri, $uri);
  45      }
  46  
  47      /**
  48       * @param int $input
  49       *
  50       * @return string The OTP at the specified input
  51       */
  52      protected function generateOTP(int $input): string
  53      {
  54          $hash = hash_hmac($this->getDigest(), $this->intToByteString($input), $this->getDecodedSecret());
  55          $hmac = [];
  56          foreach (str_split($hash, 2) as $hex) {
  57              $hmac[] = hexdec($hex);
  58          }
  59          $offset = $hmac[count($hmac) - 1] & 0xF;
  60          $code = ($hmac[$offset + 0] & 0x7F) << 24 | ($hmac[$offset + 1] & 0xFF) << 16 | ($hmac[$offset + 2] & 0xFF) << 8 | ($hmac[$offset + 3] & 0xFF);
  61          $otp = $code % pow(10, $this->getDigits());
  62  
  63          return str_pad((string) $otp, $this->getDigits(), '0', STR_PAD_LEFT);
  64      }
  65  
  66      /**
  67       * {@inheritdoc}
  68       */
  69      public function at(int $timestamp): string
  70      {
  71          return $this->generateOTP($timestamp);
  72      }
  73  
  74      /**
  75       * @param array $options
  76       */
  77      protected function filterOptions(array &$options)
  78      {
  79          foreach (['algorithm' => 'sha1', 'period' => 30, 'digits' => 6] as $key => $default) {
  80              if (isset($options[$key]) && $default === $options[$key]) {
  81                  unset($options[$key]);
  82              }
  83          }
  84  
  85          ksort($options);
  86      }
  87  
  88      /**
  89       * @param string $type
  90       * @param array  $options
  91       *
  92       * @return string
  93       */
  94      protected function generateURI(string $type, array $options): string
  95      {
  96          $label = $this->getLabel();
  97          Assertion::string($label, 'The label is not set.');
  98          Assertion::false($this->hasColon($label), 'Label must not contain a colon.');
  99          $options = array_merge($options, $this->getParameters());
 100          $this->filterOptions($options);
 101          $params = str_replace(['+', '%7E'], ['%20', '~'], http_build_query($options, '', '&'));
 102  
 103          return sprintf('otpauth://%s/%s?%s', $type, rawurlencode((null !== $this->getIssuer() ? $this->getIssuer().':' : '').$label), $params);
 104      }
 105  
 106      /**
 107       * @return string
 108       */
 109      private function getDecodedSecret(): string
 110      {
 111          try {
 112              $secret = Base32::decodeUpper($this->getSecret());
 113          } catch (\Exception $e) {
 114              throw new \RuntimeException('Unable to decode the secret. Is it correctly base32 encoded?');
 115          }
 116  
 117          return $secret;
 118      }
 119  
 120      /**
 121       * @param int $int
 122       *
 123       * @return string
 124       */
 125      private function intToByteString(int $int): string
 126      {
 127          $result = [];
 128          while (0 !== $int) {
 129              $result[] = chr($int & 0xFF);
 130              $int >>= 8;
 131          }
 132  
 133          return str_pad(implode(array_reverse($result)), 8, "\000", STR_PAD_LEFT);
 134      }
 135  
 136      /**
 137       * @param string $safe
 138       * @param string $user
 139       *
 140       * @return bool
 141       */
 142      protected function compareOTP(string $safe, string $user): bool
 143      {
 144          return hash_equals($safe, $user);
 145      }
 146  }