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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body