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