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;
  18  
  19  /**
  20   * MFA secret management class.
  21   *
  22   * @package     tool_mfa
  23   * @author      Peter Burnett <peterburnett@catalyst-au.net>
  24   * @copyright   Catalyst IT
  25   * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  26   */
  27  class secret_manager {
  28  
  29      /** @var string */
  30      const REVOKED = 'revoked';
  31  
  32      /** @var string */
  33      const VALID = 'valid';
  34  
  35      /** @var string */
  36      const NONVALID = 'nonvalid';
  37  
  38      /** @var string */
  39      private $factor;
  40  
  41      /** @var string|false */
  42      private $sessionid;
  43  
  44      /**
  45       * Initialises a secret manager instance
  46       *
  47       * @param   string $factor
  48       */
  49      public function __construct(string $factor) {
  50          $this->factor = $factor;
  51          $this->sessionid = session_id();
  52      }
  53  
  54      /**
  55       * This function creates or takes a secret, and stores it in the database or session.
  56       *
  57       * @param int $expires the length of time the secret is valid. e.g. 1 min = 60
  58       * @param bool $session whether this secret should be linked to the session.
  59       * @param string $secret an optional provided secret
  60       * @return string the secret code, or 0 if no new code created.
  61       */
  62      public function create_secret(int $expires, bool $session, string $secret = null): string {
  63          // Check if there already an active secret, unless we are forcibly given a code.
  64          if ($this->has_active_secret($session) && empty($secret)) {
  65              return '';
  66          }
  67  
  68          // Setup a secret if not provided.
  69          if (empty($secret)) {
  70              $secret = random_int(100000, 999999);
  71          }
  72  
  73          // Now pass the code where it needs to go.
  74          if ($session) {
  75              $this->add_secret_to_db($secret, $expires, $this->sessionid);
  76          } else {
  77              $this->add_secret_to_db($secret, $expires);
  78          }
  79  
  80          return $secret;
  81      }
  82  
  83      /**
  84       * Inserts the provided secret into the database with a given expiry duration.
  85       *
  86       * @param string $secret the secret to store
  87       * @param int $expires expiry duration in seconds
  88       * @param string $sessionid an optional sessionID to tie this record to
  89       * @return void
  90       */
  91      private function add_secret_to_db(string $secret, int $expires, string $sessionid = null): void {
  92          global $DB, $USER;
  93          $expirytime = time() + $expires;
  94  
  95          $data = [
  96              'userid' => $USER->id,
  97              'factor' => $this->factor,
  98              'secret' => $secret,
  99              'timecreated' => time(),
 100              'expiry' => $expirytime,
 101              'revoked' => 0,
 102          ];
 103          if (!empty($sessionid)) {
 104              $data['sessionid'] = $sessionid;
 105          }
 106          $DB->insert_record('tool_mfa_secrets', $data);
 107      }
 108  
 109      /**
 110       * Validates whether the provided secret is currently valid.
 111       *
 112       * @param string $secret the secret to check
 113       * @param bool $keep should the secret be kept for reuse until expiry?
 114       * @return string a secret manager state constant
 115       */
 116      public function validate_secret(string $secret, bool $keep = false): string {
 117          global $DB, $USER;
 118          $status = $this->check_secret_against_db($secret, $this->sessionid);
 119          if ($status !== self::NONVALID) {
 120              if ($status === self::VALID && !$keep) {
 121                  // Cleanup DB $record.
 122                  $DB->delete_records('tool_mfa_secrets', ['userid' => $USER->id, 'factor' => $this->factor]);
 123              }
 124              return $status;
 125          }
 126          // This is always nonvalid.
 127          return $status;
 128      }
 129  
 130      /**
 131       * Checks if a given secret is valid from the Database.
 132       *
 133       * @param string $secret the secret to check.
 134       * @param string $sessionid the session id to check for.
 135       * @return string a secret manager state constant.
 136       */
 137      private function check_secret_against_db(string $secret, string $sessionid): string {
 138          global $DB, $USER;
 139  
 140          $sql = "SELECT *
 141                    FROM {tool_mfa_secrets}
 142                   WHERE secret = :secret
 143                     AND expiry > :now
 144                     AND userid = :userid
 145                     AND factor = :factor";
 146  
 147          $params = [
 148              'secret' => $secret,
 149              'now' => time(),
 150              'userid' => $USER->id,
 151              'factor' => $this->factor,
 152          ];
 153  
 154          $record = $DB->get_record_sql($sql, $params);
 155  
 156          if (!empty($record)) {
 157              // If revoked it should always be revoked status.
 158              if ($record->revoked) {
 159                  return self::REVOKED;
 160              }
 161  
 162              // Check if this is valid in only one session.
 163              if (!empty($record->sessionid)) {
 164                  if ($record->sessionid === $sessionid) {
 165                      return self::VALID;
 166                  }
 167                  return self::NONVALID;
 168              }
 169              return self::VALID;
 170          }
 171          return self::NONVALID;
 172      }
 173  
 174      /**
 175       * Revokes the provided secret code for the user.
 176       *
 177       * @param string $secret the secret to revoke.
 178       * @param int $userid the userid to revoke the secret for.
 179       * @return void
 180       */
 181      public function revoke_secret(string $secret, $userid = null): void {
 182          global $DB, $USER;
 183  
 184          $userid = $userid ?? $USER->id;
 185  
 186          // We do not need to worry about session vs global here.
 187          // A factor should only ever use one.
 188          // We know this secret is valid, so we don't need to check expiry.
 189          $DB->set_field('tool_mfa_secrets', 'revoked', 1, ['userid' => $userid, 'factor' => $this->factor, 'secret' => $secret]);
 190      }
 191  
 192      /**
 193       * Checks whether this factor currently has an active secret, and should not add another.
 194       *
 195       * @param bool $checksession should we only check if a current session secret is active?
 196       * @return bool
 197       */
 198      private function has_active_secret(bool $checksession = false): bool {
 199          global $DB, $USER;
 200  
 201          $sql = "SELECT *
 202                    FROM {tool_mfa_secrets}
 203                   WHERE expiry > :now
 204                     AND userid = :userid
 205                     AND factor = :factor
 206                     AND revoked = 0";
 207  
 208          $params = [
 209              'now' => time(),
 210              'userid' => $USER->id,
 211              'factor' => $this->factor,
 212          ];
 213  
 214          if ($checksession) {
 215              $sql .= ' AND sessionid = :sessionid';
 216              $params['sessionid'] = $this->sessionid;
 217          }
 218  
 219          if ($DB->record_exists_sql($sql, $params)) {
 220              return true;
 221          }
 222  
 223          return false;
 224      }
 225  
 226      /**
 227       * Deletes any user secrets hanging around in the database.
 228       *
 229       * @param int $userid the userid to cleanup temp secrets for.
 230       * @return void
 231       */
 232      public function cleanup_temp_secrets($userid = null): void {
 233          global $DB, $USER;
 234          // Session records are autocleaned up.
 235          // Only DB cleanup required.
 236  
 237          $userid = $userid ?? $USER->id;
 238          $sql = 'DELETE FROM {tool_mfa_secrets}
 239                        WHERE userid = :userid
 240                          AND factor = :factor';
 241  
 242          $DB->execute($sql, ['userid' => $userid, 'factor' => $this->factor]);
 243      }
 244  }