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_email;
  18  
  19  use stdClass;
  20  use tool_mfa\local\factor\object_factor_base;
  21  
  22  /**
  23   * Email factor class.
  24   *
  25   * @package     factor_email
  26   * @subpackage  tool_mfa
  27   * @author      Mikhail Golenkov <golenkovm@gmail.com>
  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      /** @var string Factor icon */
  34      protected $icon = 'fa-envelope';
  35  
  36      /**
  37       * E-Mail Factor implementation.
  38       *
  39       * @param \MoodleQuickForm $mform
  40       * @return \MoodleQuickForm $mform
  41       */
  42      public function login_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm {
  43          $mform->addElement(new \tool_mfa\local\form\verification_field());
  44          $mform->setType('verificationcode', PARAM_ALPHANUM);
  45          return $mform;
  46      }
  47  
  48      /**
  49       * E-Mail Factor implementation.
  50       *
  51       * @param \MoodleQuickForm $mform Form to inject global elements into.
  52       * @return object $mform
  53       */
  54      public function login_form_definition_after_data(\MoodleQuickForm $mform): \MoodleQuickForm {
  55          $this->generate_and_email_code();
  56          return $mform;
  57      }
  58  
  59      /**
  60       * Sends and e-mail to user with given verification code.
  61       *
  62       * @param int $instanceid
  63       * @return void
  64       */
  65      public static function email_verification_code(int $instanceid): void {
  66          global $PAGE, $USER;
  67          $noreplyuser = \core_user::get_noreply_user();
  68          $subject = get_string('email:subject', 'factor_email');
  69          $renderer = $PAGE->get_renderer('factor_email');
  70          $body = $renderer->generate_email($instanceid);
  71          email_to_user($USER, $noreplyuser, $subject, $body, $body);
  72      }
  73  
  74      /**
  75       * E-Mail Factor implementation.
  76       *
  77       * @param array $data
  78       * @return array
  79       */
  80      public function login_form_validation(array $data): array {
  81          global $USER;
  82          $return = [];
  83  
  84          if (!$this->check_verification_code($data['verificationcode'])) {
  85              $return['verificationcode'] = get_string('error:wrongverification', 'factor_email');
  86          }
  87  
  88          return $return;
  89      }
  90  
  91      /**
  92       * E-Mail Factor implementation.
  93       *
  94       * @param stdClass $user the user to check against.
  95       * @return array
  96       */
  97      public function get_all_user_factors(stdClass $user): array {
  98          global $DB;
  99  
 100          $records = $DB->get_records('tool_mfa', [
 101              'userid' => $user->id,
 102              'factor' => $this->name,
 103              'label' => $user->email,
 104          ]);
 105  
 106          if (!empty($records)) {
 107              return $records;
 108          }
 109  
 110          // Null records returned, build new record.
 111          $record = [
 112              'userid' => $user->id,
 113              'factor' => $this->name,
 114              'label' => $user->email,
 115              'createdfromip' => $user->lastip,
 116              'timecreated' => time(),
 117              'revoked' => 0,
 118          ];
 119          $record['id'] = $DB->insert_record('tool_mfa', $record, true);
 120          return [(object) $record];
 121      }
 122  
 123      /**
 124       * E-Mail Factor implementation.
 125       *
 126       * {@inheritDoc}
 127       */
 128      public function has_input(): bool {
 129          if (self::is_ready()) {
 130              return true;
 131          }
 132          return false;
 133      }
 134  
 135      /**
 136       * E-Mail Factor implementation.
 137       *
 138       * {@inheritDoc}
 139       */
 140      public function get_state(): string {
 141          if (!self::is_ready()) {
 142              return \tool_mfa\plugininfo\factor::STATE_NEUTRAL;
 143          }
 144  
 145          return parent::get_state();
 146      }
 147  
 148      /**
 149       * Checks whether user email is correctly configured.
 150       *
 151       * @return bool
 152       */
 153      private static function is_ready(): bool {
 154          global $DB, $USER;
 155  
 156          if (empty($USER->email)) {
 157              return false;
 158          }
 159          if (!validate_email($USER->email)) {
 160              return false;
 161          }
 162          if (over_bounce_threshold($USER)) {
 163              return false;
 164          }
 165  
 166          // If this factor is revoked, set to not ready.
 167          if ($DB->record_exists('tool_mfa', ['userid' => $USER->id, 'factor' => 'email', 'revoked' => 1])) {
 168              return false;
 169          }
 170          return true;
 171      }
 172  
 173      /**
 174       * Generates and emails the code for login to the user, stores codes in DB.
 175       *
 176       * @return void
 177       */
 178      private function generate_and_email_code(): void {
 179          global $DB, $USER;
 180  
 181          // Get instance that isnt parent email type (label check).
 182          // This check must exclude the main singleton record, with the label as the email.
 183          // It must only grab the record with the user agent as the label.
 184          $sql = 'SELECT *
 185                    FROM {tool_mfa}
 186                   WHERE userid = ?
 187                     AND factor = ?
 188                 AND NOT label = ?';
 189  
 190          $record = $DB->get_record_sql($sql, [$USER->id, 'email', $USER->email]);
 191          $duration = get_config('factor_email', 'duration');
 192          $newcode = random_int(100000, 999999);
 193  
 194          if (empty($record)) {
 195              // No code active, generate new code.
 196              $instanceid = $DB->insert_record('tool_mfa', [
 197                  'userid' => $USER->id,
 198                  'factor' => 'email',
 199                  'secret' => $newcode,
 200                  'label' => $_SERVER['HTTP_USER_AGENT'],
 201                  'timecreated' => time(),
 202                  'createdfromip' => $USER->lastip,
 203                  'timemodified' => time(),
 204                  'lastverified' => time(),
 205                  'revoked' => 0,
 206              ], true);
 207              $this->email_verification_code($instanceid);
 208          } else if ($record->timecreated + $duration < time()) {
 209              // Old code found. Keep id, update fields.
 210              $DB->update_record('tool_mfa', [
 211                  'id' => $record->id,
 212                  'secret' => $newcode,
 213                  'label' => $_SERVER['HTTP_USER_AGENT'],
 214                  'timecreated' => time(),
 215                  'createdfromip' => $USER->lastip,
 216                  'timemodified' => time(),
 217                  'lastverified' => time(),
 218                  'revoked' => 0,
 219              ]);
 220              $instanceid = $record->id;
 221              $this->email_verification_code($instanceid);
 222          }
 223      }
 224  
 225      /**
 226       * Verifies entered code against stored DB record.
 227       *
 228       * @param string $enteredcode
 229       * @return bool
 230       */
 231      private function check_verification_code(string $enteredcode): bool {
 232          global $DB, $USER;
 233          $duration = get_config('factor_email', 'duration');
 234  
 235          // Get instance that isnt parent email type (label check).
 236          // This check must exclude the main singleton record, with the label as the email.
 237          // It must only grab the record with the user agent as the label.
 238          $sql = 'SELECT *
 239                    FROM {tool_mfa}
 240                   WHERE userid = ?
 241                     AND factor = ?
 242                 AND NOT label = ?';
 243          $record = $DB->get_record_sql($sql, [$USER->id, 'email', $USER->email]);
 244  
 245          if ($enteredcode == $record->secret) {
 246              if ($record->timecreated + $duration > time()) {
 247                  return true;
 248              }
 249          }
 250          return false;
 251      }
 252  
 253      /**
 254       * Cleans up email records once MFA passed.
 255       *
 256       * {@inheritDoc}
 257       */
 258      public function post_pass_state(): void {
 259          global $DB, $USER;
 260          // Delete all email records except base record.
 261          $selectsql = 'userid = ?
 262                    AND factor = ?
 263                AND NOT label = ?';
 264          $DB->delete_records_select('tool_mfa', $selectsql, [$USER->id, 'email', $USER->email]);
 265  
 266          // Update factor timeverified.
 267          parent::post_pass_state();
 268      }
 269  
 270      /**
 271       * Email factor implementation.
 272       * Email page must be safe to authorise session from link.
 273       *
 274       * {@inheritDoc}
 275       */
 276      public function get_no_redirect_urls(): array {
 277          $email = new \moodle_url('/admin/tool/mfa/factor/email/email.php');
 278          return [$email];
 279      }
 280  
 281      /**
 282       * Email factor implementation.
 283       *
 284       * @param stdClass $user
 285       */
 286      public function possible_states(stdClass $user): array {
 287          // Email can return all states.
 288          return [
 289              \tool_mfa\plugininfo\factor::STATE_FAIL,
 290              \tool_mfa\plugininfo\factor::STATE_PASS,
 291              \tool_mfa\plugininfo\factor::STATE_NEUTRAL,
 292              \tool_mfa\plugininfo\factor::STATE_UNKNOWN,
 293          ];
 294      }
 295  
 296      /**
 297       * Obscure an email address by replacing all but the first and last character of the local part with a dot.
 298       * So the users full email isn't displayed during login.
 299       *
 300       * @param string $email The email address to obfuscate.
 301       * @return string
 302       * @throws \coding_exception
 303       */
 304      protected function obfuscate_email(string $email): string {
 305          // Split the email address at the '@' symbol.
 306          $parts = explode('@', $email);
 307  
 308          if (count($parts) != 2) {
 309              throw new \coding_exception('Invalid email format');
 310          }
 311  
 312          $local = $parts[0];
 313          $domain = $parts[1];
 314  
 315          // Obfuscate all but the first and last character of the local part.
 316          $length = strlen($local);
 317          $middledot = "\u{00B7}";
 318          if ($length > 2) {
 319              $local = $local[0] . str_repeat($middledot, $length - 2) . $local[$length - 1];
 320          }
 321  
 322          // Put the email address back together and return it.
 323          return $local . '@' . $domain;
 324      }
 325  
 326      /**
 327       * Get the login description associated with this factor.
 328       * Override for factors that have a user input.
 329       *
 330       * @return string The login option.
 331       */
 332      public function get_login_desc(): string {
 333          global $USER;
 334          $email = $this->obfuscate_email($USER->email);
 335  
 336          return get_string('logindesc', 'factor_' . $this->name, $email);
 337      }
 338  }