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\output; 18 19 use tool_mfa\local\factor\object_factor; 20 use tool_mfa\local\form\login_form; 21 use \html_writer; 22 use tool_mfa\plugininfo\factor; 23 24 /** 25 * MFA renderer. 26 * 27 * @package tool_mfa 28 * @author Mikhail Golenkov <golenkovm@gmail.com> 29 * @copyright Catalyst IT 30 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 31 */ 32 class renderer extends \plugin_renderer_base { 33 34 /** 35 * Returns the state of the factor as a badge. 36 * 37 * @param string $state 38 * @return string 39 */ 40 public function get_state_badge(string $state): string { 41 42 switch ($state) { 43 case factor::STATE_PASS: 44 return html_writer::tag('span', get_string('state:pass', 'tool_mfa'), ['class' => 'badge badge-success']); 45 46 case factor::STATE_FAIL: 47 return html_writer::tag('span', get_string('state:fail', 'tool_mfa'), ['class' => 'badge badge-danger']); 48 49 case factor::STATE_NEUTRAL: 50 return html_writer::tag('span', get_string('state:neutral', 'tool_mfa'), ['class' => 'badge badge-warning']); 51 52 case factor::STATE_UNKNOWN: 53 return html_writer::tag('span', get_string('state:unknown', 'tool_mfa'), 54 ['class' => 'badge badge-secondary']); 55 56 case factor::STATE_LOCKED: 57 return html_writer::tag('span', get_string('state:locked', 'tool_mfa'), ['class' => 'badge badge-error']); 58 59 default: 60 return html_writer::tag('span', get_string('pending', 'tool_mfa'), ['class' => 'badge badge-secondary']); 61 } 62 } 63 64 /** 65 * Returns a list of factors which a user can add. 66 * 67 * @return string 68 */ 69 public function available_factors(): string { 70 $html = $this->output->heading(get_string('preferences:availablefactors', 'tool_mfa'), 2); 71 72 $factors = factor::get_enabled_factors(); 73 foreach ($factors as $factor) { 74 // TODO is_configured / is_ready. 75 if (!$factor->has_setup() || !$factor->show_setup_buttons()) { 76 continue; 77 } 78 $html .= $this->setup_factor($factor); 79 } 80 81 return $html; 82 } 83 84 /** 85 * Returns the html section for factor setup 86 * 87 * @param object $factor object of the factor class 88 * @return string 89 */ 90 public function setup_factor(object $factor): string { 91 $html = ''; 92 93 $html .= html_writer::start_tag('div', ['class' => 'card']); 94 95 $html .= html_writer::tag('h4', $factor->get_display_name(), ['class' => 'card-header']); 96 $html .= html_writer::start_tag('div', ['class' => 'card-body']); 97 $html .= $factor->get_info(); 98 99 $setupparams = ['action' => 'setup', 'factor' => $factor->name, 'sesskey' => sesskey()]; 100 $setupurl = new \moodle_url('action.php', $setupparams); 101 $html .= $this->output->single_button($setupurl, $factor->get_setup_string()); 102 $html .= html_writer::end_tag('div'); 103 $html .= html_writer::end_tag('div'); 104 $html .= '<br>'; 105 106 return $html; 107 } 108 109 /** 110 * Defines section with active user's factors. 111 * 112 * @return string $html 113 * @throws \coding_exception 114 */ 115 public function active_factors(): string { 116 global $USER, $CFG; 117 118 require_once($CFG->dirroot . '/iplookup/lib.php'); 119 120 $html = $this->output->heading(get_string('preferences:activefactors', 'tool_mfa'), 2); 121 122 $headers = get_strings([ 123 'factor', 124 'devicename', 125 'created', 126 'createdfromip', 127 'lastverified', 128 'revoke', 129 ], 'tool_mfa'); 130 131 $table = new \html_table(); 132 $table->id = 'active_factors'; 133 $table->attributes['class'] = 'generaltable table table-bordered'; 134 $table->head = [ 135 $headers->factor, 136 $headers->devicename, 137 $headers->created, 138 $headers->createdfromip, 139 $headers->lastverified, 140 $headers->revoke, 141 ]; 142 $table->colclasses = [ 143 'leftalign', 144 'leftalign', 145 'centeralign', 146 'centeralign', 147 'centeralign', 148 'centeralign', 149 'centeralign', 150 'centeralign', 151 ]; 152 $table->data = []; 153 154 $factors = factor::get_enabled_factors(); 155 156 foreach ($factors as $factor) { 157 $userfactors = $factor->get_active_user_factors($USER); 158 159 if (!$factor->has_setup()) { 160 continue; 161 } 162 163 foreach ($userfactors as $userfactor) { 164 if ($factor->has_revoke()) { 165 $revokeparams = [ 166 'action' => 'revoke', 'factor' => $factor->name, 167 'factorid' => $userfactor->id, 'sesskey' => sesskey(), 168 ]; 169 $revokeurl = new \moodle_url('action.php', $revokeparams); 170 $revokelink = \html_writer::link($revokeurl, $headers->revoke); 171 } else { 172 $revokelink = ''; 173 } 174 175 $timecreated = $userfactor->timecreated == '-' ? '-' 176 : userdate($userfactor->timecreated, get_string('strftimedatetime')); 177 $lastverified = $userfactor->lastverified; 178 if ($lastverified == 0) { 179 $lastverified = '-'; 180 } else if ($lastverified != '-') { 181 $lastverified = userdate($userfactor->lastverified, get_string('strftimedatetime')); 182 $lastverified .= '<br>'; 183 $lastverified .= get_string('ago', 'core_message', format_time(time() - $userfactor->lastverified)); 184 } 185 186 $info = iplookup_find_location($userfactor->createdfromip); 187 $ip = $userfactor->createdfromip; 188 $ip .= '<br>' . $info['country'] . ' - ' . $info['city']; 189 190 $row = new \html_table_row([ 191 $factor->get_display_name(), 192 $userfactor->label, 193 $timecreated, 194 $ip, 195 $lastverified, 196 $revokelink, 197 ]); 198 $table->data[] = $row; 199 } 200 } 201 // If table has no data, don't output. 202 if (count($table->data) == 0) { 203 return ''; 204 } 205 $html .= \html_writer::table($table); 206 $html .= '<br>'; 207 208 return $html; 209 } 210 211 /** 212 * Generates notification text for display when user cannot login. 213 * 214 * @return string $notification 215 */ 216 public function not_enough_factors(): string { 217 global $CFG, $SITE; 218 219 $notification = \html_writer::tag('h4', get_string('error:notenoughfactors', 'tool_mfa')); 220 $notification .= \html_writer::tag('p', get_string('error:reauth', 'tool_mfa')); 221 222 // Support link. 223 $supportemail = $CFG->supportemail; 224 if (!empty($supportemail)) { 225 $subject = get_string('email:subject', 'tool_mfa', $SITE->fullname); 226 $maillink = \html_writer::link("mailto:$supportemail?Subject=$subject", $supportemail); 227 $notification .= get_string('error:support', 'tool_mfa'); 228 $notification .= \html_writer::tag('p', $maillink); 229 } 230 231 // Support page link. 232 $supportpage = $CFG->supportpage; 233 if (!empty($supportpage)) { 234 $linktext = \html_writer::link($supportpage, $supportpage); 235 $notification .= $linktext; 236 } 237 $return = $this->output->notification($notification, 'notifyerror', false); 238 239 // Logout button. 240 $url = new \moodle_url('/admin/tool/mfa/auth.php', ['logout' => 1]); 241 $btn = new \single_button($url, get_string('logout'), 'post', \single_button::BUTTON_PRIMARY); 242 $return .= $this->render($btn); 243 244 $return .= $this->get_support_link(); 245 246 return $return; 247 } 248 249 /** 250 * Displays a table of all factors in use currently. 251 * 252 * @param int $lookback the period to view. 253 * @return string the HTML for the table 254 */ 255 public function factors_in_use_table(int $lookback): string { 256 global $DB; 257 258 $factors = factor::get_factors(); 259 260 // Setup 2 arrays, one with internal names, one pretty. 261 $columns = ['']; 262 $displaynames = $columns; 263 $colclasses = ['center', 'center', 'center', 'center', 'center']; 264 265 // Force the first 4 columns to custom data. 266 $displaynames[] = get_string('totalusers', 'tool_mfa'); 267 $displaynames[] = get_string('usersauthedinperiod', 'tool_mfa'); 268 $displaynames[] = get_string('nonauthusers', 'tool_mfa'); 269 $displaynames[] = get_string('nologinusers', 'tool_mfa'); 270 271 foreach ($factors as $factor) { 272 $columns[] = $factor->name; 273 $displaynames[] = get_string('pluginname', 'factor_'.$factor->name); 274 $colclasses[] = 'right'; 275 } 276 277 // Add total column to the end. 278 $displaynames[] = get_string('total'); 279 $colclasses[] = 'center'; 280 281 $table = new \html_table(); 282 $table->head = $displaynames; 283 $table->align = $colclasses; 284 $table->attributes['class'] = 'generaltable table table-bordered w-auto'; 285 $table->attributes['style'] = 'width: auto; min-width: 50%; margin-bottom: 0;'; 286 287 // Manually handle Total users and MFA users. 288 $alluserssql = "SELECT auth, 289 COUNT(id) 290 FROM {user} 291 WHERE deleted = 0 292 AND suspended = 0 293 GROUP BY auth"; 294 $allusersinfo = $DB->get_records_sql($alluserssql, []); 295 296 $noncompletesql = "SELECT u.auth, COUNT(u.id) 297 FROM {user} u 298 LEFT JOIN {tool_mfa_auth} mfaa ON u.id = mfaa.userid 299 WHERE u.lastlogin >= ? 300 AND (mfaa.lastverified < ? 301 OR mfaa.lastverified IS NULL) 302 GROUP BY u.auth"; 303 $noncompleteinfo = $DB->get_records_sql($noncompletesql, [$lookback, $lookback]); 304 305 $nologinsql = "SELECT auth, COUNT(id) 306 FROM {user} 307 WHERE deleted = 0 308 AND suspended = 0 309 AND lastlogin < ? 310 GROUP BY auth"; 311 $nologininfo = $DB->get_records_sql($nologinsql, [$lookback]); 312 313 $mfauserssql = "SELECT auth, 314 COUNT(DISTINCT tm.userid) 315 FROM {tool_mfa} tm 316 JOIN {user} u ON u.id = tm.userid 317 WHERE tm.lastverified >= ? 318 AND u.deleted = 0 319 AND u.suspended = 0 320 GROUP BY u.auth"; 321 $mfausersinfo = $DB->get_records_sql($mfauserssql, [$lookback]); 322 323 $factorsusedsql = "SELECT CONCAT(u.auth, '_', tm.factor) as id, 324 COUNT(*) 325 FROM {tool_mfa} tm 326 JOIN {user} u ON u.id = tm.userid 327 WHERE tm.lastverified >= ? 328 AND u.deleted = 0 329 AND u.suspended = 0 330 AND (tm.revoked = 0 OR (tm.revoked = 1 AND tm.timemodified > ?)) 331 GROUP BY CONCAT(u.auth, '_', tm.factor)"; 332 $factorsusedinfo = $DB->get_records_sql($factorsusedsql, [$lookback, $lookback]); 333 334 // Auth rows. 335 $authtypes = get_enabled_auth_plugins(true); 336 $row = []; 337 foreach ($authtypes as $authtype) { 338 $row[] = \html_writer::tag('b', $authtype); 339 340 // Setup the overall totals columns. 341 $row[] = $allusersinfo[$authtype]->count ?? '-'; 342 $row[] = $mfausersinfo[$authtype]->count ?? '-'; 343 $row[] = $noncompleteinfo[$authtype]->count ?? '-'; 344 $row[] = $nologininfo[$authtype]->count ?? '-'; 345 346 // Create a running counter for the total. 347 $authtotal = 0; 348 349 // Now for each factor add the count from the factor query, and increment the running total. 350 foreach ($columns as $column) { 351 if (!empty($column)) { 352 // Get the information from the data key. 353 $key = $authtype . '_' . $column; 354 $count = $factorsusedinfo[$key]->count ?? 0; 355 $authtotal += $count; 356 357 $row[] = $count ? format_float($count, 0) : '-'; 358 } 359 } 360 361 // Append the total of all factors to final column. 362 $row[] = $authtotal ? format_float($authtotal, 0) : '-'; 363 364 $table->data[] = $row; 365 } 366 367 // Total row. 368 $totals = [0 => html_writer::tag('b', get_string('total'))]; 369 for ($colcounter = 1; $colcounter < count($row); $colcounter++) { 370 $column = array_column($table->data, $colcounter); 371 // Transform string to int forcibly, remove -. 372 $column = array_map(function ($element) { 373 return $element === '-' ? 0 : (int) $element; 374 }, $column); 375 $columnsum = array_sum($column); 376 $colvalue = $columnsum === 0 ? '-' : $columnsum; 377 $totals[$colcounter] = $colvalue; 378 } 379 $table->data[] = $totals; 380 381 // Wrap in a div to cleanly scroll. 382 return \html_writer::div(\html_writer::table($table), '', ['style' => 'overflow:auto;']); 383 } 384 385 /** 386 * Displays a table of all factors in use currently. 387 * 388 * @return string the HTML for the table 389 */ 390 public function factors_locked_table(): string { 391 global $DB; 392 393 $factors = factor::get_factors(); 394 395 $table = new \html_table(); 396 397 $table->attributes['class'] = 'generaltable table table-bordered w-auto'; 398 $table->attributes['style'] = 'width: auto; min-width: 50%'; 399 400 $table->head = [ 401 'factor' => get_string('factor', 'tool_mfa'), 402 'active' => get_string('active'), 403 'locked' => get_string('state:locked', 'tool_mfa'), 404 'actions' => get_string('actions'), 405 ]; 406 $table->align = [ 407 'left', 408 'left', 409 'right', 410 'right', 411 ]; 412 $table->data = []; 413 $locklevel = (int) get_config('tool_mfa', 'lockout'); 414 415 foreach ($factors as $factor) { 416 $sql = "SELECT COUNT(DISTINCT(userid)) 417 FROM {tool_mfa} 418 WHERE factor = ? 419 AND lockcounter >= ? 420 AND revoked = 0"; 421 $lockedusers = $DB->count_records_sql($sql, [$factor->name, $locklevel]); 422 $enabled = $factor->is_enabled() ? \html_writer::tag('b', get_string('yes')) : get_string('no'); 423 424 $actions = \html_writer::link( new \moodle_url($this->page->url, 425 ['reset' => $factor->name, 'sesskey' => sesskey()]), get_string('performbulk', 'tool_mfa')); 426 $lockedusers = \html_writer::link(new \moodle_url($this->page->url, ['view' => $factor->name]), $lockedusers); 427 428 $table->data[] = [ 429 $factor->get_display_name(), 430 $enabled, 431 $lockedusers, 432 $actions, 433 ]; 434 } 435 436 return \html_writer::table($table); 437 } 438 439 /** 440 * Displays a table of all users with a locked instance of the given factor. 441 * 442 * @param object_factor $factor the factor class 443 * @return string the HTML for the table 444 */ 445 public function factor_locked_users_table(object_factor $factor): string { 446 global $DB; 447 448 $table = new \html_table(); 449 $table->attributes['class'] = 'generaltable table table-bordered w-auto'; 450 $table->attributes['style'] = 'width: auto; min-width: 50%'; 451 $table->head = [ 452 'userid' => get_string('userid', 'grades'), 453 'fullname' => get_string('fullname'), 454 'factorip' => get_string('ipatcreation', 'tool_mfa'), 455 'lastip' => get_string('lastip'), 456 'modified' => get_string('modified'), 457 'actions' => get_string('actions'), 458 ]; 459 $table->align = [ 460 'left', 461 'left', 462 'left', 463 'left', 464 'left', 465 'right', 466 ]; 467 $table->data = []; 468 469 $locklevel = (int) get_config('tool_mfa', 'lockout'); 470 $sql = "SELECT mfa.id as mfaid, u.*, mfa.createdfromip, mfa.timemodified 471 FROM {tool_mfa} mfa 472 JOIN {user} u ON mfa.userid = u.id 473 WHERE factor = ? 474 AND lockcounter >= ? 475 AND revoked = 0"; 476 $records = $DB->get_records_sql($sql, [$factor->name, $locklevel]); 477 478 foreach ($records as $record) { 479 // Construct profile link. 480 $proflink = \html_writer::link(new \moodle_url('/user/profile.php', 481 ['id' => $record->id]), fullname($record)); 482 483 // IP link. 484 $creatediplink = \html_writer::link(new \moodle_url('/iplookup/index.php', 485 ['ip' => $record->createdfromip]), $record->createdfromip); 486 $lastiplink = \html_writer::link(new \moodle_url('/iplookup/index.php', 487 ['ip' => $record->lastip]), $record->lastip); 488 489 // Deep link to logs. 490 $logicon = $this->pix_icon('i/report', get_string('userlogs', 'tool_mfa')); 491 $actions = \html_writer::link(new \moodle_url('/report/log/index.php', [ 492 'id' => 1, // Site. 493 'user' => $record->id, 494 ]), $logicon); 495 496 $action = new \confirm_action(get_string('resetfactorconfirm', 'tool_mfa', fullname($record))); 497 $actions .= $this->action_link( 498 new \moodle_url($this->page->url, ['reset' => $factor->name, 'id' => $record->id, 'sesskey' => sesskey()]), 499 $this->pix_icon('t/delete', get_string('resetconfirm', 'tool_mfa')), 500 $action 501 ); 502 503 $table->data[] = [ 504 $record->id, 505 $proflink, 506 $creatediplink, 507 $lastiplink, 508 userdate($record->timemodified, get_string('strftimedatetime', 'langconfig')), 509 $actions, 510 ]; 511 } 512 513 return \html_writer::table($table); 514 } 515 516 /** 517 * Returns a rendered support link. 518 * If the MFA guidance page is enabled, this is returned. 519 * Otherwise, the site support link is returned. 520 * If neither support link is configured, an empty string is returned. 521 * 522 * @return string 523 */ 524 public function get_support_link(): string { 525 // Try the guidance page link first. 526 if (get_config('tool_mfa', 'guidance')) { 527 return $this->render_from_template('tool_mfa/guide_link', []); 528 } else { 529 return $this->output->supportemail([], true); 530 } 531 } 532 533 /** 534 * Renders an mform element from a template 535 * 536 * In certain situations, includes a script element which adds autosubmission behaviour. 537 * 538 * @param mixed $element element 539 * @param bool $required if input is required field 540 * @param bool $advanced if input is an advanced field 541 * @param string|null $error error message to display 542 * @param bool $ingroup True if this element is rendered as part of a group 543 * @return mixed string|bool 544 */ 545 public function mform_element(mixed $element, bool $required, 546 bool $advanced, string|null $error, bool $ingroup): string|bool { 547 $script = null; 548 if ($element instanceof \tool_mfa\local\form\verification_field) { 549 if ($this->page->pagelayout === 'secure') { 550 $script = $element->secure_js(); 551 } 552 } 553 554 $result = parent::mform_element($element, $required, $advanced, $error, $ingroup); 555 556 if (!empty($script) && $result !== false) { 557 $result .= $script; 558 } 559 560 return $result; 561 } 562 563 /** 564 * Renders the verification form. 565 * 566 * @param object_factor $factor The factor to render the form for. 567 * @param login_form $form The login form object. 568 * @return string 569 * @throws \coding_exception 570 * @throws \dml_exception 571 * @throws \moodle_exception 572 */ 573 public function verification_form(object_factor $factor, login_form $form): string { 574 $allloginfactors = factor::get_all_user_login_factors(); 575 $additionalfactors = []; 576 $disabledfactors = []; 577 $displaycount = 0; 578 $disablefactor = false; 579 580 foreach ($allloginfactors as $loginfactor) { 581 if ($loginfactor->name != $factor->name) { 582 $additionalfactor = [ 583 'name' => $loginfactor->name, 584 'icon' => $loginfactor->get_icon(), 585 'loginoption' => get_string('loginoption', 'factor_' . $loginfactor->name), 586 ]; 587 // We mark the factor as disabled if it is locked. 588 // We store the disabled factors in a separate array so that they can be displayed at the bottom of the template. 589 if ($loginfactor->get_state() == factor::STATE_LOCKED) { 590 $additionalfactor['loginoption'] = get_string('locked', 'tool_mfa', $additionalfactor['loginoption']); 591 $additionalfactor['disable'] = true; 592 $disabledfactors[] = $additionalfactor; 593 } else { 594 $additionalfactors[] = $additionalfactor; 595 } 596 $displaycount++; 597 } 598 } 599 600 // We merge the additional factors placing the disabled ones last. 601 $alladitionalfactors = array_merge($additionalfactors, $disabledfactors); 602 $hasadditionalfactors = $displaycount > 0; 603 $authurl = new \moodle_url('/admin/tool/mfa/auth.php'); 604 605 // Set the form to better display vertically. 606 $form->set_display_vertical(); 607 608 // Check if we need to display a remaining attempts message. 609 $remattempts = $factor->get_remaining_attempts(); 610 $verificationerror = $form->get_element_error('verificationcode'); 611 if ($remattempts < get_config('tool_mfa', 'lockout') && !empty($verificationerror)) { 612 // Update the validation error for the code form field to include the remaining attempts. 613 $remattemptsstr = get_string('lockoutnotification', 'tool_mfa', $factor->get_remaining_attempts()); 614 $updatederror = $verificationerror . ' ' . $remattemptsstr; 615 $form->set_element_error('verificationcode', $updatederror); 616 } 617 618 // If all attempts for this factor have been used, disable the form. 619 // This forces the user to choose another factor or cancel their login. 620 if ($remattempts <= 0) { 621 $disablefactor = true; 622 $form->freeze('verificationcode'); 623 624 // Handle the trust factor if present. 625 if ($form->element_exists('factor_token_trust')) { 626 $form->freeze('factor_token_trust'); 627 } 628 } 629 630 $context = [ 631 'logintitle' => get_string('logintitle', 'factor_'.$factor->name), 632 'logindesc' => $factor->get_login_desc(), 633 'factoricon' => $factor->get_icon(), 634 'form' => $form->render(), 635 'hasadditionalfactors' => $hasadditionalfactors, 636 'additionalfactors' => $alladitionalfactors, 637 'authurl' => $authurl->out(), 638 'supportlink' => $this->get_support_link(), 639 'disablefactor' => $disablefactor 640 ]; 641 return $this->render_from_template('tool_mfa/verification_form', $context); 642 } 643 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body