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 tool_mfa\local\factor;
  18  
  19  use stdClass;
  20  
  21  /**
  22   * MFA factor abstract class.
  23   *
  24   * @package     tool_mfa
  25   * @author      Mikhail Golenkov <golenkovm@gmail.com>
  26   * @copyright   Catalyst IT
  27   * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  28   */
  29  abstract class object_factor_base implements object_factor {
  30  
  31      /** @var string Factor name */
  32      public $name;
  33  
  34      /** @var int Lock counter */
  35      private $lockcounter;
  36  
  37      /**
  38       * Secret manager
  39       *
  40       * @var \tool_mfa\local\secret_manager
  41       */
  42      protected $secretmanager;
  43  
  44      /** @var string Factor icon */
  45      protected $icon = 'fa-lock';
  46  
  47      /**
  48       * Class constructor
  49       *
  50       * @param string $name factor name
  51       */
  52      public function __construct($name) {
  53          global $DB, $USER;
  54          $this->name = $name;
  55  
  56          // Setup secret manager.
  57          $this->secretmanager = new \tool_mfa\local\secret_manager($this->name);
  58      }
  59  
  60      /**
  61       * This loads the locked state from the DB
  62       * Base class implementation.
  63       *
  64       * @return void
  65       */
  66      public function load_locked_state(): void {
  67          global $DB, $USER;
  68  
  69          // Check if lockcounter column exists (incase upgrade hasnt run yet).
  70          // Only 'input factors' are lockable.
  71          if ($this->is_enabled() && $this->is_lockable()) {
  72              try {
  73                  // Setup the lock counter.
  74                  $sql = "SELECT MAX(lockcounter) FROM {tool_mfa} WHERE userid = ? AND factor = ? AND revoked = ?";
  75                  @$this->lockcounter = $DB->get_field_sql($sql, [$USER->id, $this->name, 0]);
  76  
  77                  if (empty($this->lockcounter)) {
  78                      $this->lockcounter = 0;
  79                  }
  80  
  81                  // Now lock this factor if over the counter.
  82                  $lockthreshold = get_config('tool_mfa', 'lockout');
  83                  if ($this->lockcounter >= $lockthreshold) {
  84                      $this->set_state(\tool_mfa\plugininfo\factor::STATE_LOCKED);
  85                  }
  86              } catch (\dml_exception $e) {
  87                  // Set counter to -1.
  88                  $this->lockcounter = -1;
  89              }
  90          }
  91      }
  92  
  93      /**
  94       * Returns true if factor is enabled, otherwise false.
  95       *
  96       * Base class implementation.
  97       *
  98       * @return bool
  99       * @throws \dml_exception
 100       */
 101      public function is_enabled(): bool {
 102          $status = get_config('factor_'.$this->name, 'enabled');
 103          if ($status == 1) {
 104              return true;
 105          }
 106          return false;
 107      }
 108  
 109      /**
 110       * Returns configured factor weight.
 111       *
 112       * Base class implementation.
 113       *
 114       * @return int
 115       * @throws \dml_exception
 116       */
 117      public function get_weight(): int {
 118          $weight = get_config('factor_'.$this->name, 'weight');
 119          if ($weight) {
 120              return (int) $weight;
 121          }
 122          return 0;
 123      }
 124  
 125      /**
 126       * Returns factor name from language string.
 127       *
 128       * Base class implementation.
 129       *
 130       * @return string
 131       * @throws \coding_exception
 132       */
 133      public function get_display_name(): string {
 134          return get_string('pluginname', 'factor_'.$this->name);
 135      }
 136  
 137      /**
 138       * Returns factor help from language string.
 139       *
 140       * Base class implementation.
 141       *
 142       * @return string
 143       * @throws \coding_exception
 144       */
 145      public function get_info(): string {
 146          return get_string('info', 'factor_'.$this->name);
 147      }
 148  
 149      /**
 150       * Defines setup_factor form definition page for particular factor.
 151       *
 152       * Dummy implementation. Should be overridden in child class.
 153       *
 154       * @param \MoodleQuickForm $mform
 155       * @return object $mform
 156       */
 157      public function setup_factor_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm {
 158          return $mform;
 159      }
 160  
 161      /**
 162       * Defines setup_factor form definition page after form data has been set.
 163       *
 164       * Dummy implementation. Should be overridden in child class.
 165       *
 166       * @param \MoodleQuickForm $mform
 167       * @return object $mform
 168       */
 169      public function setup_factor_form_definition_after_data(\MoodleQuickForm $mform): \MoodleQuickForm {
 170          return $mform;
 171      }
 172  
 173      /**
 174       * Implements setup_factor form validation for particular factor.
 175       * Returns an array of errors, where array key = field id and array value = error text.
 176       *
 177       * Dummy implementation. Should be overridden in child class.
 178       *
 179       * @param array $data
 180       * @return array
 181       */
 182      public function setup_factor_form_validation(array $data): array {
 183          return [];
 184      }
 185  
 186      /**
 187       * Setups given factor and adds it to user's active factors list.
 188       * Returns true if factor has been successfully added, otherwise false.
 189       *
 190       * Dummy implementation. Should be overridden in child class.
 191       *
 192       * @param stdClass $data
 193       * @return stdClass|null the record if created, or null.
 194       */
 195      public function setup_user_factor(stdClass $data): stdClass|null {
 196          return null;
 197      }
 198  
 199      /**
 200       * Returns an array of all user factors of given type (both active and revoked).
 201       *
 202       * Dummy implementation. Should be overridden in child class.
 203       *
 204       * @param stdClass $user the user to check against.
 205       * @return array
 206       */
 207      public function get_all_user_factors(stdClass $user): array {
 208          return [];
 209      }
 210  
 211      /**
 212       * Returns an array of active user factor records.
 213       * Filters get_all_user_factors() output.
 214       *
 215       * @param stdClass $user object to check against.
 216       * @return array
 217       */
 218      public function get_active_user_factors(stdClass $user): array {
 219          $return = [];
 220          $factors = $this->get_all_user_factors($user);
 221          foreach ($factors as $factor) {
 222              if ($factor->revoked == 0) {
 223                  $return[] = $factor;
 224              }
 225          }
 226          return $return;
 227      }
 228  
 229      /**
 230       * Defines login form definition page for particular factor.
 231       *
 232       * Dummy implementation. Should be overridden in child class.
 233       *
 234       * @param \MoodleQuickForm $mform
 235       * @return object $mform
 236       */
 237      public function login_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm {
 238          return $mform;
 239      }
 240  
 241      /**
 242       * Defines login form definition page after form data has been set.
 243       *
 244       * Dummy implementation. Should be overridden in child class.
 245       *
 246       * @param \MoodleQuickForm $mform
 247       * @return object $mform
 248       */
 249      public function login_form_definition_after_data(\MoodleQuickForm $mform): \MoodleQuickForm {
 250          return $mform;
 251      }
 252  
 253      /**
 254       * Implements login form validation for particular factor.
 255       * Returns an array of errors, where array key = field id and array value = error text.
 256       *
 257       * Dummy implementation. Should be overridden in child class.
 258       *
 259       * @param array $data
 260       * @return array
 261       */
 262      public function login_form_validation(array $data): array {
 263          return [];
 264      }
 265  
 266      /**
 267       * Returns true if factor class has factor records that might be revoked.
 268       * It means that user can revoke factor record from their profile.
 269       *
 270       * Override in child class if necessary.
 271       *
 272       * @return bool
 273       */
 274      public function has_revoke(): bool {
 275          return false;
 276      }
 277  
 278      /**
 279       * Marks factor record as revoked.
 280       * If factorid is not provided, revoke all instances of factor.
 281       *
 282       * @param int|null $factorid
 283       * @return bool
 284       * @throws \coding_exception
 285       * @throws \dml_exception
 286       */
 287      public function revoke_user_factor(?int $factorid = null): bool {
 288          global $DB, $USER;
 289  
 290          if (!empty($factorid)) {
 291              // If we have an explicit factor id, this means we need to be careful about the user.
 292              $params = ['id' => $factorid];
 293              $existing = $DB->get_record('tool_mfa', $params);
 294              if (empty($existing)) {
 295                  return false;
 296              }
 297              $matchinguser = $existing->userid == $USER->id;
 298              if (!is_siteadmin() && !$matchinguser) {
 299                  // We aren't admin, and this isn't our factor.
 300                  return false;
 301              }
 302          } else {
 303              $params = ['userid' => $USER->id, 'factor' => $this->name];
 304          }
 305          $DB->set_field('tool_mfa', 'revoked', 1, $params);
 306  
 307          $event = \tool_mfa\event\user_revoked_factor::user_revoked_factor_event($USER, $this->get_display_name());
 308          $event->trigger();
 309  
 310          return true;
 311      }
 312  
 313      /**
 314       * When validation code is correct - update lastverified field for given factor.
 315       * If factor id is not provided, update all factor entries for user.
 316       *
 317       * @param int|null $factorid
 318       * @return bool|\dml_exception
 319       * @throws \dml_exception
 320       */
 321      public function update_lastverified(?int $factorid = null): bool|\dml_exception {
 322          global $DB, $USER;
 323          if (!empty($factorid)) {
 324              $params = ['id' => $factorid];
 325          } else {
 326              $params = ['factor' => $this->name, 'userid' => $USER->id];
 327          }
 328          return $DB->set_field('tool_mfa', 'lastverified', time(), $params);
 329      }
 330  
 331      /**
 332       * Gets lastverified timestamp.
 333       *
 334       * @param int $factorid
 335       * @return int|bool the lastverified timestamp, or false if not found.
 336       */
 337      public function get_lastverified(int $factorid): int|bool {
 338          global $DB;
 339  
 340          $record = $DB->get_record('tool_mfa', ['id' => $factorid]);
 341          return $record->lastverified;
 342      }
 343  
 344      /**
 345       * Returns true if factor needs to be setup by user and has setup_form.
 346       * Override in child class if necessary.
 347       *
 348       * @return bool
 349       */
 350      public function has_setup(): bool {
 351          return false;
 352      }
 353  
 354      /**
 355       * If has_setup returns true, decides if the setup buttons should be shown on the preferences page.
 356       *
 357       * @return bool
 358       */
 359      public function show_setup_buttons(): bool {
 360          return $this->has_setup();
 361      }
 362  
 363      /**
 364       * Returns true if a factor requires input from the user to verify.
 365       *
 366       * Override in child class if necessary
 367       *
 368       * @return bool
 369       */
 370      public function has_input(): bool {
 371          return true;
 372      }
 373  
 374      /**
 375       * Returns true if a factor is able to be locked if it fails.
 376       *
 377       * Generally only input factors are lockable.
 378       * Override in child class if necessary
 379       *
 380       * @return bool
 381       */
 382      public function is_lockable(): bool {
 383          return $this->has_input();
 384      }
 385  
 386      /**
 387       * Returns the state of the factor from session information.
 388       *
 389       * Implementation for factors that require input.
 390       * Should be overridden in child classes with no input.
 391       *
 392       * @return mixed
 393       */
 394      public function get_state(): string {
 395          global $SESSION;
 396  
 397          $property = 'factor_'.$this->name;
 398  
 399          if (property_exists($SESSION, $property)) {
 400              return $SESSION->$property;
 401          } else {
 402              return \tool_mfa\plugininfo\factor::STATE_UNKNOWN;
 403          }
 404      }
 405  
 406      /**
 407       * Sets the state of the factor into the session.
 408       *
 409       * Implementation for factors that require input.
 410       * Should be overridden in child classes with no input.
 411       *
 412       * @param string $state the state constant to set.
 413       * @return bool
 414       */
 415      public function set_state(string $state): bool {
 416          global $SESSION;
 417  
 418          // Do not allow overwriting fail states.
 419          if ($this->get_state() == \tool_mfa\plugininfo\factor::STATE_FAIL) {
 420              return false;
 421          }
 422  
 423          $property = 'factor_'.$this->name;
 424          $SESSION->$property = $state;
 425          return true;
 426      }
 427  
 428      /**
 429       * Creates an event when user successfully setup a factor
 430       *
 431       * @param object $user
 432       * @return void
 433       */
 434      public function create_event_after_factor_setup(object $user): void {
 435          $event = \tool_mfa\event\user_setup_factor::user_setup_factor_event($user, $this->get_display_name());
 436          $event->trigger();
 437      }
 438  
 439      /**
 440       * Function for factor actions in the pass state.
 441       * Override in child class if necessary.
 442       *
 443       * @return void
 444       */
 445      public function post_pass_state(): void {
 446          // Update lastverified for factor.
 447          if ($this->get_state() == \tool_mfa\plugininfo\factor::STATE_PASS) {
 448              $this->update_lastverified();
 449          }
 450  
 451          // Now clean temp secrets for factor.
 452          $this->secretmanager->cleanup_temp_secrets();
 453      }
 454  
 455      /**
 456       * Function to retrieve the label for a factorid.
 457       *
 458       * @param int $factorid
 459       * @return string|\dml_exception
 460       */
 461      public function get_label(int $factorid): string|\dml_exception {
 462          global $DB;
 463          return $DB->get_field('tool_mfa', 'label', ['id' => $factorid]);
 464      }
 465  
 466      /**
 467       * Function to get urls that should not be redirected from.
 468       *
 469       * @return array
 470       */
 471      public function get_no_redirect_urls(): array {
 472          return [];
 473      }
 474  
 475      /**
 476       * Function to get possible states for a user from factor.
 477       * Implementation where state is based on deterministic user data.
 478       * This should be overridden in factors where state is non-deterministic.
 479       * E.g. IP changes based on whether a user is using a VPN.
 480       *
 481       * @param stdClass $user
 482       * @return array
 483       */
 484      public function possible_states(stdClass $user): array {
 485          return [$this->get_state()];
 486      }
 487  
 488      /**
 489       * Returns condition for passing factor.
 490       * Implementation for basic conditions.
 491       * Override for complex conditions such as auth type.
 492       *
 493       * @return string
 494       */
 495      public function get_summary_condition(): string {
 496          return get_string('summarycondition', 'factor_'.$this->name);
 497      }
 498  
 499      /**
 500       * Checks whether the factor combination is valid based on factor behaviour.
 501       * E.g. a combination with nosetup and another factor is not valid,
 502       * as you cannot pass nosetup with another factor.
 503       *
 504       * @param array $combination array of factors that make up the combination
 505       * @return bool
 506       */
 507      public function check_combination(array $combination): bool {
 508          return true;
 509      }
 510  
 511      /**
 512       * Gets the string for setup button on preferences page.
 513       *
 514       * @return string
 515       */
 516      public function get_setup_string(): string {
 517          return get_string('setupfactor', 'tool_mfa');
 518      }
 519  
 520      /**
 521       * Deletes all instances of factor for a user.
 522       *
 523       * @param stdClass $user the user to delete for.
 524       * @return void
 525       */
 526      public function delete_factor_for_user(stdClass $user): void {
 527          global $DB, $USER;
 528          $DB->delete_records('tool_mfa', ['userid' => $user->id, 'factor' => $this->name]);
 529  
 530          // Emit event for deletion.
 531          $event = \tool_mfa\event\user_deleted_factor::user_deleted_factor_event($user, $USER, $this->name);
 532          $event->trigger();
 533      }
 534  
 535      /**
 536       * Increments the lock counter for a factor.
 537       *
 538       * @return void
 539       */
 540      public function increment_lock_counter(): void {
 541          global $DB, $USER;
 542  
 543          // First make sure the state is loaded.
 544          $this->load_locked_state();
 545  
 546          // If lockcounter is negative, the field does not exist yet.
 547          if ($this->lockcounter === -1) {
 548              return;
 549          }
 550  
 551          $this->lockcounter++;
 552          // Update record in DB.
 553          $DB->set_field('tool_mfa', 'lockcounter', $this->lockcounter, ['userid' => $USER->id, 'factor' => $this->name]);
 554  
 555          // Now lock this factor if over the counter.
 556          $lockthreshold = get_config('tool_mfa', 'lockout');
 557          if ($this->lockcounter >= $lockthreshold) {
 558              $this->set_state(\tool_mfa\plugininfo\factor::STATE_LOCKED);
 559          }
 560      }
 561  
 562      /**
 563       * Return the number of remaining attempts at this factor.
 564       *
 565       * @return int the number of attempts at this factor remaining.
 566       */
 567      public function get_remaining_attempts(): int {
 568          $lockthreshold = get_config('tool_mfa', 'lockout');
 569          if ($this->lockcounter === -1) {
 570              // If upgrade.php hasnt been run yet, just return 10.
 571              return $lockthreshold;
 572          } else {
 573              return $lockthreshold - $this->lockcounter;
 574          }
 575      }
 576  
 577      /**
 578       * Process a cancel input from a user.
 579       *
 580       * @return void
 581       */
 582      public function process_cancel_action(): void {
 583          $this->set_state(\tool_mfa\plugininfo\factor::STATE_NEUTRAL);
 584      }
 585  
 586      /**
 587       * Hook point for global auth form action hooks.
 588       *
 589       * @param \MoodleQuickForm $mform Form to inject global elements into.
 590       * @return void
 591       */
 592      public function global_definition(\MoodleQuickForm $mform): void {
 593          return;
 594      }
 595  
 596      /**
 597       * Hook point for global auth form action hooks.
 598       *
 599       * @param \MoodleQuickForm $mform Form to inject global elements into.
 600       * @return void
 601       */
 602      public function global_definition_after_data(\MoodleQuickForm $mform): void {
 603          return;
 604      }
 605  
 606      /**
 607       * Hook point for global auth form action hooks.
 608       *
 609       * @param array $data Data from the form.
 610       * @param array $files Files form the form.
 611       * @return array of errors from validation.
 612       */
 613      public function global_validation(array $data, array $files): array {
 614          return [];
 615      }
 616  
 617      /**
 618       * Hook point for global auth form action hooks.
 619       *
 620       * @param object $data Data from the form.
 621       * @return void
 622       */
 623      public function global_submit(object $data): void {
 624          return;
 625      }
 626  
 627      /**
 628       * Get the icon associated with this factor.
 629       *
 630       * @return string the icon name.
 631       */
 632      public function get_icon(): string {
 633          return $this->icon;
 634      }
 635  
 636      /**
 637       * Get the login description associated with this factor.
 638       * Override for factors that have a user input.
 639       *
 640       * @return string The login option.
 641       */
 642      public function get_login_desc(): string {
 643          return get_string('logindesc', 'factor_'.$this->name);
 644      }
 645  }