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_webauthn;
  18  
  19  defined('MOODLE_INTERNAL') || die();
  20  
  21  require_once($CFG->libdir . '/webauthn/src/WebAuthn.php');
  22  
  23  use lbuchs\WebAuthn\Binary\ByteBuffer;
  24  use lbuchs\WebAuthn\WebAuthn;
  25  use lbuchs\WebAuthn\WebAuthnException;
  26  use stdClass;
  27  use tool_mfa\local\factor\object_factor_base;
  28  
  29  /**
  30   * WebAuthn factor class.
  31   *
  32   * @package     factor_webauthn
  33   * @author      Alex Morris <alex.morris@catalyst.net.nz>
  34   * @copyright   Catalyst IT
  35   * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  36   */
  37  class factor extends object_factor_base {
  38  
  39      /** @var WebAuthn WebAuthn server */
  40      private $webauthn;
  41      /** @var string Relying party ID */
  42      private $rpid;
  43      /** @var string User verification setting */
  44      private $userverification;
  45  
  46      /** @var string Factor icon */
  47      protected $icon = 'fa-hand-pointer';
  48  
  49      /**
  50       * Create webauthn server.
  51       *
  52       * @param string $name
  53       */
  54      public function __construct($name) {
  55          global $CFG, $SITE;
  56          parent::__construct($name);
  57  
  58          $this->rpid = (new \moodle_url($CFG->wwwroot))->get_host();
  59          $this->webauthn = new WebAuthn($SITE->fullname, $this->rpid);
  60  
  61          $this->userverification = get_config('factor_webauthn', 'userverification');
  62      }
  63  
  64      /**
  65       * WebAuthn Factor implementation.
  66       *
  67       * @param stdClass $user the user to check against.
  68       * @return array
  69       */
  70      public function get_all_user_factors(stdClass $user): array {
  71          global $DB;
  72          return $DB->get_records('tool_mfa', ['userid' => $user->id, 'factor' => $this->name]);
  73      }
  74  
  75      /**
  76       * WebAuthn Factor implementation.
  77       *
  78       * {@inheritDoc}
  79       */
  80      public function has_input(): bool {
  81          return true;
  82      }
  83  
  84      /**
  85       * WebAuthn Factor implementation.
  86       *
  87       * {@inheritDoc}
  88       */
  89      public function has_revoke(): bool {
  90          return true;
  91      }
  92  
  93      /**
  94       * WebAuthn Factor implementation.
  95       *
  96       * {@inheritDoc}
  97       */
  98      public function has_setup(): bool {
  99          return true;
 100      }
 101  
 102      /**
 103       * WebAuthn Factor implementation.
 104       *
 105       * {@inheritDoc}
 106       */
 107      public function show_setup_buttons(): bool {
 108          return true;
 109      }
 110  
 111      /**
 112       * WebAuthn factor implementation.
 113       *
 114       * @param stdClass $user
 115       * @return array
 116       */
 117      public function possible_states(stdClass $user): array {
 118          return [
 119              \tool_mfa\plugininfo\factor::STATE_PASS,
 120              \tool_mfa\plugininfo\factor::STATE_NEUTRAL,
 121              \tool_mfa\plugininfo\factor::STATE_UNKNOWN,
 122          ];
 123      }
 124  
 125      /**
 126       * WebAuthn state
 127       *
 128       * {@inheritDoc}
 129       */
 130      public function get_state(): string {
 131          global $USER;
 132          $userfactors = $this->get_active_user_factors($USER);
 133  
 134          // If no authenticators are set up then we are neutral not unknown.
 135          if (count($userfactors) == 0) {
 136              return \tool_mfa\plugininfo\factor::STATE_NEUTRAL;
 137          }
 138  
 139          return parent::get_state();
 140      }
 141  
 142      /**
 143       * Gets the string for setup button on preferences page.
 144       *
 145       * @return string
 146       */
 147      public function get_setup_string(): string {
 148          return get_string('setupfactor', 'factor_webauthn');
 149      }
 150  
 151      /**
 152       * WebAuthn Factor implementation.
 153       *
 154       * @param \MoodleQuickForm $mform
 155       * @return \MoodleQuickForm $mform
 156       */
 157      public function login_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm {
 158          global $PAGE, $USER, $SESSION;
 159  
 160          $mform->addElement('hidden', 'response_input', '', ['id' => 'id_response_input']);
 161          $mform->setType('response_input', PARAM_RAW);
 162  
 163          // Required to attach verification errors, so they can be displayed to the user.
 164          $mform->addElement('static', 'verificationcode', '', '');
 165  
 166          $ids = [];
 167  
 168          $authenticators = $this->get_active_user_factors($USER);
 169          foreach ($authenticators as $authenticator) {
 170              $registration = json_decode($authenticator->secret);
 171              $ids[] = base64_decode($registration->credentialId);
 172          }
 173  
 174          $types = explode(',', get_config('factor_webauthn', 'authenticatortypes'));
 175          $getargs =
 176              $this->webauthn->getGetArgs($ids, 20, in_array('usb', $types), in_array('nfc', $types), in_array('ble', $types),
 177                  in_array('hybrid', $types), in_array('internal', $types), $this->userverification);
 178  
 179          $PAGE->requires->js_call_amd('factor_webauthn/login', 'init', [json_encode($getargs)]);
 180  
 181          // Challenge is regenerated on form submission, at this point we aren't aware if the form is submitted for being
 182          // loaded for the first time, so we store the existing and new challenge.
 183          if (isset($SESSION->factor_webauthn_challenge_new)) {
 184              $SESSION->factor_webauthn_challenge = $SESSION->factor_webauthn_challenge_new;
 185          }
 186          $SESSION->factor_webauthn_challenge_new = $this->webauthn->getChallenge()->getHex();
 187  
 188          return $mform;
 189      }
 190  
 191      /**
 192       * WebAuthn Factor implementation.
 193       *
 194       * @param array $data
 195       * @return array
 196       */
 197      public function login_form_validation(array $data): array {
 198          global $USER, $SESSION;
 199  
 200          $errors = [];
 201          if (empty($data['response_input'])) {
 202              $errors['verificationcode'] = get_string('error', 'factor_webauthn');
 203              return $errors;
 204          }
 205  
 206          $post = json_decode($data['response_input'], null, 512, JSON_THROW_ON_ERROR);
 207  
 208          $id = base64_decode($post->id);
 209          $clientdata = base64_decode($post->clientDataJSON);
 210          $authenticatordata = base64_decode($post->authenticatorData);
 211          $signature = base64_decode($post->signature);
 212          $credentialpublickey = null;
 213          $challenge = ByteBuffer::fromHex($SESSION->factor_webauthn_challenge);
 214          unset($SESSION->factor_webauthn_challenge);
 215  
 216          $authenticators = $this->get_active_user_factors($USER);
 217          foreach ($authenticators as $authenticator) {
 218              $registration = json_decode($authenticator->secret);
 219              if (base64_decode($registration->credentialId) === $id) {
 220                  $credentialpublickey = $registration->credentialPublicKey;
 221                  break;
 222              }
 223          }
 224  
 225          if ($credentialpublickey === null) {
 226              $errors['verificationcode'] = get_string('error', 'factor_webauthn');
 227              return $errors;
 228          }
 229  
 230          try {
 231              // Throws exception if authentication fails.
 232              $this->webauthn->processGet($clientdata, $authenticatordata, $signature, $credentialpublickey, $challenge, null,
 233                  $this->userverification === 'required');
 234          } catch (WebAuthnException $ex) {
 235              $errors['verificationcode'] = get_string('error', 'factor_webauthn');
 236          }
 237  
 238          return $errors;
 239      }
 240  
 241      /**
 242       * WebAuthn Factor implementation.
 243       *
 244       * @param \MoodleQuickForm $mform
 245       * @return object $mform
 246       */
 247      public function setup_factor_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm {
 248          global $PAGE, $USER, $SESSION;
 249  
 250          $mform->addElement('text', 'webauthn_name', get_string('authenticatorname', 'factor_webauthn'));
 251          $mform->setType('webauthn_name', PARAM_ALPHANUM);
 252          $mform->addRule('webauthn_name', get_string('required'), 'required', null, 'client');
 253  
 254          $registerbtn = \html_writer::tag('btn', get_string('register', 'factor_webauthn'), [
 255              'class' => 'btn btn-primary',
 256              'type' => 'button',
 257              'id' => 'factor_webauthn-register'
 258          ]);
 259          $mform->addElement('static', 'register', '', $registerbtn);
 260  
 261          $mform->addElement('hidden', 'response_input', '', ['id' => 'id_response_input']);
 262          $mform->setType('response_input', PARAM_RAW);
 263          $mform->addRule('response_input', get_string('required'), 'required', null, 'client');
 264  
 265          // Cross-platform: true if type internal is not allowed,
 266          // false if only internal is allowed,
 267          // null if internal and cross-platform is allowed.
 268          $types = explode(',', get_config('factor_webauthn', 'authenticatortypes'));
 269          $crossplatformattachment = null;
 270          if ((in_array('usb', $types) || in_array('nfc', $types) || in_array('ble', $types) || in_array('hybrid', $types)) &&
 271              !in_array('internal', $types)) {
 272              $crossplatformattachment = true;
 273          } else if (!in_array('usb', $types) && !in_array('nfc', $types) && !in_array('ble', $types) &&
 274              !in_array('hybrid', $types) && in_array('internal', $types)) {
 275              $crossplatformattachment = false;
 276          }
 277  
 278          $createargs = $this->webauthn->getCreateArgs($USER->id, $USER->username, fullname($USER), 20, false,
 279              $this->userverification, $crossplatformattachment);
 280  
 281          $PAGE->requires->js_call_amd('factor_webauthn/register', 'init', [json_encode($createargs)]);
 282  
 283          // Challenge is regenerated on form submission, at this point we aren't aware if the form is submitted for being
 284          // loaded for the first time, so we store the existing and new challenge.
 285          if (isset($SESSION->factor_webauthn_challenge_new)) {
 286              $SESSION->factor_webauthn_challenge = $SESSION->factor_webauthn_challenge_new;
 287          }
 288          $SESSION->factor_webauthn_challenge_new = $this->webauthn->getChallenge()->getHex();
 289  
 290          return $mform;
 291      }
 292  
 293      /**
 294       * WebAuthn Factor implementation.
 295       *
 296       * @param object $data
 297       * @return stdClass|null
 298       */
 299      public function setup_user_factor(object $data): stdClass|null {
 300          global $DB, $USER, $SESSION;
 301  
 302          if (!empty($data->webauthn_name) && !empty($data->response_input) && isset($SESSION->factor_webauthn_challenge)) {
 303              $post = json_decode($data->response_input, null, 512, JSON_THROW_ON_ERROR);
 304  
 305              $clientdata = base64_decode($post->clientDataJSON);
 306              $attestationobject = base64_decode($post->attestationObject);
 307              $challenge = ByteBuffer::fromHex($SESSION->factor_webauthn_challenge);
 308              unset($SESSION->factor_webauthn_challenge);
 309  
 310              $registration =
 311                  $this->webauthn->processCreate($clientdata, $attestationobject, $challenge, $this->userverification === 'required',
 312                      true, false);
 313              $registration->credentialId = base64_encode($registration->credentialId);
 314              $registration->AAGUID = base64_encode($registration->AAGUID);
 315              unset($registration->certificate);
 316  
 317              $row = new \stdClass();
 318              $row->userid = $USER->id;
 319              $row->factor = $this->name;
 320              $row->label = $data->webauthn_name;
 321              $row->secret = json_encode($registration);
 322              $row->timecreated = time();
 323              $row->createdfromip = $USER->lastip;
 324              $row->timemodified = time();
 325              $row->lastverified = time();
 326              $row->revoked = 0;
 327  
 328              $id = $DB->insert_record('tool_mfa', $row);
 329  
 330              $record = $DB->get_record('tool_mfa', array('id' => $id));
 331              $this->create_event_after_factor_setup($USER);
 332  
 333              return $record;
 334          }
 335          return null;
 336      }
 337  
 338  }