| 1 | efrain | 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 |      */
 | 
        
           | 1441 | ariadna | 62 |     public function create_secret(int $expires, bool $session, ?string $secret = null): string {
 | 
        
           | 1 | efrain | 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 |      */
 | 
        
           | 1441 | ariadna | 91 |     private function add_secret_to_db(string $secret, int $expires, ?string $sessionid = null): void {
 | 
        
           | 1 | efrain | 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 | }
 |