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;
  18  
  19  use dml_exception;
  20  use tool_mfa\plugininfo\factor;
  21  
  22  /**
  23   * MFA management class.
  24   *
  25   * @package     tool_mfa
  26   * @author      Peter Burnett <peterburnett@catalyst-au.net>
  27   * @copyright   Catalyst IT
  28   * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  29   */
  30  class manager {
  31  
  32      /** @var int */
  33      const REDIRECT = 1;
  34  
  35      /** @var int */
  36      const NO_REDIRECT = 0;
  37  
  38      /** @var int */
  39      const REDIRECT_EXCEPTION = -1;
  40  
  41      /** @var int */
  42      const REDIR_LOOP_THRESHOLD = 5;
  43  
  44      /**
  45       * Displays a debug table with current factor information.
  46       *
  47       * @return void
  48       */
  49      public static function display_debug_notification(): void {
  50          global $OUTPUT, $PAGE;
  51  
  52          if (!get_config('tool_mfa', 'debugmode')) {
  53              return;
  54          }
  55          $html = $OUTPUT->heading(get_string('debugmode:heading', 'tool_mfa'), 3);
  56  
  57          $table = new \html_table();
  58          $table->head = [
  59              get_string('weight', 'tool_mfa'),
  60              get_string('factor', 'tool_mfa'),
  61              get_string('setup', 'tool_mfa'),
  62              get_string('achievedweight', 'tool_mfa'),
  63              get_string('status'),
  64          ];
  65          $table->attributes['class'] = 'admintable generaltable table table-bordered';
  66          $table->colclasses = [
  67              'text-right',
  68              '',
  69              '',
  70              'text-right',
  71              'text-center',
  72          ];
  73          $factors = factor::get_enabled_factors();
  74          $userfactors = factor::get_active_user_factor_types();
  75          $runningtotal = 0;
  76          $weighttoggle = false;
  77  
  78          foreach ($factors as $factor) {
  79              $namespace = 'factor_'.$factor->name;
  80              $name = get_string('pluginname', $namespace);
  81  
  82              // If factor is unknown, pending from here.
  83              if ($factor->get_state() == factor::STATE_UNKNOWN) {
  84                  $weighttoggle = true;
  85              }
  86  
  87              // Stop adding weight if 100 achieved.
  88              if (!$weighttoggle) {
  89                  $achieved = $factor->get_state() == factor::STATE_PASS ? $factor->get_weight() : 0;
  90                  $achieved = '+'.$achieved;
  91                  $runningtotal += $achieved;
  92              } else {
  93                  $achieved = '';
  94              }
  95  
  96              // Setup.
  97              if ($factor->has_setup()) {
  98                  $found = false;
  99                  foreach ($userfactors as $userfactor) {
 100                      if ($userfactor->name == $factor->name) {
 101                          $found = true;
 102                      }
 103                  }
 104                  $setup = $found ? get_string('yes') : get_string('no');
 105              } else {
 106                  $setup = get_string('na', 'tool_mfa');
 107              }
 108  
 109              // Status.
 110              $OUTPUT = $PAGE->get_renderer('tool_mfa');
 111              // If toggle has been flipped, fall to default pending badge.
 112              if ($weighttoggle) {
 113                  $state = $OUTPUT->get_state_badge('');
 114              } else {
 115                  $state = $OUTPUT->get_state_badge($factor->get_state());
 116              }
 117  
 118              $table->data[] = [
 119                  $factor->get_weight(),
 120                  $name,
 121                  $setup,
 122                  $achieved,
 123                  $state,
 124              ];
 125  
 126              // If we just hit 100, flip toggle.
 127              if ($runningtotal >= 100) {
 128                  $weighttoggle = true;
 129              }
 130          }
 131  
 132          $finalstate = self::get_status();
 133          $table->data[] = [
 134              '',
 135              '',
 136              '<b>' . get_string('overall', 'tool_mfa') . '</b>',
 137              self::get_cumulative_weight(),
 138              $OUTPUT->get_state_badge($finalstate),
 139          ];
 140  
 141          $html .= \html_writer::table($table);
 142          echo $html;
 143      }
 144  
 145      /**
 146       * Returns the total weight from all factors currently enabled for user.
 147       *
 148       * @return int
 149       */
 150      public static function get_total_weight(): int {
 151          $totalweight = 0;
 152          $factors = factor::get_active_user_factor_types();
 153  
 154          foreach ($factors as $factor) {
 155              if ($factor->get_state() == factor::STATE_PASS) {
 156                  $totalweight += $factor->get_weight();
 157              }
 158          }
 159          return $totalweight;
 160      }
 161  
 162      /**
 163       * Checks that provided factorid exists and belongs to current user.
 164       *
 165       * @param int $factorid
 166       * @param object $user
 167       * @return bool
 168       * @throws \dml_exception
 169       */
 170      public static function is_factorid_valid(int $factorid, object $user): bool {
 171          global $DB;
 172          return $DB->record_exists('tool_mfa', ['userid' => $user->id, 'id' => $factorid]);
 173      }
 174  
 175      /**
 176       * Function to display to the user that they cannot login, then log them out.
 177       *
 178       * @return void
 179       */
 180      public static function cannot_login(): void {
 181          global $ME, $PAGE, $SESSION, $USER;
 182  
 183          // Determine page URL without triggering warnings from $PAGE.
 184          if (!preg_match("~(\/admin\/tool\/mfa\/auth.php)~", $ME)) {
 185              // If URL isn't set, we need to redir to auth.php.
 186              // This ensures URL and required info is correctly set.
 187              // Then we arrive back here.
 188              redirect(new \moodle_url('/admin/tool/mfa/auth.php'));
 189          }
 190  
 191          $renderer = $PAGE->get_renderer('tool_mfa');
 192  
 193          echo $renderer->header();
 194          if (get_config('tool_mfa', 'debugmode')) {
 195              self::display_debug_notification();
 196          }
 197          echo $renderer->not_enough_factors();
 198          echo $renderer->footer();
 199          // Emit an event for failure, then logout.
 200          $event = \tool_mfa\event\user_failed_mfa::user_failed_mfa_event($USER);
 201          $event->trigger();
 202  
 203          // We should set the redir flag, as this page is generated through auth.php.
 204          $SESSION->tool_mfa_has_been_redirected = true;
 205          die;
 206      }
 207  
 208      /**
 209       * Logout user.
 210       *
 211       * @return void
 212       */
 213      public static function mfa_logout(): void {
 214          $authsequence = get_enabled_auth_plugins();
 215          foreach ($authsequence as $authname) {
 216              $authplugin = get_auth_plugin($authname);
 217              $authplugin->logoutpage_hook();
 218          }
 219          require_logout();
 220      }
 221  
 222      /**
 223       * Function to get the overall status of a user's authentication.
 224       *
 225       * @return string a STATE variable from plugininfo
 226       */
 227      public static function get_status(): string {
 228          global $SESSION;
 229  
 230          // Check for any instant fail states.
 231          $factors = factor::get_active_user_factor_types();
 232          foreach ($factors as $factor) {
 233              $factor->load_locked_state();
 234  
 235              if ($factor->get_state() == factor::STATE_FAIL) {
 236                  return factor::STATE_FAIL;
 237              }
 238          }
 239  
 240          $passcondition = ((isset($SESSION->tool_mfa_authenticated) && $SESSION->tool_mfa_authenticated) ||
 241              self::passed_enough_factors());
 242  
 243          // Check next factor for instant fail (fallback).
 244          if (factor::get_next_user_login_factor()->get_state() == factor::STATE_FAIL) {
 245              // We need to handle a special case here, where someone reached the fallback,
 246              // If they were able to modify their state on the error page, such as passing iprange,
 247              // We must return pass.
 248              if ($passcondition) {
 249                  return factor::STATE_PASS;
 250              }
 251  
 252              return factor::STATE_FAIL;
 253          }
 254  
 255          // Now check for general passing state. If found, ensure that session var is set.
 256          if ($passcondition) {
 257              return factor::STATE_PASS;
 258          }
 259  
 260          // Else return neutral state.
 261          return factor::STATE_NEUTRAL;
 262      }
 263  
 264      /**
 265       * Function to check the overall status of a users authentication,
 266       * and perform any required actions.
 267       *
 268       * @param bool $shouldreload whether the function should reload (used for auth.php).
 269       * @return void
 270       */
 271      public static function resolve_mfa_status(bool $shouldreload = false): void {
 272          global $SESSION;
 273  
 274          $state = self::get_status();
 275          if ($state == factor::STATE_PASS) {
 276              self::set_pass_state();
 277              // Check if user even had to reach auth page.
 278              if (isset($SESSION->tool_mfa_has_been_redirected)) {
 279                  if (empty($SESSION->wantsurl)) {
 280                      $wantsurl = '/';
 281                  } else {
 282                      $wantsurl = $SESSION->wantsurl;
 283                  }
 284                  unset($SESSION->wantsurl);
 285                  redirect(new \moodle_url($wantsurl));
 286              } else {
 287                  // Don't touch anything, let user be on their way.
 288                  return;
 289              }
 290          } else if ($state == factor::STATE_FAIL) {
 291              self::cannot_login();
 292          } else if ($shouldreload) {
 293              // Set a session variable to track whether user is where they want to be.
 294              $SESSION->tool_mfa_has_been_redirected = true;
 295              $authurl = new \moodle_url('/admin/tool/mfa/auth.php');
 296              redirect($authurl);
 297          }
 298      }
 299  
 300      /**
 301       * Checks whether user has passed enough factors to be allowed in.
 302       *
 303       * @return bool true if user has passed enough factors.
 304       */
 305      public static function passed_enough_factors(): bool {
 306  
 307          // Check for any instant fail states.
 308          $factors = factor::get_active_user_factor_types();
 309          foreach ($factors as $factor) {
 310              if ($factor->get_state() == factor::STATE_FAIL) {
 311                  self::mfa_logout();
 312              }
 313          }
 314  
 315          $totalweight = self::get_cumulative_weight();
 316          if ($totalweight >= 100) {
 317              return true;
 318          }
 319  
 320          return false;
 321      }
 322  
 323      /**
 324       * Sets the session variable for pass_state, if not already set.
 325       *
 326       * @return void
 327       */
 328      public static function set_pass_state(): void {
 329          global $DB, $SESSION, $USER;
 330          if (!isset($SESSION->tool_mfa_authenticated)) {
 331              $SESSION->tool_mfa_authenticated = true;
 332              $event = \tool_mfa\event\user_passed_mfa::user_passed_mfa_event($USER);
 333              $event->trigger();
 334  
 335              // Add/update record in DB for users last mfa auth.
 336              self::update_pass_time();
 337  
 338              // Unset session vars during mfa auth.
 339              unset($SESSION->mfa_redir_referer);
 340              unset($SESSION->mfa_redir_count);
 341  
 342              // Unset user preferences during mfa auth.
 343              unset_user_preference('mfa_sleep_duration', $USER);
 344  
 345              try {
 346                  // Clear locked user factors, they may now reauth with anything.
 347                  @$DB->set_field('tool_mfa', 'lockcounter', 0, ['userid' => $USER->id]);
 348              // @codingStandardsIgnoreStart
 349              } catch (\Exception $e) {
 350                  // This occurs when upgrade.php hasn't been run. Nothing to do here.
 351                  // Coding standards ignored, they break on empty catches.
 352              }
 353              // @codingStandardsIgnoreEnd
 354  
 355              // Fire post pass state factor actions.
 356              $factors = factor::get_active_user_factor_types();
 357              foreach ($factors as $factor) {
 358                  $factor->post_pass_state();
 359                  // Also set the states for this session to neutral if they were locked.
 360                  if ($factor->get_state() == factor::STATE_LOCKED) {
 361                      $factor->set_state(factor::STATE_NEUTRAL);
 362                  }
 363              }
 364  
 365              // Output notifications if any factors were reset for this user.
 366              $enabledfactors = factor::get_enabled_factors();
 367              foreach ($enabledfactors as $factor) {
 368                  $pref = 'tool_mfa_reset_' . $factor->name;
 369                  $factorpref = get_user_preferences($pref, false);
 370                  if ($factorpref) {
 371                      $url = new \moodle_url('/admin/tool/mfa/user_preferences.php');
 372                      $link = \html_writer::link($url, get_string('preferenceslink', 'tool_mfa'));
 373                      $data = ['factor' => $factor->get_display_name(), 'url' => $link];
 374                      \core\notification::warning(get_string('factorreset', 'tool_mfa', $data));
 375                      unset_user_preference($pref);
 376                  }
 377              }
 378  
 379              // Also check for a global reset.
 380              // TODO: Delete this in a few months, the reset all preference is no longer set.
 381              $allfactor = get_user_preferences('tool_mfa_reset_all', false);
 382              if ($allfactor) {
 383                  $url = new \moodle_url('/admin/tool/mfa/user_preferences.php');
 384                  $link = \html_writer::link($url, get_string('preferenceslink', 'tool_mfa'));
 385                  \core\notification::warning(get_string('factorresetall', 'tool_mfa', $link));
 386                  unset_user_preference('tool_mfa_reset_all');
 387              }
 388          }
 389      }
 390  
 391      /**
 392       * Inserts or updates user's last MFA pass time in DB.
 393       * This should only be called from set_pass_state.
 394       *
 395       * @return void
 396       */
 397      private static function update_pass_time(): void {
 398          global $DB, $USER;
 399  
 400          $exists = $DB->record_exists('tool_mfa_auth', ['userid' => $USER->id]);
 401  
 402          if ($exists) {
 403              $DB->set_field('tool_mfa_auth', 'lastverified', time(), ['userid' => $USER->id]);
 404          } else {
 405              $DB->insert_record('tool_mfa_auth', ['userid' => $USER->id, 'lastverified' => time()]);
 406          }
 407      }
 408  
 409      /**
 410       * Checks whether the user should be redirected from the provided url.
 411       *
 412       * @param string|\moodle_url $url
 413       * @param bool|null $preventredirect
 414       * @return int
 415       */
 416      public static function should_require_mfa(string|\moodle_url $url, bool|null $preventredirect): int {
 417          global $CFG, $USER, $SESSION;
 418  
 419          // If no cookies then no session so cannot do MFA.
 420          // Unit testing based on defines is not viable.
 421          if (NO_MOODLE_COOKIES && !PHPUNIT_TEST) {
 422              return self::NO_REDIRECT;
 423          }
 424  
 425          // Remove all params before comparison.
 426          $url->remove_all_params();
 427  
 428          // Checks for upgrades pending.
 429          if (is_siteadmin()) {
 430              // We should only allow an upgrade from the frontend to complete.
 431              // After that is completed, only the settings shouldn't redirect.
 432              // Everything else should be safe to enforce MFA.
 433              if (moodle_needs_upgrading()) {
 434                  return self::NO_REDIRECT;
 435              }
 436              // An upgrade isn't complete if there are settings that must be saved.
 437              $upgradesettings = new \moodle_url('/admin/upgradesettings.php');
 438              if ($url->compare($upgradesettings, URL_MATCH_BASE)) {
 439                  return self::NO_REDIRECT;
 440              }
 441          }
 442  
 443          // Dont redirect logo images from pluginfile.php (for example: logo in header).
 444          $logourl = new \moodle_url('/pluginfile.php/1/core_admin/logocompact/');
 445          if ($url->compare($logourl)) {
 446              return self::NO_REDIRECT;
 447          }
 448  
 449          // Admin not setup.
 450          if (!empty($CFG->adminsetuppending)) {
 451              return self::NO_REDIRECT;
 452          }
 453  
 454          // Initial installation.
 455          // We get this for free from get_plugins_with_function.
 456  
 457          // Upgrade check.
 458          // We get this for free from get_plugins_with_function.
 459  
 460          // Honor prevent_redirect.
 461          if ($preventredirect) {
 462              return self::NO_REDIRECT;
 463          }
 464  
 465          // User not properly setup.
 466          if (user_not_fully_set_up($USER)) {
 467              return self::NO_REDIRECT;
 468          }
 469  
 470          // Enrolment.
 471          $enrol = new \moodle_url('/enrol/index.php');
 472          if ($enrol->compare($url, URL_MATCH_BASE)) {
 473              return self::NO_REDIRECT;
 474          }
 475  
 476          // Guest access.
 477          if (isguestuser()) {
 478              return self::NO_REDIRECT;
 479          }
 480  
 481          // Forced password changes.
 482          if (get_user_preferences('auth_forcepasswordchange')) {
 483              return self::NO_REDIRECT;
 484          }
 485  
 486          // Login as.
 487          if (\core\session\manager::is_loggedinas()) {
 488              return self::NO_REDIRECT;
 489          }
 490  
 491          // Site policy.
 492          if (isset($USER->policyagreed) && !$USER->policyagreed) {
 493              $manager = new \core_privacy\local\sitepolicy\manager();
 494              $policyurl = $manager->get_redirect_url(false);
 495              if (!empty($policyurl) && $url->compare($policyurl, URL_MATCH_BASE)) {
 496                  return self::NO_REDIRECT;
 497              }
 498          }
 499  
 500          // WS/AJAX check.
 501          if (WS_SERVER || AJAX_SCRIPT) {
 502              if (isset($SESSION->mfa_pending) && !empty($SESSION->mfa_pending)) {
 503                  // Allow AJAX and WS, but never from auth.php.
 504                  return self::NO_REDIRECT;
 505              }
 506              return self::REDIRECT_EXCEPTION;
 507          }
 508  
 509          // Check factor defined safe urls.
 510          $factorurls = self::get_no_redirect_urls();
 511          foreach ($factorurls as $factorurl) {
 512              if ($factorurl->compare($url)) {
 513                  return self::NO_REDIRECT;
 514              }
 515          }
 516  
 517          // Circular checks.
 518          $authurl = new \moodle_url('/admin/tool/mfa/auth.php');
 519          $authlocal = $authurl->out_as_local_url();
 520          if (isset($SESSION->mfa_redir_referer)
 521              && $SESSION->mfa_redir_referer != $authlocal) {
 522              if ($SESSION->mfa_redir_referer == get_local_referer(true)) {
 523                  // Possible redirect loop.
 524                  if (!isset($SESSION->mfa_redir_count)) {
 525                      $SESSION->mfa_redir_count = 1;
 526                  } else {
 527                      $SESSION->mfa_redir_count++;
 528                  }
 529                  if ($SESSION->mfa_redir_count > self::REDIR_LOOP_THRESHOLD) {
 530                      return self::REDIRECT_EXCEPTION;
 531                  }
 532              } else {
 533                  // If not a match, reset counter.
 534                  $SESSION->mfa_redir_count = 0;
 535              }
 536          }
 537          // Set referer after checks.
 538          $SESSION->mfa_redir_referer = get_local_referer(true);
 539  
 540          // Don't redirect if already on auth.php.
 541          if ($url->compare($authurl, URL_MATCH_BASE)) {
 542              return self::NO_REDIRECT;
 543          }
 544  
 545          return self::REDIRECT;
 546      }
 547  
 548      /**
 549       * Clears the redirect counter for infinite redirect loops. Called from auth.php when a valid load is resolved.
 550       *
 551       * @return void
 552       */
 553      public static function clear_redirect_counter(): void {
 554          global $SESSION;
 555  
 556          unset($SESSION->mfa_redir_referer);
 557          unset($SESSION->mfa_redir_count);
 558      }
 559  
 560      /**
 561       * Gets all defined factor urls that should not redirect.
 562       *
 563       * @return array
 564       */
 565      public static function get_no_redirect_urls(): array {
 566          $factors = factor::get_factors();
 567          $urls = [
 568              new \moodle_url('/login/logout.php'),
 569              new \moodle_url('/admin/tool/mfa/guide.php'),
 570          ];
 571          foreach ($factors as $factor) {
 572              $urls = array_merge($urls, $factor->get_no_redirect_urls());
 573          }
 574  
 575          // Allow forced redirection exclusions.
 576          if ($exclusions = get_config('tool_mfa', 'redir_exclusions')) {
 577              foreach (explode("\n", $exclusions) as $exclusion) {
 578                  $urls[] = new \moodle_url($exclusion);
 579              }
 580          }
 581  
 582          return $urls;
 583      }
 584  
 585      /**
 586       * Sleeps for an increasing period of time.
 587       *
 588       * @return void
 589       */
 590      public static function sleep_timer(): void {
 591          global $USER;
 592  
 593          $duration = get_user_preferences('mfa_sleep_duration', null, $USER);
 594          if (!empty($duration)) {
 595              // Double current time.
 596              $duration *= 2;
 597              $duration = min(2, $duration);
 598          } else {
 599              // No duration set.
 600              $duration = 0.05;
 601          }
 602          set_user_preference('mfa_sleep_duration', $duration, $USER);
 603          sleep((int)$duration);
 604      }
 605  
 606      /**
 607       * If MFA Plugin is ready check tool_mfa_authenticated USER property and
 608       * start MFA authentication if it's not set or false.
 609       *
 610       * @param mixed $courseorid
 611       * @param mixed $autologinguest
 612       * @param mixed $cm
 613       * @param mixed $setwantsurltome
 614       * @param mixed $preventredirect
 615       * @return void
 616       */
 617      public static function require_auth($courseorid = null, $autologinguest = null, $cm = null,
 618      $setwantsurltome = null, $preventredirect = null): void {
 619          global $PAGE, $SESSION, $FULLME;
 620  
 621          // Guest user should never interact with MFA,
 622          // And $SESSION->tool_mfa_authenticated should never be set in a guest session.
 623          if (isguestuser()) {
 624              return;
 625          }
 626  
 627          if (!self::is_ready()) {
 628              // Set session var so if MFA becomes ready, you dont get locked from session.
 629              $SESSION->tool_mfa_authenticated = true;
 630              return;
 631          }
 632  
 633          if (empty($SESSION->tool_mfa_authenticated) || !$SESSION->tool_mfa_authenticated) {
 634              if ($PAGE->has_set_url()) {
 635                  $cleanurl = $PAGE->url;
 636              } else {
 637                  // Use $FULLME instead.
 638                  $cleanurl = new \moodle_url($FULLME);
 639              }
 640              $authurl = new \moodle_url('/admin/tool/mfa/auth.php');
 641  
 642              $redir = self::should_require_mfa($cleanurl, $preventredirect);
 643  
 644              if ($redir == self::NO_REDIRECT && !$cleanurl->compare($authurl, URL_MATCH_BASE)) {
 645                  // A non-MFA page that should take precedence.
 646                  // This check is for any pages, such as site policy, that must occur before MFA.
 647                  // This check allows AJAX and WS requests to fire on these pages without throwing an exception.
 648                  $SESSION->mfa_pending = true;
 649              }
 650  
 651              if ($redir == self::REDIRECT) {
 652                  if (empty($SESSION->wantsurl)) {
 653                      !empty($setwantsurltome)
 654                          ? $SESSION->wantsurl = qualified_me()
 655                          : $SESSION->wantsurl = new \moodle_url('/');
 656  
 657                      $SESSION->tool_mfa_setwantsurl = true;
 658                  }
 659                  // Remove pending status.
 660                  // We must now auth with MFA, now that pending statuses are resolved.
 661                  unset($SESSION->mfa_pending);
 662  
 663                  // Call resolve_status to instantly pass if no redirect is required.
 664                  self::resolve_mfa_status(true);
 665              } else if ($redir == self::REDIRECT_EXCEPTION) {
 666                  if (!empty($SESSION->mfa_redir_referer)) {
 667                      throw new \moodle_exception('redirecterrordetected', 'tool_mfa',
 668                          $SESSION->mfa_redir_referer, $SESSION->mfa_redir_referer);
 669                  } else {
 670                      throw new \moodle_exception('redirecterrordetected', 'error');
 671                  }
 672              }
 673          }
 674      }
 675  
 676      /**
 677       * Sets config variable for given factor.
 678       *
 679       * @param array $data
 680       * @param string $factor
 681       *
 682       * @return bool true or exception
 683       * @throws dml_exception
 684       */
 685      public static function set_factor_config(array $data, string $factor): bool|dml_exception {
 686          $factorconf = get_config($factor);
 687          foreach ($data as $key => $newvalue) {
 688              if (empty($factorconf->$key)) {
 689                  add_to_config_log($key, null, $newvalue, $factor);
 690                  set_config($key, $newvalue, $factor);
 691              } else if ($factorconf->$key != $newvalue) {
 692                  add_to_config_log($key, $factorconf->$key, $newvalue, $factor);
 693                  set_config($key, $newvalue, $factor);
 694              }
 695          }
 696          return true;
 697      }
 698  
 699      /**
 700       * Checks if MFA Plugin is enabled and has enabled factor.
 701       * If plugin is disabled or there is no enabled factors,
 702       * it means there is nothing to do from user side.
 703       * Thus, login flow shouldn't be extended with MFA.
 704       *
 705       * @return bool
 706       * @throws \dml_exception
 707       */
 708      public static function is_ready(): bool {
 709          global $CFG, $USER;
 710  
 711          if (!empty($CFG->upgraderunning)) {
 712              return false;
 713          }
 714  
 715          $pluginenabled = get_config('tool_mfa', 'enabled');
 716          if (empty($pluginenabled)) {
 717              return false;
 718          }
 719  
 720          // Check if user can interact with MFA.
 721          $usercontext = \context_user::instance($USER->id);
 722          if (!has_capability('tool/mfa:mfaaccess', $usercontext)) {
 723              return false;
 724          }
 725  
 726          $enabledfactors = factor::get_enabled_factors();
 727          if (count($enabledfactors) == 0) {
 728              return false;
 729          }
 730  
 731          return true;
 732      }
 733  
 734      /**
 735       * Performs factor actions for given factor.
 736       * Change factor order and enable/disable.
 737       *
 738       * @param string $factorname
 739       * @param string $action
 740       *
 741       * @return void
 742       * @throws dml_exception
 743       */
 744      public static function do_factor_action(string $factorname, string $action): void {
 745          $order = explode(',', get_config('tool_mfa', 'factor_order'));
 746          $key = array_search($factorname, $order);
 747  
 748          switch ($action) {
 749              case 'up':
 750                  if ($key >= 1) {
 751                      $fsave = $order[$key];
 752                      $order[$key] = $order[$key - 1];
 753                      $order[$key - 1] = $fsave;
 754                  }
 755                  break;
 756  
 757              case 'down':
 758                  if ($key < (count($order) - 1)) {
 759                      $fsave = $order[$key];
 760                      $order[$key] = $order[$key + 1];
 761                      $order[$key + 1] = $fsave;
 762                  }
 763                  break;
 764  
 765              case 'enable':
 766                  if (!$key) {
 767                      $order[] = $factorname;
 768                  }
 769                  break;
 770  
 771              case 'disable':
 772                  if ($key) {
 773                      unset($order[$key]);
 774                  }
 775                  break;
 776  
 777              default:
 778                  break;
 779          }
 780          self::set_factor_config(['factor_order' => implode(',', $order)], 'tool_mfa');
 781      }
 782  
 783      /**
 784       * Checks if a factor that can make a user pass can be setup.
 785       * It checks if a user will always pass regardless,
 786       * then checks if there are factors that can be setup to let a user pass.
 787       *
 788       * @return bool
 789       */
 790      public static function possible_factor_setup(): bool {
 791          global $USER;
 792  
 793          // Get all active factors.
 794          $factors = factor::get_enabled_factors();
 795  
 796          // Check if there are enough factors that a user can ONLY pass, if so, don't display the menu.
 797          $weight = 0;
 798          foreach ($factors as $factor) {
 799              $states = $factor->possible_states($USER);
 800              if (count($states) == 1 && reset($states) == factor::STATE_PASS) {
 801                  $weight += $factor->get_weight();
 802                  if ($weight >= 100) {
 803                      return false;
 804                  }
 805              }
 806          }
 807  
 808          // Now if there is a factor that can be setup, that may return a pass state for the user, display menu.
 809          foreach ($factors as $factor) {
 810              if ($factor->has_setup()) {
 811                  if (in_array(factor::STATE_PASS, $factor->possible_states($USER))) {
 812                      return true;
 813                  }
 814              }
 815          }
 816  
 817          return false;
 818      }
 819  
 820      /**
 821       * Gets current user weight, up until first unknown factor.
 822       *
 823       * @return int $totalweight Total weight of all factors.
 824       */
 825      public static function get_cumulative_weight(): int {
 826          $factors = factor::get_active_user_factor_types();
 827          // Factor order is important here, so sort the factors by state.
 828          $sortedfactors = factor::sort_factors_by_state($factors, factor::STATE_PASS);
 829          $totalweight = 0;
 830          foreach ($sortedfactors as $factor) {
 831              if ($factor->get_state() == factor::STATE_PASS) {
 832                  $totalweight += $factor->get_weight();
 833                  // If over 100, break. Don't care about >100.
 834                  if ($totalweight >= 100) {
 835                      break;
 836                  }
 837              } else if ($factor->get_state() == factor::STATE_UNKNOWN) {
 838                  break;
 839              }
 840          }
 841          return $totalweight;
 842      }
 843  
 844      /**
 845       * Checks whether the factor was actually used in the login process.
 846       *
 847       * @param string $factorname the name of the factor.
 848       * @return bool true if factor is pending.
 849       */
 850      public static function check_factor_pending(string $factorname): bool {
 851          $factors = factor::get_active_user_factor_types();
 852          // Setup vars.
 853          $pending = [];
 854          $totalweight = 0;
 855          $weighttoggle = false;
 856  
 857          foreach ($factors as $factor) {
 858              // If toggle is reached, put in pending and continue.
 859              if ($weighttoggle) {
 860                  $pending[] = $factor->name;
 861                  continue;
 862              }
 863  
 864              if ($factor->get_state() == factor::STATE_PASS) {
 865                  $totalweight += $factor->get_weight();
 866                  if ($totalweight >= 100) {
 867                      $weighttoggle = true;
 868                  }
 869              }
 870          }
 871  
 872          // Check whether factor falls into pending category.
 873          return in_array($factorname, $pending);
 874      }
 875  }