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_token;
  18  
  19  use stdClass;
  20  use tool_mfa\local\factor\object_factor_base;
  21  use tool_mfa\local\secret_manager;
  22  
  23  /**
  24   * Token factor class.
  25   *
  26   * @package     factor_token
  27   * @author      Peter Burnett <peterburnett@catalyst-au.net>
  28   * @copyright   Catalyst IT
  29   * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  30   */
  31  class factor extends object_factor_base {
  32  
  33      /**
  34       * Token implementation.
  35       *
  36       * {@inheritDoc}
  37       */
  38      public function has_input(): bool {
  39          return false;
  40      }
  41  
  42      /**
  43       * Token implementation.
  44       * This factor is a singleton, return single instance.
  45       *
  46       * @param stdClass $user the user to check against.
  47       * @return array
  48       */
  49      public function get_all_user_factors(stdClass $user): array {
  50          global $DB;
  51          $records = $DB->get_records('tool_mfa', ['userid' => $user->id, 'factor' => $this->name]);
  52  
  53          if (!empty($records)) {
  54              return $records;
  55          }
  56  
  57          // Null records returned, build new record.
  58          $record = [
  59              'userid' => $user->id,
  60              'factor' => $this->name,
  61              'timecreated' => time(),
  62              'createdfromip' => $user->lastip,
  63              'timemodified' => time(),
  64              'revoked' => 0,
  65          ];
  66          $record['id'] = $DB->insert_record('tool_mfa', $record, true);
  67          return [(object) $record];
  68      }
  69  
  70      /**
  71       * Token implementation.
  72       * Checks whether the user has selected roles in any context.
  73       *
  74       * {@inheritDoc}
  75       */
  76      public function get_state(): string {
  77          global $USER;
  78  
  79          // Check if there was a previous locked status to return.
  80          $state = parent::get_state();
  81          if ($state === \tool_mfa\plugininfo\factor::STATE_LOCKED) {
  82              return \tool_mfa\plugininfo\factor::STATE_LOCKED;
  83          }
  84  
  85          // Check cookie Exists.
  86          $cookie = 'MFA_TOKEN_' . $USER->id;
  87          if (NO_MOODLE_COOKIES || empty($_COOKIE[$cookie])) {
  88              return \tool_mfa\plugininfo\factor::STATE_NEUTRAL;
  89          }
  90          $token = $_COOKIE[$cookie];
  91  
  92          $secretmanager = new secret_manager($this->name);
  93          $verified = $secretmanager->validate_secret($token, true);
  94  
  95          // If we got a bad cookie value, someone is likely being dodgy.
  96          // In this instance we should just lock and make the user re-MFA.
  97          if ($verified === secret_manager::NONVALID) {
  98              $this->set_state(\tool_mfa\plugininfo\factor::STATE_LOCKED);
  99              return \tool_mfa\plugininfo\factor::STATE_LOCKED;
 100          } else if ($verified === secret_manager::VALID) {
 101              return \tool_mfa\plugininfo\factor::STATE_PASS;
 102          }
 103  
 104          // We should never get here. Factor cannot be revoked.
 105          return \tool_mfa\plugininfo\factor::STATE_NEUTRAL;
 106      }
 107  
 108      /**
 109       * Token Implementation.
 110       * We can't get_state like the parent here or it will recurse forever.
 111       *
 112       * @param string $state the state constant to set
 113       * @return bool
 114       */
 115      public function set_state($state): bool {
 116          global $SESSION;
 117          $property = 'factor_' . $this->name;
 118          $SESSION->$property = $state;
 119          return true;
 120      }
 121  
 122      /**
 123       * Token implementation.
 124       *
 125       * @param stdClass $user
 126       * @return array
 127       */
 128      public function possible_states(stdClass $user): array {
 129          return [
 130              \tool_mfa\plugininfo\factor::STATE_PASS,
 131              \tool_mfa\plugininfo\factor::STATE_NEUTRAL,
 132              \tool_mfa\plugininfo\factor::STATE_LOCKED,
 133          ];
 134      }
 135  
 136      /**
 137       * Token implementation.
 138       * Inject a checkbox into every auth form if needed.
 139       *
 140       * @param \MoodleQuickForm $mform Form to inject global elements into.
 141       * @return void
 142       */
 143      public function global_definition_after_data($mform): void {
 144          global $SESSION;
 145  
 146          // First thing, we need to decide on whether we should show the checkbox.
 147          $noproperty = !property_exists($SESSION, 'tool_mfa_factor_token');
 148          $nostate = $this->get_state() !== \tool_mfa\plugininfo\factor::STATE_PASS;
 149  
 150          if ($noproperty && $nostate) {
 151              $expiry = get_config('factor_token', 'expiry');
 152              $expirystring = format_time($expiry);
 153              $mform->addElement('advcheckbox', 'factor_token_trust', '', get_string('form:trust', 'factor_token', $expirystring));
 154              $mform->setType('factor_token_trust', PARAM_BOOL);
 155              $mform->setDefault('factor_token_trust', true);
 156          }
 157      }
 158  
 159      /**
 160       * Token implementation.
 161       * Store information about the token status.
 162       *
 163       * @param object $data Data from the form.
 164       * @return void
 165       */
 166      public function global_submit($data): void {
 167          global $SESSION;
 168  
 169          // Store any kind of response here, we shouldnt show again.
 170          $trust = $data->factor_token_trust;
 171          $SESSION->tool_mfa_factor_token = $trust;
 172      }
 173  
 174      /**
 175       * Token implementation.
 176       * Pass hook to set the cookie for use in subsequent auths.
 177       *
 178       * {@inheritDoc}
 179       */
 180      public function post_pass_state(): void {
 181          global $CFG, $SESSION, $USER;
 182  
 183          if (!property_exists($SESSION, 'tool_mfa_factor_token')) {
 184              return;
 185          }
 186          $settoken = $SESSION->tool_mfa_factor_token;
 187          if (!$settoken) {
 188              return;
 189          }
 190          $cookie = 'MFA_TOKEN_' . $USER->id;
 191  
 192          list($expirytime, $expiry) = $this->calculate_expiry_time();
 193  
 194          // Store this secret in the database.
 195          $secretmanager = new secret_manager($this->name);
 196          $secret = base64_encode(random_bytes(256));
 197          $secretmanager->create_secret($expiry, false, $secret);
 198  
 199          // All the prep is now done, we can set this cookie.
 200          setcookie($cookie, $secret, $expirytime, $CFG->sessioncookiepath, $CFG->sessioncookiedomain, false, true);
 201  
 202          // Finally emit a log event for storing the cookie.
 203          $state = [
 204              'expiry' => $expirytime,
 205              'cookie' => $cookie,
 206          ];
 207          $event = \factor_token\event\token_created::token_created_event($USER, $state);
 208          $event->trigger();
 209      }
 210  
 211      /**
 212       * Calculate the expiry time of the token, based on configuration.
 213       *
 214       * @param integer|null $basetime time to use for calcalations.
 215       * @return array
 216       */
 217      public function calculate_expiry_time($basetime = null): array {
 218          if (empty($basetime)) {
 219              $basetime = time();
 220          }
 221  
 222          // Calculate the expiry time. This is provided by config,
 223          // But optionally might need to be rounded  to expire a few hours after 0000 server time.
 224          $expiry = get_config('factor_token', 'expiry');
 225          $expirytime = $basetime + $expiry;
 226  
 227          // If expiring overnight, it should expire at 2am the following morning, if required.
 228          $expireovernight = get_config('factor_token', 'expireovernight');
 229          if ($expireovernight) {
 230              // Find out what 2am the following morning time is.
 231              $datetime = new \DateTime();
 232              $timezone = \core_date::get_user_timezone_object();
 233  
 234              // Bit to ensure 'expireovernight' works when 'expire' is longer than one day.
 235              $difftime = 0;
 236              if ($expiry > DAYSECS) {
 237                  // Ensures a safe amount of days is added before doing the 2am checks.
 238                  $difftime = $expiry - DAYSECS;
 239              }
 240  
 241              // Calculte the overnight expiry time, ignoring 'expiry' duration period.
 242              $workingexpirytime = $basetime + $difftime;
 243              $datetime->setTimezone($timezone);
 244              $datetime->setTimestamp($workingexpirytime);
 245              $datetime->add(new \DateInterval('P1D'));
 246              $datetime->setTime(2, 0); // Set the hour to 2am.
 247  
 248              // Ensure whatever happens, ensure the expiry never goes over the default 'expiry' time.
 249              $overnightexpirytime = $datetime->getTimestamp();
 250              $expirytime = min($overnightexpirytime, $expirytime);
 251              $expiry = $expirytime - $basetime;
 252          }
 253  
 254          return [$expirytime, $expiry];
 255      }
 256  }