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  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  namespace factor_totp;
  18  
  19  defined('MOODLE_INTERNAL') || die();
  20  
  21  require_once($CFG->libdir.'/tcpdf/tcpdf_barcodes_2d.php');
  22  require_once (__DIR__.'/../extlib/OTPHP/OTPInterface.php');
  23  require_once (__DIR__.'/../extlib/OTPHP/TOTPInterface.php');
  24  require_once (__DIR__.'/../extlib/OTPHP/ParameterTrait.php');
  25  require_once (__DIR__.'/../extlib/OTPHP/OTP.php');
  26  require_once (__DIR__.'/../extlib/OTPHP/TOTP.php');
  27  
  28  require_once (__DIR__.'/../extlib/Assert/Assertion.php');
  29  require_once (__DIR__.'/../extlib/Assert/AssertionFailedException.php');
  30  require_once (__DIR__.'/../extlib/Assert/InvalidArgumentException.php');
  31  require_once (__DIR__.'/../extlib/ParagonIE/ConstantTime/EncoderInterface.php');
  32  require_once (__DIR__.'/../extlib/ParagonIE/ConstantTime/Binary.php');
  33  require_once (__DIR__.'/../extlib/ParagonIE/ConstantTime/Base32.php');
  34  
  35  use tool_mfa\local\factor\object_factor_base;
  36  use OTPHP\TOTP;
  37  use stdClass;
  38  
  39  /**
  40   * TOTP factor class.
  41   *
  42   * @package     factor_totp
  43   * @subpackage  tool_mfa
  44   * @author      Mikhail Golenkov <golenkovm@gmail.com>
  45   * @copyright   Catalyst IT
  46   * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  47   */
  48  class factor extends object_factor_base {
  49  
  50      /** @var string */
  51      const TOTP_OLD = 'old';
  52  
  53      /** @var string */
  54      const TOTP_FUTURE = 'future';
  55  
  56      /** @var string */
  57      const TOTP_USED = 'used';
  58  
  59      /** @var string */
  60      const TOTP_VALID = 'valid';
  61  
  62      /** @var string */
  63      const TOTP_INVALID = 'invalid';
  64  
  65      /** @var string Factor icon */
  66      protected $icon = 'fa-mobile-screen';
  67  
  68  
  69      /**
  70       * Generates TOTP URI for given secret key.
  71       * Uses site name, hostname and user name to make GA account look like:
  72       * "Sitename hostname (username)".
  73       *
  74       * @param string $secret
  75       * @return string
  76       */
  77      public function generate_totp_uri(string $secret): string {
  78          global $USER, $SITE, $CFG;
  79          $host = parse_url($CFG->wwwroot, PHP_URL_HOST);
  80          $sitename = str_replace(':', '', $SITE->fullname);
  81          $issuer = $sitename.' '.$host;
  82          $totp = TOTP::create($secret);
  83          $totp->setLabel($USER->username);
  84          $totp->setIssuer($issuer);
  85          return $totp->getProvisioningUri();
  86      }
  87  
  88      /**
  89       * Generates HTML sting with QR code for given secret key.
  90       *
  91       * @param string $secret
  92       * @return string
  93       */
  94      public function generate_qrcode(string $secret): string {
  95          $uri = $this->generate_totp_uri($secret);
  96          $qrcode = new \TCPDF2DBarcode($uri, 'QRCODE');
  97          $image = $qrcode->getBarcodePngData(7, 7);
  98          $html = \html_writer::tag('p', get_string('setupfactor:scanwithapp', 'factor_totp'));
  99          $html .= \html_writer::img('data:image/png;base64,' . base64_encode($image), '', ['width' => '150px']);
 100          return $html;
 101      }
 102  
 103      /**
 104       * TOTP state
 105       *
 106       * {@inheritDoc}
 107       */
 108      public function get_state(): string {
 109          global $USER;
 110          $userfactors = $this->get_active_user_factors($USER);
 111  
 112          // If no codes are setup then we must be neutral not unknown.
 113          if (count($userfactors) == 0) {
 114              return \tool_mfa\plugininfo\factor::STATE_NEUTRAL;
 115          }
 116  
 117          return parent::get_state();
 118      }
 119  
 120      /**
 121       * TOTP Factor implementation.
 122       *
 123       * @param \MoodleQuickForm $mform
 124       * @return \MoodleQuickForm $mform
 125       */
 126      public function setup_factor_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm {
 127          $secret = $this->generate_secret_code();
 128          $mform->addElement('hidden', 'secret', $secret);
 129          $mform->setType('secret', PARAM_ALPHANUM);
 130  
 131          return $mform;
 132      }
 133  
 134      /**
 135       * TOTP Factor implementation.
 136       *
 137       * @param \MoodleQuickForm $mform
 138       * @return \MoodleQuickForm $mform
 139       */
 140      public function setup_factor_form_definition_after_data(\MoodleQuickForm $mform): \MoodleQuickForm {
 141          global $OUTPUT, $SITE, $USER;
 142  
 143          // Array of elements to allow XSS.
 144          $xssallowedelements = [];
 145  
 146          $mform->addElement('html', $OUTPUT->heading(get_string('setupfactor', 'factor_totp'), 2));
 147          $mform->addElement('html', \html_writer::tag('p', get_string('info', 'factor_totp')));
 148          $mform->addElement('html', \html_writer::tag('hr', ''));
 149  
 150          $mform->addElement('text', 'devicename', get_string('devicename', 'factor_totp'), [
 151              'placeholder' => get_string('devicenameexample', 'factor_totp'),
 152              'autofocus' => 'autofocus',
 153          ]);
 154          $mform->addHelpButton('devicename', 'devicename', 'factor_totp');
 155          $mform->setType('devicename', PARAM_TEXT);
 156          $mform->addRule('devicename', get_string('required'), 'required', null, 'client');
 157  
 158          // Scan.
 159          $secretfield = $mform->getElement('secret');
 160          $secret = $secretfield->getValue();
 161          $qrcode = $this->generate_qrcode($secret);
 162  
 163          $html = \html_writer::tag('p', $qrcode);
 164          $xssallowedelements[] = $mform->addElement('static', 'scan', get_string('setupfactor:scan', 'factor_totp'), $html);
 165  
 166          // Link.
 167          if (get_config('factor_totp', 'totplink')) {
 168              $uri = $this->generate_totp_uri($secret);
 169              $html = $OUTPUT->action_link($uri, get_string('setupfactor:linklabel', 'factor_totp'));
 170              $xssallowedelements[] = $mform->addElement('static', 'link', get_string('setupfactor:link', 'factor_totp'), $html);
 171              $mform->addHelpButton('link', 'setupfactor:link', 'factor_totp');
 172          }
 173  
 174          // Enter manually.
 175          $secret = wordwrap($secret, 4, ' ', true) . '</code>';
 176          $secret = \html_writer::tag('code', $secret);
 177  
 178          $manualtable = new \html_table();
 179          $manualtable->id = 'manualattributes';
 180          $manualtable->attributes['class'] = 'generaltable table table-bordered table-sm w-auto';
 181          $manualtable->attributes['style'] = 'width: auto;';
 182          $manualtable->data = [
 183              [get_string('setupfactor:key', 'factor_totp'), $secret],
 184              [get_string('setupfactor:account', 'factor_totp'), "$SITE->fullname ($USER->username)"],
 185              [get_string('setupfactor:mode', 'factor_totp'), get_string('setupfactor:mode:timebased', 'factor_totp')],
 186          ];
 187  
 188          $html = \html_writer::table($manualtable);
 189          $html = \html_writer::tag('p', get_string('setupfactor:enter', 'factor_totp')) . $html;
 190          // Wrap the table in a couple of divs to be controlled via bootstrap.
 191          $html = \html_writer::div($html, 'card card-body', ['style' => 'padding-left: 0 !important;']);
 192          $html = \html_writer::div($html, 'collapse', ['id' => 'collapseManualAttributes']);
 193  
 194          $togglelink = \html_writer::tag('btn', get_string('setupfactor:scanfail', 'factor_totp'), [
 195              'class' => 'btn btn-secondary',
 196              'type' => 'button',
 197              'data-toggle' => 'collapse',
 198              'data-target' => '#collapseManualAttributes',
 199              'aria-expanded' => 'false',
 200              'aria-controls' => 'collapseManualAttributes',
 201              'style' => 'font-size: 14px;',
 202          ]);
 203  
 204          $html = $togglelink . $html;
 205          $xssallowedelements[] = $mform->addElement('static', 'enter', '', $html);
 206  
 207          // Allow XSS.
 208          if (method_exists('MoodleQuickForm_static', 'set_allow_xss')) {
 209              foreach ($xssallowedelements as $xssallowedelement) {
 210                  $xssallowedelement->set_allow_xss(true);
 211              }
 212          }
 213  
 214          $mform->addElement(new \tool_mfa\local\form\verification_field(null, false));
 215          $mform->setType('verificationcode', PARAM_ALPHANUM);
 216          $mform->addHelpButton('verificationcode', 'verificationcode', 'factor_totp');
 217          $mform->addRule('verificationcode', get_string('required'), 'required', null, 'client');
 218  
 219          return $mform;
 220      }
 221  
 222      /**
 223       * TOTP Factor implementation.
 224       *
 225       * @param array $data
 226       * @return array
 227       */
 228      public function setup_factor_form_validation(array $data): array {
 229          $errors = [];
 230  
 231          $totp = TOTP::create($data['secret']);
 232          if (!$totp->verify($data['verificationcode'], time(), 1)) {
 233              $errors['verificationcode'] = get_string('error:wrongverification', 'factor_totp');
 234          }
 235  
 236          return $errors;
 237      }
 238  
 239      /**
 240       * TOTP Factor implementation.
 241       *
 242       * @param \MoodleQuickForm $mform
 243       * @return \MoodleQuickForm $mform
 244       */
 245      public function login_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm {
 246  
 247          $mform->disable_form_change_checker();
 248          $mform->addElement(new \tool_mfa\local\form\verification_field());
 249          $mform->setType('verificationcode', PARAM_ALPHANUM);
 250  
 251          return $mform;
 252      }
 253  
 254      /**
 255       * TOTP Factor implementation.
 256       *
 257       * @param array $data
 258       * @return array
 259       */
 260      public function login_form_validation(array $data): array {
 261          global $USER;
 262          $factors = $this->get_active_user_factors($USER);
 263          $result = ['verificationcode' => get_string('error:wrongverification', 'factor_totp')];
 264          $windowconfig = get_config('factor_totp', 'window');
 265  
 266          foreach ($factors as $factor) {
 267              $totp = TOTP::create($factor->secret);
 268              // Convert seconds to windows.
 269              $window = (int) floor($windowconfig / $totp->getPeriod());
 270              $factorresult = $this->validate_code($data['verificationcode'], $window, $totp, $factor);
 271              $time = userdate(time(), get_string('systimeformat', 'factor_totp'));
 272  
 273              switch ($factorresult) {
 274                  case self::TOTP_USED:
 275                      return ['verificationcode' => get_string('error:codealreadyused', 'factor_totp')];
 276  
 277                  case self::TOTP_OLD:
 278                      return ['verificationcode' => get_string('error:oldcode', 'factor_totp', $time)];
 279  
 280                  case self::TOTP_FUTURE:
 281                      return ['verificationcode' => get_string('error:futurecode', 'factor_totp', $time)];
 282  
 283                  case self::TOTP_VALID:
 284                      $this->update_lastverified($factor->id);
 285                      return [];
 286  
 287                  default:
 288                      continue(2);
 289              }
 290          }
 291          return $result;
 292      }
 293  
 294      /**
 295       * Checks the code for reuse, clock skew, and validity.
 296       *
 297       * @param string $code the code to check.
 298       * @param int $window the window to check validity for.
 299       * @param TOTP $totp the totp object to check against.
 300       * @param stdClass $factor the factor with information required.
 301       *
 302       * @return string constant with verification state.
 303       */
 304      public function validate_code(string $code, int $window, TOTP $totp, stdClass $factor): string {
 305          // First check if this code matches the last verified timestamp.
 306          $lastverified = $this->get_lastverified($factor->id);
 307          if ($lastverified > 0 && $totp->verify($code, $lastverified, $window)) {
 308              return self::TOTP_USED;
 309          }
 310  
 311          // The window in which to check for clock skew, 5 increments past valid window.
 312          $skewwindow = $window + 5;
 313          $pasttimestamp = time() - ($skewwindow * $totp->getPeriod());
 314          $futuretimestamp = time() + ($skewwindow * $totp->getPeriod());
 315  
 316          if ($totp->verify($code, time(), $window)) {
 317              return self::TOTP_VALID;
 318          } else if ($totp->verify($code, $pasttimestamp, $skewwindow)) {
 319              // Check for clock skew in the past 10 periods.
 320              return self::TOTP_OLD;
 321          } else if ($totp->verify($code, $futuretimestamp, $skewwindow)) {
 322              // Check for clock skew in the future 10 periods.
 323              return self::TOTP_FUTURE;
 324          } else {
 325              // In all other cases, code is invalid.
 326              return self::TOTP_INVALID;
 327          }
 328      }
 329  
 330      /**
 331       * Generates cryptographically secure pseudo-random 16-digit secret code.
 332       *
 333       * @return string
 334       */
 335      public function generate_secret_code(): string {
 336          $totp = TOTP::create();
 337          return substr($totp->getSecret(), 0, 16);
 338      }
 339  
 340      /**
 341       * TOTP Factor implementation.
 342       *
 343       * @param stdClass $data
 344       * @return stdClass the factor record, or null.
 345       */
 346      public function setup_user_factor(stdClass $data): stdClass|null {
 347          global $DB, $USER;
 348  
 349          if (!empty($data->secret)) {
 350              $row = new stdClass();
 351              $row->userid = $USER->id;
 352              $row->factor = $this->name;
 353              $row->secret = $data->secret;
 354              $row->label = $data->devicename;
 355              $row->timecreated = time();
 356              $row->createdfromip = $USER->lastip;
 357              $row->timemodified = time();
 358              $row->lastverified = 0;
 359              $row->revoked = 0;
 360  
 361              // Check if a record with this configuration already exists, warning the user accordingly.
 362              $record = $DB->get_record('tool_mfa', [
 363                  'userid' => $row->userid,
 364                  'secret' => $row->secret,
 365                  'factor' => $row->factor,
 366              ], '*', IGNORE_MULTIPLE);
 367              if ($record) {
 368                  \core\notification::warning(get_string('error:alreadyregistered', 'factor_totp'));
 369                  return $record;
 370              }
 371  
 372              $id = $DB->insert_record('tool_mfa', $row);
 373              $record = $DB->get_record('tool_mfa', ['id' => $id]);
 374              $this->create_event_after_factor_setup($USER);
 375  
 376              return $record;
 377          }
 378  
 379          return null;
 380      }
 381  
 382      /**
 383       * TOTP Factor implementation.
 384       *
 385       * @param stdClass $user the user to check against.
 386       * @return array
 387       */
 388      public function get_all_user_factors($user): array {
 389          global $DB;
 390          return $DB->get_records('tool_mfa', ['userid' => $user->id, 'factor' => $this->name]);
 391      }
 392  
 393      /**
 394       * TOTP Factor implementation.
 395       *
 396       * {@inheritDoc}
 397       */
 398      public function has_revoke(): bool {
 399          return true;
 400      }
 401  
 402      /**
 403       * TOTP Factor implementation.
 404       *
 405       * {@inheritDoc}
 406       */
 407      public function has_setup(): bool {
 408          return true;
 409      }
 410  
 411      /**
 412       * TOTP Factor implementation
 413       *
 414       * {@inheritDoc}
 415       */
 416      public function show_setup_buttons(): bool {
 417          return true;
 418      }
 419  
 420      /**
 421       * TOTP Factor implementation.
 422       * Empty override of parent.
 423       *
 424       * {@inheritDoc}
 425       */
 426      public function post_pass_state(): void {
 427          return;
 428      }
 429  
 430      /**
 431       * TOTP Factor implementation.
 432       * TOTP cannot return fail state.
 433       *
 434       * @param stdClass $user
 435       */
 436      public function possible_states(stdClass $user): array {
 437          return [
 438              \tool_mfa\plugininfo\factor::STATE_PASS,
 439              \tool_mfa\plugininfo\factor::STATE_NEUTRAL,
 440              \tool_mfa\plugininfo\factor::STATE_UNKNOWN,
 441          ];
 442      }
 443  
 444      /**
 445       * TOTP Factor implementation.
 446       *
 447       * {@inheritDoc}
 448       */
 449      public function get_setup_string(): string {
 450          return get_string('factorsetup', 'factor_totp');
 451      }
 452  }