first commit

This commit is contained in:
CHIEFSOFT\ameye
2024-09-30 18:11:26 -04:00
commit e592ca6823
27270 changed files with 5002257 additions and 0 deletions
@@ -0,0 +1,97 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace tool_mfa\event;
use stdClass;
/**
* Event for when user factor is deleted.
*
* @property-read array $other {
* Extra information about event.
* }
*
* @package tool_mfa
* @author Peter Burnett <peterburnett@catalyst-au.net>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class user_deleted_factor extends \core\event\base {
/**
* Create instance of event.
*
* @param stdClass $user the User object of the User who had the factor deleted.
* @param stdClass $deleteuser the user who performed the factor delete.
* @param string $factorname deleted factor
*
* @return \core\event\base the user_factor_deleted event
*
* @throws \coding_exception
*/
public static function user_deleted_factor_event(stdClass $user, $deleteuser, $factorname): \core\event\base {
$data = [
'relateduserid' => $user->id,
'context' => \context_user::instance($user->id),
'other' => [
'userid' => $user->id,
'factorname' => $factorname,
'delete' => $deleteuser->id,
],
];
return self::create($data);
}
/**
* Init method.
*
* @return void
*/
protected function init(): void {
$this->data['crud'] = 'd';
$this->data['edulevel'] = self::LEVEL_OTHER;
}
/**
* Returns description of what happened.
*
* @return string
*/
public function get_description(): string {
// The log message changed from logging the deleter user object to the ID. This must be kept for backwards compat
// With old log events.
if (is_object($this->other['delete'])) {
return "The user with id '{$this->other['delete']->id}' successfully deleted
{$this->other['factorname']} factor for user with id '{$this->other['userid']}'";
} else {
return "The user with id '{$this->other['delete']}' successfully deleted
{$this->other['factorname']} factor for user with id '{$this->other['userid']}'";
}
}
/**
* Return localised event name.
*
* @return string
* @throws \coding_exception
*/
public static function get_name(): string {
return get_string('event:userdeletedfactor', 'tool_mfa');
}
}
@@ -0,0 +1,100 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace tool_mfa\event;
use stdClass;
/**
* Event for when user successfully passed all MFA factor checks.
*
* @property-read array $other {
* Extra information about event.
* }
*
* @package tool_mfa
* @author Peter Burnett <peterburnett@catalyst-au.net>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class user_failed_mfa extends \core\event\base {
/**
* Create instance of event.
*
* @param stdClass $user the User object of the User who failed MFA authentication.
*
* @return user_failed_mfa the user_passed_mfa event
*
* @throws \coding_exception
*/
public static function user_failed_mfa_event(stdClass $user): user_failed_mfa {
// Build debug info string.
$factors = \tool_mfa\plugininfo\factor::get_active_user_factor_types();
$debug = '';
$failurereason = get_string('event:failnotenoughfactors', 'tool_mfa');
foreach ($factors as $factor) {
$debug .= "<br> Factor {$factor->name} status: {$factor->get_state()}";
if ($factor->get_state() === \tool_mfa\plugininfo\factor::STATE_FAIL) {
$failurereason = get_string('event:failfactor', 'tool_mfa');
} else if ($factor->get_state() === \tool_mfa\plugininfo\factor::STATE_LOCKED) {
$failurereason = get_string('event:faillockout', 'tool_mfa');
}
}
$data = [
'relateduserid' => null,
'context' => \context_user::instance($user->id),
'other' => [
'userid' => $user->id,
'debug' => $debug,
'failurereason' => $failurereason,
],
];
return self::create($data);
}
/**
* Init method.
*
* @return void
*/
protected function init(): void {
$this->data['crud'] = 'r';
$this->data['edulevel'] = self::LEVEL_OTHER;
}
/**
* Returns description of what happened.
*
* @return string
*/
public function get_description(): string {
return "The user with id '{$this->other['userid']}' failed authenticating with MFA.
<br> Information: {$this->other['failurereason']}{$this->other['debug']}";
}
/**
* Return localised event name.
*
* @return string
* @throws \coding_exception
*/
public static function get_name(): string {
return get_string('event:userfailedmfa', 'tool_mfa');
}
}
@@ -0,0 +1,93 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace tool_mfa\event;
use stdClass;
/**
* Event for when user successfully passed all MFA factor checks.
*
* @property-read array $other {
* Extra information about event.
* }
*
* @package tool_mfa
* @author Mikhail Golenkov <golenkovm@gmail.com>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class user_passed_mfa extends \core\event\base {
/**
* Create instance of event.
*
* @param stdClass $user the User object of the User who passed all MFA factor checks.
*
* @return user_passed_mfa the user_passed_mfa event
*
* @throws \coding_exception
*/
public static function user_passed_mfa_event(stdClass $user): user_passed_mfa {
// Build debug info string.
$factors = \tool_mfa\plugininfo\factor::get_active_user_factor_types();
$debug = '';
foreach ($factors as $factor) {
$debug .= "<br> Factor {$factor->name} status: {$factor->get_state()}";
}
$data = [
'relateduserid' => null,
'context' => \context_user::instance($user->id),
'other' => [
'userid' => $user->id,
'debug' => $debug,
],
];
return self::create($data);
}
/**
* Init method.
*
* @return void
*/
protected function init(): void {
$this->data['crud'] = 'r';
$this->data['edulevel'] = self::LEVEL_OTHER;
}
/**
* Returns description of what happened.
*
* @return string
*/
public function get_description(): string {
return "The user with id '{$this->other['userid']}' successfully passed MFA. <br> Information: {$this->other['debug']}";
}
/**
* Return localised event name.
*
* @return string
* @throws \coding_exception
*/
public static function get_name(): string {
return get_string('event:userpassedmfa', 'tool_mfa');
}
}
@@ -0,0 +1,87 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace tool_mfa\event;
use stdClass;
/**
* Event for when user successfully revoked MFA Factor.
*
* @property-read array $other {
* Extra information about event.
* }
*
* @package tool_mfa
* @author Mikhail Golenkov <golenkovm@gmail.com>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class user_revoked_factor extends \core\event\base {
/**
* Create instance of event.
*
* @param stdClass $user the User object of the User who has revoked new factor
* @param string $factorname revoked factor
*
* @return self the related event
*
* @throws \coding_exception
*/
public static function user_revoked_factor_event(stdClass $user, $factorname): self {
$data = [
'relateduserid' => null,
'context' => \context_user::instance($user->id),
'other' => [
'userid' => $user->id,
'factorname' => $factorname,
],
];
return self::create($data);
}
/**
* Init method.
*
* @return void
*/
protected function init(): void {
$this->data['crud'] = 'd';
$this->data['edulevel'] = self::LEVEL_OTHER;
}
/**
* Returns description of what happened.
*
* @return string
*/
public function get_description(): string {
return "The user with id '{$this->other['userid']}' successfully revoked {$this->other['factorname']}";
}
/**
* Return localised event name.
*
* @return string
* @throws \coding_exception
*/
public static function get_name(): string {
return get_string('event:userrevokedfactor', 'tool_mfa');
}
}
@@ -0,0 +1,87 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace tool_mfa\event;
use stdClass;
/**
* Event for when user successfully setup new MFA Factor.
*
* @property-read array $other {
* Extra information about event.
* }
*
* @package tool_mfa
* @author Mikhail Golenkov <golenkovm@gmail.com>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class user_setup_factor extends \core\event\base {
/**
* Create instance of event.
*
* @param stdClass $user the User object of the User who has setup new factor
* @param string $factorname setup factor
*
* @return self the related event
*
* @throws \coding_exception
*/
public static function user_setup_factor_event(stdClass $user, $factorname): self {
$data = [
'relateduserid' => null,
'context' => \context_user::instance($user->id),
'other' => [
'userid' => $user->id,
'factorname' => $factorname,
],
];
return self::create($data);
}
/**
* Init method.
*
* @return void
*/
protected function init(): void {
$this->data['crud'] = 'c';
$this->data['edulevel'] = self::LEVEL_OTHER;
}
/**
* Returns description of what happened.
*
* @return string
*/
public function get_description(): string {
return "The user with id '{$this->other['userid']}' successfully setup {$this->other['factorname']}";
}
/**
* Return localised event name.
*
* @return string
* @throws \coding_exception
*/
public static function get_name(): string {
return get_string('event:usersetupfactor', 'tool_mfa');
}
}
@@ -0,0 +1,34 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace tool_mfa\hook;
use core\hook\stoppable_trait;
/**
* Allow plugins to callback as soon possible after user has passed MFA.
*
* @package tool_mfa
* @copyright 2024 Juan Leyva
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[\core\attribute\label('Allow plugins to callback as soon possible after user has passed MFA.')]
#[\core\attribute\tags('user', 'login')]
class after_user_passed_mfa implements
\Psr\EventDispatcher\StoppableEventInterface
{
use stoppable_trait;
}
@@ -0,0 +1,271 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace tool_mfa\local;
use tool_mfa\local\factor\object_factor_base;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir.'/ddllib.php');
require_once($CFG->libdir.'/xmlize.php');
require_once($CFG->libdir.'/messagelib.php');
/**
* Admin setting for MFA.
*
* @package tool_mfa
* @author Mikhail Golenkov <golenkovm@gmail.com>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class admin_setting_managemfa extends \admin_setting {
/**
* Calls parent::__construct with specific arguments
*/
public function __construct() {
$this->nosave = true;
parent::__construct('mfaui', get_string('mfasettings', 'tool_mfa'), '', '');
}
/**
* Always returns true
*
* @return bool
*/
public function get_setting(): bool {
return true;
}
/**
* Always returns '' and doesn't write anything
*
* @param mixed $data
* @return string Always returns ''
*/
public function write_setting($data): string {
return '';
}
/**
* Returns XHTML to display Manage MFA admin page.
*
* @param mixed $data Unused
* @param string $query
*
* @return string highlight
* @throws \coding_exception
* @throws \moodle_exception
*/
public function output_html($data, $query=''): string {
global $OUTPUT;
$return = $OUTPUT->box_start('generalbox');
$return .= $this->define_manage_mfa_table();
$return .= $OUTPUT->box_end();
$return .= $OUTPUT->heading(get_string('settings:combinations', 'tool_mfa'), 3);
$return .= $OUTPUT->box_start('generalbox');
$return .= $this->define_factor_combinations_table();
$return .= $OUTPUT->box_end();
return highlight($query, $return);
}
/**
* Defines main table with configurable factors.
*
* @return string HTML code
* @throws \coding_exception
* @throws \moodle_exception
*/
public function define_manage_mfa_table() {
global $OUTPUT;
$sesskey = sesskey();
$txt = get_strings(['enable', 'disable', 'moveup', 'movedown', 'order', 'settings']);
$txt->factor = get_string('factor', 'tool_mfa');
$txt->weight = get_string('weight', 'tool_mfa');
$txt->setup = get_string('setuprequired', 'tool_mfa');
$txt->input = get_string('inputrequired', 'tool_mfa');
$table = new \html_table();
$table->id = 'managemfatable';
$table->attributes['class'] = 'admintable generaltable';
$table->head = [
$txt->factor,
$txt->enable,
$txt->order,
$txt->weight,
$txt->settings,
$txt->setup,
$txt->input,
];
$table->colclasses = ['leftalign', 'centeralign', 'centeralign', 'centeralign', 'centeralign'];
$table->data = [];
$factors = \tool_mfa\plugininfo\factor::get_factors();
$enabledfactors = \tool_mfa\plugininfo\factor::get_enabled_factors();
$order = 1;
foreach ($factors as $factor) {
$settingsparams = ['section' => 'factor_'.$factor->name];
$settingsurl = new \moodle_url('settings.php', $settingsparams);
$settingslink = \html_writer::link($settingsurl, $txt->settings);
if ($factor->is_enabled()) {
$hideshowparams = ['action' => 'disable', 'factor' => $factor->name, 'sesskey' => $sesskey];
$hideshowurl = new \moodle_url('tool/mfa/index.php', $hideshowparams);
$hideshowlink = \html_writer::link($hideshowurl, $OUTPUT->pix_icon('t/hide', $txt->disable));
$class = '';
if ($order > 1) {
$upparams = ['action' => 'up', 'factor' => $factor->name, 'sesskey' => $sesskey];
$upurl = new \moodle_url('tool/mfa/index.php', $upparams);
$uplink = \html_writer::link($upurl, $OUTPUT->pix_icon('t/up', $txt->moveup));
} else {
$uplink = \html_writer::link('', $uplink = $OUTPUT->spacer(['style' => 'margin-right: .5rem']));
}
if ($order < count($enabledfactors)) {
$downparams = ['action' => 'down', 'factor' => $factor->name, 'sesskey' => $sesskey];
$downurl = new \moodle_url('tool/mfa/index.php', $downparams);
$downlink = \html_writer::link($downurl, $OUTPUT->pix_icon('t/down', $txt->movedown));
} else {
$downlink = '';
}
$updownlink = $uplink.$downlink;
$order++;
} else {
$hideshowparams = ['action' => 'enable', 'factor' => $factor->name, 'sesskey' => $sesskey];
$hideshowurl = new \moodle_url('tool/mfa/index.php', $hideshowparams);
$hideshowlink = \html_writer::link($hideshowurl, $OUTPUT->pix_icon('t/show', $txt->enable));
$class = 'dimmed_text';
$updownlink = '';
}
$hassetup = $factor->has_setup() ? get_string('yes') : get_string('no');
$hasinput = $factor->has_input() ? get_string('yes') : get_string('no');
$rowarray = [
$factor->get_display_name(),
$hideshowlink,
$updownlink,
$factor->get_weight(),
$settingslink,
$hassetup,
$hasinput,
];
$row = new \html_table_row($rowarray);
$row->attributes['class'] = $class;
$table->data[] = $row;
}
return \html_writer::table($table);
}
/**
* Defines supplementary table that shows available combinations of factors enough for successful authentication.
*
* @return string HTML code
*/
public function define_factor_combinations_table() {
global $OUTPUT;
$factors = \tool_mfa\plugininfo\factor::get_enabled_factors();
$combinations = $this->get_factor_combinations($factors, 0, count($factors) - 1);
if (empty($combinations)) {
return $OUTPUT->notification(get_string('error:notenoughfactors', 'tool_mfa'), 'notifyproblem');
}
$txt = get_strings(['combination', 'totalweight'], 'tool_mfa');
$table = new \html_table();
$table->id = 'managemfatable';
$table->attributes['class'] = 'admintable generaltable table table-bordered';
$table->head = [$txt->combination, $txt->totalweight];
$table->colclasses = ['leftalign', 'centeralign'];
$table->data = [];
$factorstringconnector = get_string('connector', 'tool_mfa');
foreach ($combinations as $combination) {
$factorstrings = array_map(static function(object_factor_base $factor): string {
return $factor->get_summary_condition() . ' <sup>' . $factor->get_weight() . '</sup>';
}, $combination['combination']);
$string = implode(" {$factorstringconnector} ", $factorstrings);
$table->data[] = new \html_table_row([$string, $combination['totalweight']]);
}
return \html_writer::table($table);
}
/**
* Recursive method to get all possible combinations of given factors.
* Output is filtered by combination total weight (should be greater than 100).
*
* @param array $allfactors initial array of factor objects
* @param int $start start position in initial array
* @param int $end end position in initial array
* @param int $totalweight total weight of combination
* @param array $combination combination candidate
* @param array $result array that includes combination total weight and subarray of factors combination
*
* @return array
*/
public function get_factor_combinations($allfactors, $start = 0, $end = 0,
$totalweight = 0, $combination = [], $result = []) {
if ($totalweight >= 100) {
// Ensure this is a valid combination before appending result.
$valid = true;
foreach ($combination as $factor) {
if (!$factor->check_combination($combination)) {
$valid = false;
}
}
if ($valid) {
$result[] = ['totalweight' => $totalweight, 'combination' => $combination];
}
return $result;
} else if ($start > $end) {
return $result;
}
$combinationnext = $combination;
$combinationnext[] = $allfactors[$start];
$result = $this->get_factor_combinations(
$allfactors,
$start + 1,
$end,
$totalweight + $allfactors[$start]->get_weight(),
$combinationnext,
$result);
$result = $this->get_factor_combinations(
$allfactors,
$start + 1,
$end,
$totalweight,
$combination,
$result);
return $result;
}
}
@@ -0,0 +1,67 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace tool_mfa\local\factor;
/**
* Fallback factor class.
*
* @package tool_mfa
* @author Peter Burnett <peterburnett@catalyst-au.net>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class fallback extends object_factor_base {
/**
* Overridden constructor. Name is hard set to 'fallback'.
*/
public function __construct() {
$this->name = 'fallback';
}
/**
* {@inheritDoc}
*/
public function get_display_name(): string {
return get_string('fallback', 'tool_mfa');
}
/**
* {@inheritDoc}
*/
public function get_info(): string {
return get_string('fallback_info', 'tool_mfa');
}
/**
* {@inheritDoc}
*/
public function get_state(): string {
return \tool_mfa\plugininfo\factor::STATE_FAIL;
}
/**
* Sets the state of the factor check into the session.
* Returns whether storing the var was successful.
*
* @param string $state
* @return bool
*/
public function set_state(string $state): bool {
return false;
}
}
@@ -0,0 +1,331 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* MFA factor interface.
*
* @package tool_mfa
* @author Mikhail Golenkov <golenkovm@gmail.com>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace tool_mfa\local\factor;
use stdClass;
interface object_factor {
/**
* Returns true if factor is enabled, otherwise false.
*
* @return bool
* @throws \dml_exception
*/
public function is_enabled(): bool;
/**
* Returns configured factor weight.
*
* @return int
* @throws \dml_exception
*/
public function get_weight(): int;
/**
* Returns factor name from language string.
*
* @return string
* @throws \coding_exception
*/
public function get_display_name(): string;
/**
* Returns factor info from language string.
*
* @return string
* @throws \coding_exception
*/
public function get_info(): string;
/**
* Defines setup_factor form definition page for particular factor.
*
* @param \MoodleQuickForm $mform
* @return \MoodleQuickForm $mform
* @throws \coding_exception
*/
public function setup_factor_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm;
/**
* Defines setup_factor form definition page after form data has been set.
*
* @param \MoodleQuickForm $mform
* @return \MoodleQuickForm $mform
* @throws \coding_exception
*/
public function setup_factor_form_definition_after_data(\MoodleQuickForm $mform): \MoodleQuickForm;
/**
* Implements setup_factor form validation for particular factor.
* Returns an array of errors, where array key = field id and array value = error text.
*
* @param array $data
* @return array
*/
public function setup_factor_form_validation(array $data): array;
/**
* Defines login form definition page for particular factor.
*
* @param \MoodleQuickForm $mform
* @return \MoodleQuickForm $mform
* @throws \coding_exception
*/
public function login_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm;
/**
* Defines login form definition page after form data has been set.
*
* @param \MoodleQuickForm $mform
* @return \MoodleQuickForm $mform
* @throws \coding_exception
*/
public function login_form_definition_after_data(\MoodleQuickForm $mform): \MoodleQuickForm;
/**
* Implements login form validation for particular factor.
* Returns an array of errors, where array key = field id and array value = error text.
*
* @param array $data
* @return array
*/
public function login_form_validation(array $data): array;
/**
* Setups in given factor when the form is cancelled
*
* @param int $factorid
* @return void
*/
public function setup_factor_form_is_cancelled(int $factorid): void;
/**
* Setup submit button string in given factor
*
* @return string|null
*/
public function setup_factor_form_submit_button_string(): ?string;
/**
* Setups given factor and adds it to user's active factors list.
* Returns true if factor has been successfully added, otherwise false.
*
* @param stdClass $data
* @return stdClass|null the factor record, or null.
*/
public function setup_user_factor(stdClass $data): stdClass|null;
/**
* Returns an array of all user factors of given type (both active and revoked).
*
* @param stdClass $user the user to check against.
* @return array
*/
public function get_all_user_factors(stdClass $user): array;
/**
* Returns an array of active user factor records.
* Filters get_all_user_factors() output.
*
* @param stdClass $user the user to check against.
* @return array
*/
public function get_active_user_factors(stdClass $user): array;
/**
* Returns true if factor class has factor records that might be revoked.
* It means that user can revoke factor record from their profile.
*
* @return bool
*/
public function has_revoke(): bool;
/**
* Marks factor record as revoked.
* If factorid is not provided, revoke all instances of factor.
*
* @param int|null $factorid
* @return bool
*/
public function revoke_user_factor(?int $factorid = null): bool;
/**
* When validation code is correct - update lastverified field for given factor.
* If factor id is not provided, update all factor entries for user.
*
* @param int|null $factorid
* @return bool|\dml_exception
*/
public function update_lastverified(?int $factorid = null): bool|\dml_exception;
/**
* Gets lastverified timestamp.
*
* @param int $factorid
* @return int|bool
*/
public function get_lastverified(int $factorid): int|bool;
/**
* Returns true if factor needs to be setup by user and has setup_form.
*
* @return bool
*/
public function has_setup(): bool;
/**
* If has_setup returns true, decides if the setup buttons should be shown on the preferences page.
*
* @return bool
*/
public function show_setup_buttons(): bool;
/**
* Returns true if factor requires user input for success or failure during login.
*
* @return bool
*/
public function has_input(): bool;
/**
* Returns the state of the factor check
*
* @return string
*/
public function get_state(): string;
/**
* Sets the state of the factor check into the session.
* Returns whether storing the var was successful.
*
* @param string $state
* @return bool
*/
public function set_state(string $state): bool;
/**
* Fires any additional actions required by the factor once the user reaches the pass state.
*
* @return void
*/
public function post_pass_state(): void;
/**
* Retrieves label for a factorid.
*
* @param int $factorid
* @return string|\dml_exception
*/
public function get_label(int $factorid): string|\dml_exception;
/**
* Returns a list of urls to not redirect from.
*
* @return array
*/
public function get_no_redirect_urls(): array;
/**
* Returns all possible states for a user.
*
* @param stdClass $user
* @return array
*/
public function possible_states(stdClass $user): array;
/**
* Return summary condition for passing factor.
*
* @return string
*/
public function get_summary_condition(): string;
/**
* Checks whether the factor combination is valid based on factor behaviour.
* E.g. a combination with nosetup and another factor is not valid,
* as you cannot pass nosetup with another factor.
*
* @param array $combination array of factors that make up the combination
* @return bool
*/
public function check_combination(array $combination): bool;
/**
* Gets the string for setup button on preferences page.
*
* @return string the string to display on the button.
*/
public function get_setup_string(): string;
/**
* Deletes all instances of a factor for user.
*
* @param stdClass $user the user to delete for.
* @return void
*/
public function delete_factor_for_user(stdClass $user): void;
/**
* Process a cancel action from a user.
*
* @return void
*/
public function process_cancel_action(): void;
/**
* Hook point for global auth form action hooks.
*
* @param \MoodleQuickForm $mform Form to inject global elements into.
* @return void
*/
public function global_definition(\MoodleQuickForm $mform): void;
/**
* Hook point for global auth form action hooks.
*
* @param \MoodleQuickForm $mform Form to inject global elements into.
* @return void
*/
public function global_definition_after_data(\MoodleQuickForm $mform): void;
/**
* Hook point for global auth form action hooks.
*
* @param array $data Data from the form.
* @param array $files Files form the form.
* @return array of errors from validation.
*/
public function global_validation(array $data, array $files): array;
/**
* Hook point for global auth form action hooks.
*
* @param object $data Data from the form.
* @return void
*/
public function global_submit(object $data): void;
}
@@ -0,0 +1,715 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace tool_mfa\local\factor;
use stdClass;
/**
* MFA factor abstract class.
*
* @package tool_mfa
* @author Mikhail Golenkov <golenkovm@gmail.com>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class object_factor_base implements object_factor {
/** @var string Factor name */
public $name;
/** @var int Lock counter */
private $lockcounter;
/**
* Secret manager
*
* @var \tool_mfa\local\secret_manager
*/
protected $secretmanager;
/** @var string Factor icon */
protected $icon = 'fa-lock';
/**
* Class constructor
*
* @param string $name factor name
*/
public function __construct($name) {
global $DB, $USER;
$this->name = $name;
// Setup secret manager.
$this->secretmanager = new \tool_mfa\local\secret_manager($this->name);
}
/**
* This loads the locked state from the DB
* Base class implementation.
*
* @return void
*/
public function load_locked_state(): void {
global $DB, $USER;
// Check if lockcounter column exists (incase upgrade hasnt run yet).
// Only 'input factors' are lockable.
if ($this->is_enabled() && $this->is_lockable()) {
try {
// Setup the lock counter.
$sql = "SELECT MAX(lockcounter) FROM {tool_mfa} WHERE userid = ? AND factor = ? AND revoked = ?";
@$this->lockcounter = $DB->get_field_sql($sql, [$USER->id, $this->name, 0]);
if (empty($this->lockcounter)) {
$this->lockcounter = 0;
}
// Now lock this factor if over the counter.
$lockthreshold = get_config('tool_mfa', 'lockout');
if ($this->lockcounter >= $lockthreshold) {
$this->set_state(\tool_mfa\plugininfo\factor::STATE_LOCKED);
}
} catch (\dml_exception $e) {
// Set counter to -1.
$this->lockcounter = -1;
}
}
}
/**
* Returns true if factor is enabled, otherwise false.
*
* Base class implementation.
*
* @return bool
* @throws \dml_exception
*/
public function is_enabled(): bool {
$status = get_config('factor_'.$this->name, 'enabled');
if ($status == 1) {
return true;
}
return false;
}
/**
* Returns configured factor weight.
*
* Base class implementation.
*
* @return int
* @throws \dml_exception
*/
public function get_weight(): int {
$weight = get_config('factor_'.$this->name, 'weight');
if ($weight) {
return (int) $weight;
}
return 0;
}
/**
* Returns factor name from language string.
*
* Base class implementation.
*
* @return string
* @throws \coding_exception
*/
public function get_display_name(): string {
return get_string('pluginname', 'factor_'.$this->name);
}
/**
* Returns factor help from language string.
*
* Base class implementation.
*
* @return string
* @throws \coding_exception
*/
public function get_info(): string {
return get_string('info', 'factor_'.$this->name);
}
/**
* Returns factor help from language string when there is factor management available.
*
* Base class implementation.
*
* @param int $factorid The factor we want manage info for.
* @return string
* @throws \coding_exception
*/
public function get_manage_info(int $factorid): string {
return get_string('manageinfo', 'factor_'.$this->name, $this->get_label($factorid));
}
/**
* Defines setup_factor form definition page for particular factor.
*
* Dummy implementation. Should be overridden in child class.
*
* @param \MoodleQuickForm $mform
* @return \MoodleQuickForm $mform
*/
public function setup_factor_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm {
return $mform;
}
/**
* Defines setup_factor form definition page after form data has been set.
*
* Dummy implementation. Should be overridden in child class.
*
* @param \MoodleQuickForm $mform
* @return \MoodleQuickForm $mform
*/
public function setup_factor_form_definition_after_data(\MoodleQuickForm $mform): \MoodleQuickForm {
return $mform;
}
/**
* Implements setup_factor form validation for particular factor.
* Returns an array of errors, where array key = field id and array value = error text.
*
* Dummy implementation. Should be overridden in child class.
*
* @param array $data
* @return array
*/
public function setup_factor_form_validation(array $data): array {
return [];
}
/**
* Setups in given factor when the form is cancelled
*
* Dummy implementation. Should be overridden in child class.
*
* @param int $factorid
* @return void
*/
public function setup_factor_form_is_cancelled(int $factorid): void {
}
/**
* Setup submit button string in given factor
*
* Dummy implementation. Should be overridden in child class.
*
* @return string|null
*/
public function setup_factor_form_submit_button_string(): ?string {
return null;
}
/**
* Setups given factor and adds it to user's active factors list.
* Returns true if factor has been successfully added, otherwise false.
*
* Dummy implementation. Should be overridden in child class.
*
* @param stdClass $data
* @return stdClass|null the record if created, or null.
*/
public function setup_user_factor(stdClass $data): stdClass|null {
return null;
}
/**
* Replaces a given factor and adds it to user's active factors list.
* Returns the new factor if it has been successfully replaced.
*
* Dummy implementation. Should be overridden in child class.
*
* @param stdClass $data The new factor data.
* @param int $id The id of the factor to replace.
* @return stdClass|null the record if created, or null.
*/
public function replace_user_factor(stdClass $data, int $id): stdClass|null {
return null;
}
/**
* Returns an array of all user factors of given type (both active and revoked).
*
* Dummy implementation. Should be overridden in child class.
*
* @param stdClass $user the user to check against.
* @return array
*/
public function get_all_user_factors(stdClass $user): array {
return [];
}
/**
* Returns an array of active user factor records.
* Filters get_all_user_factors() output.
*
* @param stdClass $user object to check against.
* @return array
*/
public function get_active_user_factors(stdClass $user): array {
$return = [];
$factors = $this->get_all_user_factors($user);
foreach ($factors as $factor) {
if ($factor->revoked == 0) {
$return[] = $factor;
}
}
return $return;
}
/**
* Defines login form definition page for particular factor.
*
* Dummy implementation. Should be overridden in child class.
*
* @param \MoodleQuickForm $mform
* @return \MoodleQuickForm $mform
*/
public function login_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm {
return $mform;
}
/**
* Defines login form definition page after form data has been set.
*
* Dummy implementation. Should be overridden in child class.
*
* @param \MoodleQuickForm $mform
* @return \MoodleQuickForm $mform
*/
public function login_form_definition_after_data(\MoodleQuickForm $mform): \MoodleQuickForm {
return $mform;
}
/**
* Implements login form validation for particular factor.
* Returns an array of errors, where array key = field id and array value = error text.
*
* Dummy implementation. Should be overridden in child class.
*
* @param array $data
* @return array
*/
public function login_form_validation(array $data): array {
return [];
}
/**
* Returns true if factor class has factor records that might be revoked.
* It means that user can revoke factor record from their profile.
*
* Override in child class if necessary.
*
* @return bool
*/
public function has_revoke(): bool {
return false;
}
/**
* Marks factor record as revoked.
* If factorid is not provided, revoke all instances of factor.
*
* @param int|null $factorid
* @return bool
* @throws \coding_exception
* @throws \dml_exception
*/
public function revoke_user_factor(?int $factorid = null): bool {
global $DB, $USER;
if (!empty($factorid)) {
// If we have an explicit factor id, this means we need to be careful about the user.
$params = ['id' => $factorid];
$existing = $DB->get_record('tool_mfa', $params);
if (empty($existing)) {
return false;
}
$matchinguser = $existing->userid == $USER->id;
if (!is_siteadmin() && !$matchinguser) {
// We aren't admin, and this isn't our factor.
return false;
}
} else {
$params = ['userid' => $USER->id, 'factor' => $this->name];
}
$DB->set_field('tool_mfa', 'revoked', 1, $params);
$event = \tool_mfa\event\user_revoked_factor::user_revoked_factor_event($USER, $this->get_display_name());
$event->trigger();
return true;
}
/**
* Returns true if factor class has factor records that can be replaced.
*
* Override in child class if necessary.
*
* @return bool
*/
public function has_replace(): bool {
return false;
}
/**
* When validation code is correct - update lastverified field for given factor.
* If factor id is not provided, update all factor entries for user.
*
* @param int|null $factorid
* @return bool|\dml_exception
* @throws \dml_exception
*/
public function update_lastverified(?int $factorid = null): bool|\dml_exception {
global $DB, $USER;
if (!empty($factorid)) {
$params = ['id' => $factorid];
} else {
$params = ['factor' => $this->name, 'userid' => $USER->id];
}
return $DB->set_field('tool_mfa', 'lastverified', time(), $params);
}
/**
* Gets lastverified timestamp.
*
* @param int $factorid
* @return int|bool the lastverified timestamp, or false if not found.
*/
public function get_lastverified(int $factorid): int|bool {
global $DB;
$record = $DB->get_record('tool_mfa', ['id' => $factorid]);
return $record->lastverified;
}
/**
* Returns true if factor needs to be setup by user and has setup_form.
* Override in child class if necessary.
*
* @return bool
*/
public function has_setup(): bool {
return false;
}
/**
* If has_setup returns true, decides if the setup buttons should be shown on the preferences page.
*
* @return bool
*/
public function show_setup_buttons(): bool {
return $this->has_setup();
}
/**
* Returns true if a factor requires input from the user to verify.
*
* Override in child class if necessary
*
* @return bool
*/
public function has_input(): bool {
return true;
}
/**
* Returns true if a factor is able to be locked if it fails.
*
* Generally only input factors are lockable.
* Override in child class if necessary
*
* @return bool
*/
public function is_lockable(): bool {
return $this->has_input();
}
/**
* Returns the state of the factor from session information.
*
* Implementation for factors that require input.
* Should be overridden in child classes with no input.
*
* @return mixed
*/
public function get_state(): string {
global $SESSION;
$property = 'factor_'.$this->name;
if (property_exists($SESSION, $property)) {
return $SESSION->$property;
} else {
return \tool_mfa\plugininfo\factor::STATE_UNKNOWN;
}
}
/**
* Sets the state of the factor into the session.
*
* Implementation for factors that require input.
* Should be overridden in child classes with no input.
*
* @param string $state the state constant to set.
* @return bool
*/
public function set_state(string $state): bool {
global $SESSION;
// Do not allow overwriting fail states.
if ($this->get_state() == \tool_mfa\plugininfo\factor::STATE_FAIL) {
return false;
}
$property = 'factor_'.$this->name;
$SESSION->$property = $state;
return true;
}
/**
* Creates an event when user successfully setup a factor
*
* @param object $user
* @return void
*/
public function create_event_after_factor_setup(object $user): void {
$event = \tool_mfa\event\user_setup_factor::user_setup_factor_event($user, $this->get_display_name());
$event->trigger();
}
/**
* Function for factor actions in the pass state.
* Override in child class if necessary.
*
* @return void
*/
public function post_pass_state(): void {
// Update lastverified for factor.
if ($this->get_state() == \tool_mfa\plugininfo\factor::STATE_PASS) {
$this->update_lastverified();
}
// Now clean temp secrets for factor.
$this->secretmanager->cleanup_temp_secrets();
}
/**
* Function to retrieve the label for a factorid.
*
* @param int $factorid
* @return string|\dml_exception
*/
public function get_label(int $factorid): string|\dml_exception {
global $DB;
return $DB->get_field('tool_mfa', 'label', ['id' => $factorid]);
}
/**
* Function to get urls that should not be redirected from.
*
* @return array
*/
public function get_no_redirect_urls(): array {
return [];
}
/**
* Function to get possible states for a user from factor.
* Implementation where state is based on deterministic user data.
* This should be overridden in factors where state is non-deterministic.
* E.g. IP changes based on whether a user is using a VPN.
*
* @param stdClass $user
* @return array
*/
public function possible_states(stdClass $user): array {
return [$this->get_state()];
}
/**
* Returns condition for passing factor.
* Implementation for basic conditions.
* Override for complex conditions such as auth type.
*
* @return string
*/
public function get_summary_condition(): string {
return get_string('summarycondition', 'factor_'.$this->name);
}
/**
* Checks whether the factor combination is valid based on factor behaviour.
* E.g. a combination with nosetup and another factor is not valid,
* as you cannot pass nosetup with another factor.
*
* @param array $combination array of factors that make up the combination
* @return bool
*/
public function check_combination(array $combination): bool {
return true;
}
/**
* Gets the string for setup button on preferences page.
*
* @return string
*/
public function get_setup_string(): string {
return get_string('setupfactor', 'tool_mfa');
}
/**
* Gets the string for manage button on preferences page.
*
* @return string
*/
public function get_manage_string(): string {
return get_string('managefactor', 'tool_mfa');
}
/**
* Deletes all instances of factor for a user.
*
* @param stdClass $user the user to delete for.
* @return void
*/
public function delete_factor_for_user(stdClass $user): void {
global $DB, $USER;
$DB->delete_records('tool_mfa', ['userid' => $user->id, 'factor' => $this->name]);
// Emit event for deletion.
$event = \tool_mfa\event\user_deleted_factor::user_deleted_factor_event($user, $USER, $this->name);
$event->trigger();
}
/**
* Increments the lock counter for a factor.
*
* @return void
*/
public function increment_lock_counter(): void {
global $DB, $USER;
// First make sure the state is loaded.
$this->load_locked_state();
// If lockcounter is negative, the field does not exist yet.
if ($this->lockcounter === -1) {
return;
}
$this->lockcounter++;
// Update record in DB.
$DB->set_field('tool_mfa', 'lockcounter', $this->lockcounter, ['userid' => $USER->id, 'factor' => $this->name]);
// Now lock this factor if over the counter.
$lockthreshold = get_config('tool_mfa', 'lockout');
if ($this->lockcounter >= $lockthreshold) {
$this->set_state(\tool_mfa\plugininfo\factor::STATE_LOCKED);
}
}
/**
* Return the number of remaining attempts at this factor.
*
* @return int the number of attempts at this factor remaining.
*/
public function get_remaining_attempts(): int {
$lockthreshold = get_config('tool_mfa', 'lockout');
if ($this->lockcounter === -1) {
// If upgrade.php hasnt been run yet, just return 10.
return $lockthreshold;
} else {
return $lockthreshold - $this->lockcounter;
}
}
/**
* Process a cancel input from a user.
*
* @return void
*/
public function process_cancel_action(): void {
$this->set_state(\tool_mfa\plugininfo\factor::STATE_NEUTRAL);
}
/**
* Hook point for global auth form action hooks.
*
* @param \MoodleQuickForm $mform Form to inject global elements into.
* @return void
*/
public function global_definition(\MoodleQuickForm $mform): void {
return;
}
/**
* Hook point for global auth form action hooks.
*
* @param \MoodleQuickForm $mform Form to inject global elements into.
* @return void
*/
public function global_definition_after_data(\MoodleQuickForm $mform): void {
return;
}
/**
* Hook point for global auth form action hooks.
*
* @param array $data Data from the form.
* @param array $files Files form the form.
* @return array of errors from validation.
*/
public function global_validation(array $data, array $files): array {
return [];
}
/**
* Hook point for global auth form action hooks.
*
* @param object $data Data from the form.
* @return void
*/
public function global_submit(object $data): void {
return;
}
/**
* Get the icon associated with this factor.
*
* @return string the icon name.
*/
public function get_icon(): string {
return $this->icon;
}
/**
* Get the login description associated with this factor.
* Override for factors that have a user input.
*
* @return string The login option.
*/
public function get_login_desc(): string {
return get_string('logindesc', 'factor_'.$this->name);
}
}
@@ -0,0 +1,56 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace tool_mfa\local\form;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir . "/formslib.php");
/**
* Factor action confirmation form.
*
* @package tool_mfa
* @author Mikhail Golenkov <golenkovm@gmail.com>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class factor_action_confirmation_form extends \moodleform {
/**
* Form definition.
*/
public function definition(): void {
$mform = $this->_form;
$factor = $this->_customdata['factor'];
$devicename = $this->_customdata['devicename'];
$factorid = $this->_customdata['factorid'];
$action = $this->_customdata['action'];
$mform->addElement('html', get_string('confirmation' . $action, 'tool_mfa', $devicename));
$mform->setType('factorid', PARAM_INT);
$mform->addElement('hidden', 'factorid', $factorid);
$mform->setType('factor', PARAM_TEXT);
$mform->addElement('hidden', 'factor', $factor);
$mform->setType('action', PARAM_TEXT);
$mform->addElement('hidden', 'action', $action);
$mform->addElement('hidden', 'sesskey', sesskey());
}
}
@@ -0,0 +1,90 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace tool_mfa\local\form;
use tool_mfa\plugininfo\factor;
/**
* MFA login form
*
* @package tool_mfa
* @author Mikhail Golenkov <golenkovm@gmail.com>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class global_form_manager {
/** @var array factors to call hooks upon. */
private $activefactors;
/**
* Create an instance of this class.
*/
public function __construct() {
$this->activefactors = factor::get_active_user_factor_types();
}
/**
* Hook point for global auth form action hooks.
*
* @param \MoodleQuickForm $mform Form to inject global elements into.
* @return void
*/
public function definition(\MoodleQuickForm &$mform): void {
foreach ($this->activefactors as $factor) {
$factor->global_definition($mform);
}
}
/**
* Hook point for global auth form action hooks.
*
* @param \MoodleQuickForm $mform Form to inject global elements into.
* @return void
*/
public function definition_after_data(\MoodleQuickForm &$mform): void {
foreach ($this->activefactors as $factor) {
$factor->global_definition_after_data($mform);
}
}
/**
* Hook point for global auth form action hooks.
*
* @param array $data Data from the form.
* @param array $files Files form the form.
* @return array of errors from validation.
*/
public function validation(array $data, array $files): array {
$errors = [];
foreach ($this->activefactors as $factor) {
$errors = array_merge($errors, $factor->global_validation($data, $files));
}
return $errors;
}
/**
* Hook point for global auth form submission hooks.
*
* @param \stdClass $data Data from the form.
* @return void
*/
public function submit(\stdClass $data): void {
foreach ($this->activefactors as $factor) {
$factor->global_submit($data);
}
}
}
@@ -0,0 +1,156 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace tool_mfa\local\form;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir . "/formslib.php");
/**
* MFA login form
*
* @package tool_mfa
* @author Mikhail Golenkov <golenkovm@gmail.com>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class login_form extends \moodleform {
/** @var \tool_mfa\local\form\global_form_manager */
public $globalmanager;
/**
* Create an instance of this class.
*
* @param mixed $action the action attribute for the form. If empty defaults to auto detect the
* current url. If a moodle_url object then outputs params as hidden variables.
* @param mixed $customdata if your form defintion method needs access to data such as $course
* $cm, etc. to construct the form definition then pass it in this array. You can
* use globals for somethings.
* @param string $method if you set this to anything other than 'post' then _GET and _POST will
* be merged and used as incoming data to the form.
* @param string $target target frame for form submission. You will rarely use this. Don't use
* it if you don't need to as the target attribute is deprecated in xhtml strict.
* @param mixed $attributes you can pass a string of html attributes here or an array.
* Special attribute 'data-random-ids' will randomise generated elements ids. This
* is necessary when there are several forms on the same page.
* Special attribute 'data-double-submit-protection' set to 'off' will turn off
* double-submit protection JavaScript - this may be necessary if your form sends
* downloadable files in response to a submit button, and can't call
* \core_form\util::form_download_complete();
* @param bool $editable
* @param array $ajaxformdata Forms submitted via ajax, must pass their data here, instead of relying on _GET and _POST.
*/
public function __construct($action = null, $customdata = null, $method = 'post', $target = '',
$attributes = null, $editable = true, $ajaxformdata = null) {
$this->globalmanager = new \tool_mfa\local\form\global_form_manager();
parent::__construct($action, $customdata, $method, $target, $attributes, $editable, $ajaxformdata);
}
/**
* {@inheritDoc}
* @see moodleform::definition()
*/
public function definition(): void {
$mform = $this->_form;
$factor = $this->_customdata['factor'];
$mform = $factor->login_form_definition($mform);
// Add a hidden field with the factor name so it is always available.
$factorname = $mform->addElement('hidden', 'factor', $factor->name);
$factorname->setType(PARAM_ALPHAEXT);
$this->globalmanager->definition($mform);
}
/**
* Invokes factor login_form_definition_after_data() method after form data has been set.
*
* @return void
*/
public function definition_after_data(): void {
$mform = $this->_form;
$factor = $this->_customdata['factor'];
$factor->login_form_definition_after_data($mform);
$this->globalmanager->definition_after_data($mform);
$buttonarray = [];
$buttonarray[] = &$mform->createElement('submit', 'submitbutton', get_string('loginsubmit', 'factor_' . $factor->name));
$mform->addGroup($buttonarray, 'buttonar', '', [' '], false);
$mform->closeHeaderBefore('buttonar');
}
/**
* Validates the login form with given factor validation method.
*
* @param array $data
* @param array $files
* @return array
*/
public function validation($data, $files) {
$errors = parent::validation($data, $files);
$factor = $this->_customdata['factor'];
$errors += $factor->login_form_validation($data);
$errors += $this->globalmanager->validation($data, $files);
// Execute sleep time bruteforce mitigation.
\tool_mfa\manager::sleep_timer();
return $errors;
}
/**
* Returns error corresponding to validated element.
*
* @param string $elementname Name of form element to check.
* @return string|null Error message corresponding to the validated element.
*/
public function get_element_error(string $elementname): ?string {
return $this->_form->getElementError($elementname);
}
/**
* Set an error message for a form element.
*
* @param string $elementname Name of form element to set error for.
* @param string $error Error message, if empty then removes the current error message.
* @return void
*/
public function set_element_error(string $elementname, string $error): void {
$this->_form->setElementError($elementname, $error);
}
/**
* Freeze a form element.
*
* @param string $elementname Name of form element to freeze.
* @return void
*/
public function freeze(string $elementname): void {
$this->_form->freeze($elementname);
}
/**
* Returns true if the form element exists.
*
* @param string $elementname Name of form element to check.
* @return bool
*/
public function element_exists(string $elementname): bool {
return $this->_form->elementExists($elementname);
}
}
@@ -0,0 +1,98 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace tool_mfa\local\form;
defined('MOODLE_INTERNAL') || die();
require_once("$CFG->libdir/formslib.php");
/**
* Form to reset gracemode timer for users.
*
* @package tool_mfa
* @author Peter Burnett <peterburnett@catalyst-au.net>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class reset_factor extends \moodleform {
/**
* Form definition.
*/
public function definition(): void {
$mform = $this->_form;
$factors = $this->_customdata['factors'];
$bulkaction = $this->_customdata['bulk'];
$mform->addElement('hidden', 'bulkaction', $bulkaction);
$mform->setType('bulkaction', PARAM_BOOL);
$mform->addElement('hidden', 'returnurl');
$mform->setType('returnurl', PARAM_LOCALURL);
$factors = array_map(function ($element) {
return $element->get_display_name();
}, $factors);
// Add an 'all' action.
$factors['all'] = get_string('all');
$mform->addElement('select', 'factor', get_string('selectfactor', 'tool_mfa'), $factors);
if (!$bulkaction) {
$mform->addElement('text', 'resetfactor', get_string('resetuser', 'tool_mfa'),
['placeholder' => get_string('resetfactorplaceholder', 'tool_mfa')]);
$mform->setType('resetfactor', PARAM_TEXT);
$mform->addRule('resetfactor', get_string('userempty', 'tool_mfa'), 'required');
}
$this->add_action_buttons(true, get_string('resetconfirm', 'tool_mfa'));
}
/**
* Form validation.
*
* Server side rules do not work for uploaded files, implement serverside rules here if needed.
*
* @param array $data array of ("fieldname"=>value) of submitted data
* @param array $files array of uploaded files "element_name"=>tmp_file_path
* @return array of "element_name"=>"error_description" if there are errors,
* or an empty array if everything is OK (true allowed for backwards compatibility too).
*/
public function validation($data, $files) {
global $DB;
$errors = parent::validation($data, $files);
if (!$data['bulkaction']) {
$userinfo = $data['resetfactor'];
// Try input as username first, then email.
$user = $DB->get_record('user', ['username' => $userinfo]);
if (empty($user)) {
// If not found, try username.
$user = $DB->get_record('user', ['email' => $userinfo]);
}
if (empty($user)) {
$errors['resetfactor'] = get_string('usernotfound', 'tool_mfa');
} else {
// Add custom field to store user.
$this->_form->addElement('hidden', 'user', $user);
$this->_form->setType('user', PARAM_RAW);
}
}
return $errors;
}
}
@@ -0,0 +1,100 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace tool_mfa\local\form;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir . "/formslib.php");
/**
* Setup factor form
*
* @package tool_mfa
* @author Mikhail Golenkov <golenkovm@gmail.com>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class setup_factor_form extends \moodleform {
/**
* {@inheritDoc}
* @see moodleform::definition()
*/
public function definition(): void {
$mform = $this->_form;
// Indicate a factor id that will be replaced with this setup.
$replaceid = $this->_customdata['replaceid'] ?? null;
if (!empty($replaceid)) {
$mform->addelement('hidden', 'replaceid', $replaceid);
$mform->setType('replaceid', PARAM_INT);
}
$factorname = $this->_customdata['factorname'];
$factor = \tool_mfa\plugininfo\factor::get_factor($factorname);
$mform = $factor->setup_factor_form_definition($mform);
$this->xss_whitelist_static_form_elements($mform);
}
/**
* Validates setup_factor form with given factor validation method.
*
* @param array $data
* @param array $files
* @return array
*/
public function validation($data, $files) {
$errors = parent::validation($data, $files);
$factorname = $this->_customdata['factorname'];
$factor = \tool_mfa\plugininfo\factor::get_factor($factorname);
$errors += $factor->setup_factor_form_validation($data);
return $errors;
}
/**
* Invokes factor setup_factor_form_definition_after_data() method after form data has been set.
*/
public function definition_after_data(): void {
$mform = $this->_form;
$factorname = $this->_customdata['factorname'];
$factor = \tool_mfa\plugininfo\factor::get_factor($factorname);
$mform = $factor->setup_factor_form_definition_after_data($mform);
$this->xss_whitelist_static_form_elements($mform);
$this->add_action_buttons(true, $factor->setup_factor_form_submit_button_string());
}
/**
* Form elements clean up
*
* @param \HTML_QuickForm $mform
* @return void
*/
private function xss_whitelist_static_form_elements($mform): void {
if (!method_exists('MoodleQuickForm_static', 'set_allow_xss')) {
return;
}
$elements = $mform->_elements;
foreach ($elements as $element) {
if (is_a($element, 'MoodleQuickForm_static')) {
$element->set_allow_xss(true);
}
}
}
}
@@ -0,0 +1,124 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace tool_mfa\local\form;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir . '/form/text.php');
/**
* MFA Verification code element.
*
* @package tool_mfa
* @author Peter Burnett <peterburnett@catalyst-au.net>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class verification_field extends \MoodleQuickForm_text {
/** @var bool */
private $appendjs;
/**
* Verification field is a text entry box that features some useful extras.
*
* Contains JS to autosubmit the auth page when code is entered, as well as additional styling.
*
* @param array $attributes
* @param boolean $auth is this constructed in auth.php loginform_* definitions. Set to false to prevent autosubmission of form.
* @param string|null $elementlabel Provide a different element label.
*/
public function __construct($attributes = null, $auth = true, string $elementlabel = null) {
global $PAGE;
// Force attributes.
if (empty($attributes)) {
$attributes = [];
}
$attributes['autocomplete'] = 'one-time-code';
$attributes['inputmode'] = 'numeric';
$attributes['pattern'] = '[0-9]*';
// Overwrite default classes if set.
$attributes['class'] = isset($attributes['class']) ? $attributes['class'] : 'tool-mfa-verification-code font-weight-bold';
$attributes['maxlength'] = 6;
// If we aren't on the auth page, this might be part of a larger form such as for setup.
// We shouldn't autofocus here, as it probably isn't the only element, or main target.
if ($auth) {
$attributes['autofocus'] = 'autofocus';
}
// If we are on the auth page, load JS for element.
$this->appendjs = false;
if ($auth) {
$PAGE->requires->js_call_amd('tool_mfa/autosubmit_verification_code', 'init', []);
}
// Force element name to match JS.
$elementname = 'verificationcode';
// Overwrite default element label if set.
$elementlabel = !empty($elementlabel) ? $elementlabel : get_string('entercode', 'tool_mfa');
return parent::__construct($elementname, $elementlabel, $attributes);
}
/**
* Returns HTML for this form element.
*
* phpcs:disable moodle.NamingConventions.ValidFunctionName.LowercaseMethod
*
* @return string
*/
public function toHtml(): string {
// Empty the value after all attributes decided.
$this->_attributes['value'] = '';
$result = parent::toHtml();
$submitjs = "<script>
document.querySelector('#id_verificationcode').addEventListener('keyup', function() {
if (this.value.length == 6) {
// Submits the closes form (parent).
this.closest('form').submit();
}
});
</script>";
if ($this->appendjs) {
$result .= $submitjs;
}
return $result;
}
/**
* Setup and return the script for autosubmission while inside the secure layout.
*
* @return string the JS to inline attach to the rendered object.
*/
public function secure_js(): string {
// Empty the value after all attributes decided.
$this->_attributes['value'] = '';
return "<script>
document.querySelector('#id_verificationcode').addEventListener('keyup', function() {
if (this.value.length == 6) {
// Submits the closes form (parent).
this.closest('form').submit();
}
});
</script>";
}
}
@@ -0,0 +1,41 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace tool_mfa\local\hooks;
/**
* Extend user bulk actions menu
*
* @package tool_mfa
* @copyright 2024 Marina Glancy
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class extend_bulk_user_actions {
/**
* Add action to reset MFA factors
*
* @param \core_user\hook\extend_bulk_user_actions $hook
*/
public static function callback(\core_user\hook\extend_bulk_user_actions $hook): void {
if (has_capability('moodle/site:config', \context_system::instance())) {
$hook->add_action('tool_mfa_reset_factors', new \action_link(
new \moodle_url('/admin/tool/mfa/reset_factor.php'),
get_string('resetfactor', 'tool_mfa')
));
}
}
}
@@ -0,0 +1,244 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace tool_mfa\local;
/**
* MFA secret management class.
*
* @package tool_mfa
* @author Peter Burnett <peterburnett@catalyst-au.net>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class secret_manager {
/** @var string */
const REVOKED = 'revoked';
/** @var string */
const VALID = 'valid';
/** @var string */
const NONVALID = 'nonvalid';
/** @var string */
private $factor;
/** @var string|false */
private $sessionid;
/**
* Initialises a secret manager instance
*
* @param string $factor
*/
public function __construct(string $factor) {
$this->factor = $factor;
$this->sessionid = session_id();
}
/**
* This function creates or takes a secret, and stores it in the database or session.
*
* @param int $expires the length of time the secret is valid. e.g. 1 min = 60
* @param bool $session whether this secret should be linked to the session.
* @param string $secret an optional provided secret
* @return string the secret code, or 0 if no new code created.
*/
public function create_secret(int $expires, bool $session, string $secret = null): string {
// Check if there already an active secret, unless we are forcibly given a code.
if ($this->has_active_secret($session) && empty($secret)) {
return '';
}
// Setup a secret if not provided.
if (empty($secret)) {
$secret = random_int(100000, 999999);
}
// Now pass the code where it needs to go.
if ($session) {
$this->add_secret_to_db($secret, $expires, $this->sessionid);
} else {
$this->add_secret_to_db($secret, $expires);
}
return $secret;
}
/**
* Inserts the provided secret into the database with a given expiry duration.
*
* @param string $secret the secret to store
* @param int $expires expiry duration in seconds
* @param string $sessionid an optional sessionID to tie this record to
* @return void
*/
private function add_secret_to_db(string $secret, int $expires, string $sessionid = null): void {
global $DB, $USER;
$expirytime = time() + $expires;
$data = [
'userid' => $USER->id,
'factor' => $this->factor,
'secret' => $secret,
'timecreated' => time(),
'expiry' => $expirytime,
'revoked' => 0,
];
if (!empty($sessionid)) {
$data['sessionid'] = $sessionid;
}
$DB->insert_record('tool_mfa_secrets', $data);
}
/**
* Validates whether the provided secret is currently valid.
*
* @param string $secret the secret to check
* @param bool $keep should the secret be kept for reuse until expiry?
* @return string a secret manager state constant
*/
public function validate_secret(string $secret, bool $keep = false): string {
global $DB, $USER;
$status = $this->check_secret_against_db($secret, $this->sessionid);
if ($status !== self::NONVALID) {
if ($status === self::VALID && !$keep) {
// Cleanup DB $record.
$DB->delete_records('tool_mfa_secrets', ['userid' => $USER->id, 'factor' => $this->factor]);
}
return $status;
}
// This is always nonvalid.
return $status;
}
/**
* Checks if a given secret is valid from the Database.
*
* @param string $secret the secret to check.
* @param string $sessionid the session id to check for.
* @return string a secret manager state constant.
*/
private function check_secret_against_db(string $secret, string $sessionid): string {
global $DB, $USER;
$sql = "SELECT *
FROM {tool_mfa_secrets}
WHERE secret = :secret
AND expiry > :now
AND userid = :userid
AND factor = :factor";
$params = [
'secret' => $secret,
'now' => time(),
'userid' => $USER->id,
'factor' => $this->factor,
];
$record = $DB->get_record_sql($sql, $params);
if (!empty($record)) {
// If revoked it should always be revoked status.
if ($record->revoked) {
return self::REVOKED;
}
// Check if this is valid in only one session.
if (!empty($record->sessionid)) {
if ($record->sessionid === $sessionid) {
return self::VALID;
}
return self::NONVALID;
}
return self::VALID;
}
return self::NONVALID;
}
/**
* Revokes the provided secret code for the user.
*
* @param string $secret the secret to revoke.
* @param int $userid the userid to revoke the secret for.
* @return void
*/
public function revoke_secret(string $secret, $userid = null): void {
global $DB, $USER;
$userid = $userid ?? $USER->id;
// We do not need to worry about session vs global here.
// A factor should only ever use one.
// We know this secret is valid, so we don't need to check expiry.
$DB->set_field('tool_mfa_secrets', 'revoked', 1, ['userid' => $userid, 'factor' => $this->factor, 'secret' => $secret]);
}
/**
* Checks whether this factor currently has an active secret, and should not add another.
*
* @param bool $checksession should we only check if a current session secret is active?
* @return bool
*/
private function has_active_secret(bool $checksession = false): bool {
global $DB, $USER;
$sql = "SELECT *
FROM {tool_mfa_secrets}
WHERE expiry > :now
AND userid = :userid
AND factor = :factor
AND revoked = 0";
$params = [
'now' => time(),
'userid' => $USER->id,
'factor' => $this->factor,
];
if ($checksession) {
$sql .= ' AND sessionid = :sessionid';
$params['sessionid'] = $this->sessionid;
}
if ($DB->record_exists_sql($sql, $params)) {
return true;
}
return false;
}
/**
* Deletes any user secrets hanging around in the database.
*
* @param int $userid the userid to cleanup temp secrets for.
* @return void
*/
public function cleanup_temp_secrets($userid = null): void {
global $DB, $USER;
// Session records are autocleaned up.
// Only DB cleanup required.
$userid = $userid ?? $USER->id;
$sql = 'DELETE FROM {tool_mfa_secrets}
WHERE userid = :userid
AND factor = :factor';
$DB->execute($sql, ['userid' => $userid, 'factor' => $this->factor]);
}
}
+877
View File
@@ -0,0 +1,877 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace tool_mfa;
use dml_exception;
use tool_mfa\plugininfo\factor;
/**
* MFA management class.
*
* @package tool_mfa
* @author Peter Burnett <peterburnett@catalyst-au.net>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class manager {
/** @var int */
const REDIRECT = 1;
/** @var int */
const NO_REDIRECT = 0;
/** @var int */
const REDIRECT_EXCEPTION = -1;
/** @var int */
const REDIR_LOOP_THRESHOLD = 5;
/**
* Displays a debug table with current factor information.
*
* @return void
*/
public static function display_debug_notification(): void {
global $OUTPUT, $PAGE;
if (!get_config('tool_mfa', 'debugmode')) {
return;
}
$html = $OUTPUT->heading(get_string('debugmode:heading', 'tool_mfa'), 3);
$table = new \html_table();
$table->head = [
get_string('weight', 'tool_mfa'),
get_string('factor', 'tool_mfa'),
get_string('setup', 'tool_mfa'),
get_string('achievedweight', 'tool_mfa'),
get_string('status'),
];
$table->attributes['class'] = 'admintable generaltable table table-bordered';
$table->colclasses = [
'text-right',
'',
'',
'text-right',
'text-center',
];
$factors = factor::get_enabled_factors();
$userfactors = factor::get_active_user_factor_types();
$runningtotal = 0;
$weighttoggle = false;
foreach ($factors as $factor) {
$namespace = 'factor_'.$factor->name;
$name = get_string('pluginname', $namespace);
// If factor is unknown, pending from here.
if ($factor->get_state() == factor::STATE_UNKNOWN) {
$weighttoggle = true;
}
// Stop adding weight if 100 achieved.
if (!$weighttoggle) {
$achieved = $factor->get_state() == factor::STATE_PASS ? $factor->get_weight() : 0;
$achieved = '+'.$achieved;
$runningtotal += $achieved;
} else {
$achieved = '';
}
// Setup.
if ($factor->has_setup()) {
$found = false;
foreach ($userfactors as $userfactor) {
if ($userfactor->name == $factor->name) {
$found = true;
}
}
$setup = $found ? get_string('yes') : get_string('no');
} else {
$setup = get_string('na', 'tool_mfa');
}
// Status.
$OUTPUT = $PAGE->get_renderer('tool_mfa');
// If toggle has been flipped, fall to default pending badge.
if ($weighttoggle) {
$state = $OUTPUT->get_state_badge('');
} else {
$state = $OUTPUT->get_state_badge($factor->get_state());
}
$table->data[] = [
$factor->get_weight(),
$name,
$setup,
$achieved,
$state,
];
// If we just hit 100, flip toggle.
if ($runningtotal >= 100) {
$weighttoggle = true;
}
}
$finalstate = self::get_status();
$table->data[] = [
'',
'',
'<b>' . get_string('overall', 'tool_mfa') . '</b>',
self::get_cumulative_weight(),
$OUTPUT->get_state_badge($finalstate),
];
$html .= \html_writer::table($table);
echo $html;
}
/**
* Returns the total weight from all factors currently enabled for user.
*
* @return int
*/
public static function get_total_weight(): int {
$totalweight = 0;
$factors = factor::get_active_user_factor_types();
foreach ($factors as $factor) {
if ($factor->get_state() == factor::STATE_PASS) {
$totalweight += $factor->get_weight();
}
}
return $totalweight;
}
/**
* Checks that provided factorid exists and belongs to current user.
*
* @param int $factorid
* @param object $user
* @return bool
* @throws \dml_exception
*/
public static function is_factorid_valid(int $factorid, object $user): bool {
global $DB;
return $DB->record_exists('tool_mfa', ['userid' => $user->id, 'id' => $factorid]);
}
/**
* Function to display to the user that they cannot login, then log them out.
*
* @return void
*/
public static function cannot_login(): void {
global $ME, $PAGE, $SESSION, $USER;
// Determine page URL without triggering warnings from $PAGE.
if (!preg_match("~(\/admin\/tool\/mfa\/auth.php)~", $ME)) {
// If URL isn't set, we need to redir to auth.php.
// This ensures URL and required info is correctly set.
// Then we arrive back here.
redirect(new \moodle_url('/admin/tool/mfa/auth.php'));
}
$renderer = $PAGE->get_renderer('tool_mfa');
echo $renderer->header();
if (get_config('tool_mfa', 'debugmode')) {
self::display_debug_notification();
}
echo $renderer->not_enough_factors();
echo $renderer->footer();
// Emit an event for failure, then logout.
$event = \tool_mfa\event\user_failed_mfa::user_failed_mfa_event($USER);
$event->trigger();
// We should set the redir flag, as this page is generated through auth.php.
$SESSION->tool_mfa_has_been_redirected = true;
die;
}
/**
* Logout user.
*
* @return void
*/
public static function mfa_logout(): void {
$authsequence = get_enabled_auth_plugins();
foreach ($authsequence as $authname) {
$authplugin = get_auth_plugin($authname);
$authplugin->logoutpage_hook();
}
require_logout();
}
/**
* Function to get the overall status of a user's authentication.
*
* @return string a STATE variable from plugininfo
*/
public static function get_status(): string {
global $SESSION;
// Check for any instant fail states.
$factors = factor::get_active_user_factor_types();
foreach ($factors as $factor) {
$factor->load_locked_state();
if ($factor->get_state() == factor::STATE_FAIL) {
return factor::STATE_FAIL;
}
}
$passcondition = ((isset($SESSION->tool_mfa_authenticated) && $SESSION->tool_mfa_authenticated) ||
self::passed_enough_factors());
// Check next factor for instant fail (fallback).
if (factor::get_next_user_login_factor()->get_state() == factor::STATE_FAIL) {
// We need to handle a special case here, where someone reached the fallback,
// If they were able to modify their state on the error page, such as passing iprange,
// We must return pass.
if ($passcondition) {
return factor::STATE_PASS;
}
return factor::STATE_FAIL;
}
// Now check for general passing state. If found, ensure that session var is set.
if ($passcondition) {
return factor::STATE_PASS;
}
// Else return neutral state.
return factor::STATE_NEUTRAL;
}
/**
* Function to check the overall status of a users authentication,
* and perform any required actions.
*
* @param bool $shouldreload whether the function should reload (used for auth.php).
* @return void
*/
public static function resolve_mfa_status(bool $shouldreload = false): void {
global $SESSION;
$state = self::get_status();
if ($state == factor::STATE_PASS) {
self::set_pass_state();
// Check if user even had to reach auth page.
if (isset($SESSION->tool_mfa_has_been_redirected)) {
if (empty($SESSION->wantsurl)) {
$wantsurl = '/';
} else {
$wantsurl = $SESSION->wantsurl;
}
unset($SESSION->wantsurl);
redirect(new \moodle_url($wantsurl));
} else {
// Don't touch anything, let user be on their way.
return;
}
} else if ($state == factor::STATE_FAIL) {
self::cannot_login();
} else if ($shouldreload) {
// Set a session variable to track whether user is where they want to be.
$SESSION->tool_mfa_has_been_redirected = true;
$authurl = new \moodle_url('/admin/tool/mfa/auth.php');
redirect($authurl);
}
}
/**
* Checks whether user has passed enough factors to be allowed in.
*
* @return bool true if user has passed enough factors.
*/
public static function passed_enough_factors(): bool {
// Check for any instant fail states.
$factors = factor::get_active_user_factor_types();
foreach ($factors as $factor) {
if ($factor->get_state() == factor::STATE_FAIL) {
self::mfa_logout();
}
}
$totalweight = self::get_cumulative_weight();
if ($totalweight >= 100) {
return true;
}
return false;
}
/**
* Sets the session variable for pass_state, if not already set.
*
* @return void
*/
public static function set_pass_state(): void {
global $DB, $SESSION, $USER;
if (!isset($SESSION->tool_mfa_authenticated)) {
$SESSION->tool_mfa_authenticated = true;
$event = \tool_mfa\event\user_passed_mfa::user_passed_mfa_event($USER);
$event->trigger();
// Allow plugins to callback as soon possible after user has passed MFA.
$hook = new \tool_mfa\hook\after_user_passed_mfa();
\core\di::get(\core\hook\manager::class)->dispatch($hook);
// Add/update record in DB for users last mfa auth.
self::update_pass_time();
// Unset session vars during mfa auth.
unset($SESSION->mfa_redir_referer);
unset($SESSION->mfa_redir_count);
// Unset user preferences during mfa auth.
unset_user_preference('mfa_sleep_duration', $USER);
try {
// Clear locked user factors, they may now reauth with anything.
@$DB->set_field('tool_mfa', 'lockcounter', 0, ['userid' => $USER->id]);
// phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
} catch (\Exception $e) {
// This occurs when upgrade.php hasn't been run. Nothing to do here.
}
// Fire post pass state factor actions.
$factors = factor::get_active_user_factor_types();
foreach ($factors as $factor) {
$factor->post_pass_state();
// Also set the states for this session to neutral if they were locked.
if ($factor->get_state() == factor::STATE_LOCKED) {
$factor->set_state(factor::STATE_NEUTRAL);
}
}
// Output notifications if any factors were reset for this user.
$enabledfactors = factor::get_enabled_factors();
foreach ($enabledfactors as $factor) {
$pref = 'tool_mfa_reset_' . $factor->name;
$factorpref = get_user_preferences($pref, false);
if ($factorpref) {
$url = new \moodle_url('/admin/tool/mfa/user_preferences.php');
$link = \html_writer::link($url, get_string('preferenceslink', 'tool_mfa'));
$data = ['factor' => $factor->get_display_name(), 'url' => $link];
\core\notification::warning(get_string('factorreset', 'tool_mfa', $data));
unset_user_preference($pref);
}
}
// Also check for a global reset.
// TODO: Delete this in a few months, the reset all preference is no longer set.
$allfactor = get_user_preferences('tool_mfa_reset_all', false);
if ($allfactor) {
$url = new \moodle_url('/admin/tool/mfa/user_preferences.php');
$link = \html_writer::link($url, get_string('preferenceslink', 'tool_mfa'));
\core\notification::warning(get_string('factorresetall', 'tool_mfa', $link));
unset_user_preference('tool_mfa_reset_all');
}
}
}
/**
* Inserts or updates user's last MFA pass time in DB.
* This should only be called from set_pass_state.
*
* @return void
*/
private static function update_pass_time(): void {
global $DB, $USER;
$exists = $DB->record_exists('tool_mfa_auth', ['userid' => $USER->id]);
if ($exists) {
$DB->set_field('tool_mfa_auth', 'lastverified', time(), ['userid' => $USER->id]);
} else {
$DB->insert_record('tool_mfa_auth', ['userid' => $USER->id, 'lastverified' => time()]);
}
}
/**
* Checks whether the user should be redirected from the provided url.
*
* @param string|\moodle_url $url
* @param bool|null $preventredirect
* @return int
*/
public static function should_require_mfa(string|\moodle_url $url, bool|null $preventredirect): int {
global $CFG, $USER, $SESSION;
// If no cookies then no session so cannot do MFA.
// Unit testing based on defines is not viable.
if (NO_MOODLE_COOKIES && !PHPUNIT_TEST) {
return self::NO_REDIRECT;
}
// Remove all params before comparison.
$url->remove_all_params();
// Checks for upgrades pending.
if (is_siteadmin()) {
// We should only allow an upgrade from the frontend to complete.
// After that is completed, only the settings shouldn't redirect.
// Everything else should be safe to enforce MFA.
if (moodle_needs_upgrading()) {
return self::NO_REDIRECT;
}
// An upgrade isn't complete if there are settings that must be saved.
$upgradesettings = new \moodle_url('/admin/upgradesettings.php');
if ($url->compare($upgradesettings, URL_MATCH_BASE)) {
return self::NO_REDIRECT;
}
}
// Dont redirect logo images from pluginfile.php (for example: logo in header).
$logourl = new \moodle_url('/pluginfile.php/1/core_admin/logocompact/');
if ($url->compare($logourl)) {
return self::NO_REDIRECT;
}
// Admin not setup.
if (!empty($CFG->adminsetuppending)) {
return self::NO_REDIRECT;
}
// Initial installation.
// We get this for free from get_plugins_with_function.
// Upgrade check.
// We get this for free from get_plugins_with_function.
// Honor prevent_redirect.
if ($preventredirect) {
return self::NO_REDIRECT;
}
// User not properly setup.
if (user_not_fully_set_up($USER)) {
return self::NO_REDIRECT;
}
// Enrolment.
$enrol = new \moodle_url('/enrol/index.php');
if ($enrol->compare($url, URL_MATCH_BASE)) {
return self::NO_REDIRECT;
}
// Guest access.
if (isguestuser()) {
return self::NO_REDIRECT;
}
// Forced password changes.
if (get_user_preferences('auth_forcepasswordchange')) {
return self::NO_REDIRECT;
}
// Login as.
if (\core\session\manager::is_loggedinas()) {
return self::NO_REDIRECT;
}
// Site policy.
if (isset($USER->policyagreed) && !$USER->policyagreed) {
$manager = new \core_privacy\local\sitepolicy\manager();
$policyurl = $manager->get_redirect_url(false);
if (!empty($policyurl) && $url->compare($policyurl, URL_MATCH_BASE)) {
return self::NO_REDIRECT;
}
}
// WS/AJAX check.
if (WS_SERVER || AJAX_SCRIPT) {
if (isset($SESSION->mfa_pending) && !empty($SESSION->mfa_pending)) {
// Allow AJAX and WS, but never from auth.php.
return self::NO_REDIRECT;
}
return self::REDIRECT_EXCEPTION;
}
// Check factor defined safe urls.
$factorurls = self::get_no_redirect_urls();
foreach ($factorurls as $factorurl) {
if ($factorurl->compare($url)) {
return self::NO_REDIRECT;
}
}
// Circular checks.
$authurl = new \moodle_url('/admin/tool/mfa/auth.php');
$authlocal = $authurl->out_as_local_url();
if (isset($SESSION->mfa_redir_referer)
&& $SESSION->mfa_redir_referer != $authlocal) {
if ($SESSION->mfa_redir_referer == get_local_referer(true)) {
// Possible redirect loop.
if (!isset($SESSION->mfa_redir_count)) {
$SESSION->mfa_redir_count = 1;
} else {
$SESSION->mfa_redir_count++;
}
if ($SESSION->mfa_redir_count > self::REDIR_LOOP_THRESHOLD) {
return self::REDIRECT_EXCEPTION;
}
} else {
// If not a match, reset counter.
$SESSION->mfa_redir_count = 0;
}
}
// Set referer after checks.
$SESSION->mfa_redir_referer = get_local_referer(true);
// Don't redirect if already on auth.php.
if ($url->compare($authurl, URL_MATCH_BASE)) {
return self::NO_REDIRECT;
}
return self::REDIRECT;
}
/**
* Clears the redirect counter for infinite redirect loops. Called from auth.php when a valid load is resolved.
*
* @return void
*/
public static function clear_redirect_counter(): void {
global $SESSION;
unset($SESSION->mfa_redir_referer);
unset($SESSION->mfa_redir_count);
}
/**
* Gets all defined factor urls that should not redirect.
*
* @return array
*/
public static function get_no_redirect_urls(): array {
$factors = factor::get_factors();
$urls = [
new \moodle_url('/login/logout.php'),
new \moodle_url('/admin/tool/mfa/guide.php'),
];
foreach ($factors as $factor) {
$urls = array_merge($urls, $factor->get_no_redirect_urls());
}
// Allow forced redirection exclusions.
if ($exclusions = get_config('tool_mfa', 'redir_exclusions')) {
foreach (explode("\n", $exclusions) as $exclusion) {
$urls[] = new \moodle_url($exclusion);
}
}
return $urls;
}
/**
* Sleeps for an increasing period of time.
*
* @return void
*/
public static function sleep_timer(): void {
global $USER;
$duration = get_user_preferences('mfa_sleep_duration', null, $USER);
if (!empty($duration)) {
// Double current time.
$duration *= 2;
$duration = min(2, $duration);
} else {
// No duration set.
$duration = 0.05;
}
set_user_preference('mfa_sleep_duration', $duration, $USER);
sleep((int)$duration);
}
/**
* If MFA Plugin is ready check tool_mfa_authenticated USER property and
* start MFA authentication if it's not set or false.
*
* @param mixed $courseorid
* @param mixed $autologinguest
* @param mixed $cm
* @param mixed $setwantsurltome
* @param mixed $preventredirect
* @return void
*/
public static function require_auth($courseorid = null, $autologinguest = null, $cm = null,
$setwantsurltome = null, $preventredirect = null): void {
global $PAGE, $SESSION, $FULLME;
// Guest user should never interact with MFA,
// And $SESSION->tool_mfa_authenticated should never be set in a guest session.
if (isguestuser()) {
return;
}
if (!self::is_ready()) {
// Set session var so if MFA becomes ready, you dont get locked from session.
$SESSION->tool_mfa_authenticated = true;
return;
}
if (empty($SESSION->tool_mfa_authenticated) || !$SESSION->tool_mfa_authenticated) {
if ($PAGE->has_set_url()) {
$cleanurl = $PAGE->url;
} else {
// Use $FULLME instead.
$cleanurl = new \moodle_url($FULLME);
}
$authurl = new \moodle_url('/admin/tool/mfa/auth.php');
$redir = self::should_require_mfa($cleanurl, $preventredirect);
if ($redir == self::NO_REDIRECT && !$cleanurl->compare($authurl, URL_MATCH_BASE)) {
// A non-MFA page that should take precedence.
// This check is for any pages, such as site policy, that must occur before MFA.
// This check allows AJAX and WS requests to fire on these pages without throwing an exception.
$SESSION->mfa_pending = true;
}
if ($redir == self::REDIRECT) {
if (empty($SESSION->wantsurl)) {
!empty($setwantsurltome)
? $SESSION->wantsurl = qualified_me()
: $SESSION->wantsurl = new \moodle_url('/');
$SESSION->tool_mfa_setwantsurl = true;
}
// Remove pending status.
// We must now auth with MFA, now that pending statuses are resolved.
unset($SESSION->mfa_pending);
// Call resolve_status to instantly pass if no redirect is required.
self::resolve_mfa_status(true);
} else if ($redir == self::REDIRECT_EXCEPTION) {
if (!empty($SESSION->mfa_redir_referer)) {
throw new \moodle_exception('redirecterrordetected', 'tool_mfa',
$SESSION->mfa_redir_referer, $SESSION->mfa_redir_referer);
} else {
throw new \moodle_exception('redirecterrordetected', 'error');
}
}
}
}
/**
* Sets config variable for given factor.
*
* @param array $data
* @param string $factor
*
* @return bool true or exception
* @throws dml_exception
*/
public static function set_factor_config(array $data, string $factor): bool|dml_exception {
$factorconf = get_config($factor);
foreach ($data as $key => $newvalue) {
if (empty($factorconf->$key)) {
add_to_config_log($key, null, $newvalue, $factor);
set_config($key, $newvalue, $factor);
} else if ($factorconf->$key != $newvalue) {
add_to_config_log($key, $factorconf->$key, $newvalue, $factor);
set_config($key, $newvalue, $factor);
}
}
return true;
}
/**
* Checks if MFA Plugin is enabled and has enabled factor.
* If plugin is disabled or there is no enabled factors,
* it means there is nothing to do from user side.
* Thus, login flow shouldn't be extended with MFA.
*
* @return bool
* @throws \dml_exception
*/
public static function is_ready(): bool {
global $CFG, $USER;
if (!empty($CFG->upgraderunning)) {
return false;
}
$pluginenabled = get_config('tool_mfa', 'enabled');
if (empty($pluginenabled)) {
return false;
}
// Check if user can interact with MFA.
$usercontext = \context_user::instance($USER->id);
if (!has_capability('tool/mfa:mfaaccess', $usercontext)) {
return false;
}
$enabledfactors = factor::get_enabled_factors();
if (count($enabledfactors) == 0) {
return false;
}
return true;
}
/**
* Performs factor actions for given factor.
* Change factor order and enable/disable.
*
* @param string $factorname
* @param string $action
*
* @return void
* @throws dml_exception
*/
public static function do_factor_action(string $factorname, string $action): void {
$order = explode(',', get_config('tool_mfa', 'factor_order'));
$key = array_search($factorname, $order);
switch ($action) {
case 'up':
if ($key >= 1) {
$fsave = $order[$key];
$order[$key] = $order[$key - 1];
$order[$key - 1] = $fsave;
}
break;
case 'down':
if ($key < (count($order) - 1)) {
$fsave = $order[$key];
$order[$key] = $order[$key + 1];
$order[$key + 1] = $fsave;
}
break;
case 'enable':
if (!$key) {
$order[] = $factorname;
}
break;
case 'disable':
if ($key) {
unset($order[$key]);
}
break;
default:
break;
}
self::set_factor_config(['factor_order' => implode(',', $order)], 'tool_mfa');
}
/**
* Checks if a factor that can make a user pass can be setup.
* It checks if a user will always pass regardless,
* then checks if there are factors that can be setup to let a user pass.
*
* @return bool
*/
public static function possible_factor_setup(): bool {
global $USER;
// Get all active factors.
$factors = factor::get_enabled_factors();
// Check if there are enough factors that a user can ONLY pass, if so, don't display the menu.
$weight = 0;
foreach ($factors as $factor) {
$states = $factor->possible_states($USER);
if (count($states) == 1 && reset($states) == factor::STATE_PASS) {
$weight += $factor->get_weight();
if ($weight >= 100) {
return false;
}
}
}
// Now if there is a factor that can be setup, that may return a pass state for the user, display menu.
foreach ($factors as $factor) {
if ($factor->has_setup()) {
if (in_array(factor::STATE_PASS, $factor->possible_states($USER))) {
return true;
}
}
}
return false;
}
/**
* Gets current user weight, up until first unknown factor.
*
* @return int $totalweight Total weight of all factors.
*/
public static function get_cumulative_weight(): int {
$factors = factor::get_active_user_factor_types();
// Factor order is important here, so sort the factors by state.
$sortedfactors = factor::sort_factors_by_state($factors, factor::STATE_PASS);
$totalweight = 0;
foreach ($sortedfactors as $factor) {
if ($factor->get_state() == factor::STATE_PASS) {
$totalweight += $factor->get_weight();
// If over 100, break. Don't care about >100.
if ($totalweight >= 100) {
break;
}
} else if ($factor->get_state() == factor::STATE_UNKNOWN) {
break;
}
}
return $totalweight;
}
/**
* Checks whether the factor was actually used in the login process.
*
* @param string $factorname the name of the factor.
* @return bool true if factor is pending.
*/
public static function check_factor_pending(string $factorname): bool {
$factors = factor::get_active_user_factor_types();
// Setup vars.
$pending = [];
$totalweight = 0;
$weighttoggle = false;
foreach ($factors as $factor) {
// If toggle is reached, put in pending and continue.
if ($weighttoggle) {
$pending[] = $factor->name;
continue;
}
if ($factor->get_state() == factor::STATE_PASS) {
$totalweight += $factor->get_weight();
if ($totalweight >= 100) {
$weighttoggle = true;
}
}
}
// Check whether factor falls into pending category.
return in_array($factorname, $pending);
}
}
+732
View File
@@ -0,0 +1,732 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace tool_mfa\output;
use core\context\system;
use tool_mfa\local\factor\object_factor;
use tool_mfa\local\form\login_form;
use \html_writer;
use tool_mfa\plugininfo\factor;
/**
* MFA renderer.
*
* @package tool_mfa
* @author Mikhail Golenkov <golenkovm@gmail.com>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class renderer extends \plugin_renderer_base {
/**
* Returns the state of the factor as a badge.
*
* @param string $state
* @return string
*/
public function get_state_badge(string $state): string {
switch ($state) {
case factor::STATE_PASS:
return html_writer::tag('span', get_string('state:pass', 'tool_mfa'), ['class' => 'badge bg-success text-white']);
case factor::STATE_FAIL:
return html_writer::tag('span', get_string('state:fail', 'tool_mfa'), ['class' => 'badge bg-danger text-white']);
case factor::STATE_NEUTRAL:
return html_writer::tag('span', get_string('state:neutral', 'tool_mfa'),
['class' => 'badge bg-warning text-dark']);
case factor::STATE_UNKNOWN:
return html_writer::tag('span', get_string('state:unknown', 'tool_mfa'),
['class' => 'badge bg-secondary text-dark']);
case factor::STATE_LOCKED:
return html_writer::tag('span', get_string('state:locked', 'tool_mfa'), ['class' => 'badge bg-danger text-white']);
default:
return html_writer::tag('span', get_string('pending', 'tool_mfa'), ['class' => 'badge bg-secondary text-dark']);
}
}
/**
* Returns a list of factors which a user can add.
*
* @return string
*/
public function available_factors(): string {
global $USER;
$factors = factor::get_enabled_factors();
$data = [];
foreach ($factors as $factor) {
// Allow all factors with setup and button.
// Make an exception for email factor as this is currently set up by admins only and required on this list.
if ((!$factor->has_setup() || !$factor->show_setup_buttons()) && !$factor instanceof \factor_email\factor) {
continue;
}
$userfactors = $factor->get_active_user_factors($USER);
$active = !empty($userfactors) ?? false;
$button = null;
$icon = $factor->get_icon();
$params = [
'action' => 'setup',
'factor' => $factor->name,
];
if (!$active) {
// Not active yet and requires set up.
$info = $factor->get_info();
if ($factor->show_setup_buttons()) {
$params['action'] = 'setup';
$button = new \single_button(
url: new \moodle_url('action.php', $params),
label: $factor->get_setup_string(),
method: 'post',
type: \single_button::BUTTON_PRIMARY,
attributes: [
'aria-label' => get_string('setupfactor', 'factor_' . $factor->name),
],
);
$button = $button->export_for_template($this->output);
}
} else {
// Active and can be managed.
$factorid = reset($userfactors)->id;
$info = $factor->get_manage_info($factorid);
if ($factor->show_setup_buttons()) {
$params['action'] = 'manage';
$button = new \single_button(
url: new \moodle_url('action.php', $params),
label: $factor->get_manage_string(),
method: 'post',
type: \single_button::BUTTON_PRIMARY,
attributes: [
'aria-label' => get_string('managefactor', 'factor_' . $factor->name),
],
);
$button = $button->export_for_template($this->output);
}
}
// Prepare data for template.
$data['factors'][] = [
'active' => $active,
'label' => $factor->get_display_name(),
'name' => $factor->name,
'info' => $info,
'icon' => $icon,
'button' => $button,
];
}
return $this->render_from_template('tool_mfa/mfa_selector', $data);
}
/**
* Returns the html section for factor setup
*
* @param object $factor object of the factor class
* @return string
* @deprecated since Moodle 4.4
* @todo Final deprecation in Moodle 4.8 MDL-80995
*/
public function setup_factor(object $factor): string {
debugging('The method setup_factor() has been deprecated. The HTML derived from this method is no longer needed.
Similar HTML is now achieved as part of available_factors().', DEBUG_DEVELOPER);
$html = '';
$html .= html_writer::start_tag('div', ['class' => 'card']);
$html .= html_writer::tag('h4', $factor->get_display_name(), ['class' => 'card-header']);
$html .= html_writer::start_tag('div', ['class' => 'card-body']);
$html .= $factor->get_info();
$setupparams = ['action' => 'setup', 'factor' => $factor->name, 'sesskey' => sesskey()];
$setupurl = new \moodle_url('action.php', $setupparams);
$html .= $this->output->single_button($setupurl, $factor->get_setup_string());
$html .= html_writer::end_tag('div');
$html .= html_writer::end_tag('div');
$html .= '<br>';
return $html;
}
/**
* Show a table displaying a users active factors.
*
* @param string|null $filterfactor The factor name to filter on.
* @return string $html
* @throws \coding_exception
*/
public function active_factors(string $filterfactor = null): string {
global $USER, $CFG;
require_once($CFG->dirroot . '/iplookup/lib.php');
$html = '';
$headers = get_strings([
'devicename',
'added',
'lastused',
'replace',
'remove',
], 'tool_mfa');
$table = new \html_table();
$table->id = 'active_factors';
$table->attributes['class'] = 'generaltable table table-bordered';
$table->head = [
$headers->devicename,
$headers->added,
$headers->lastused,
$headers->replace,
$headers->remove,
];
$table->colclasses = [
'text-left',
'text-left',
'text-left',
'text-center',
'text-center',
];
$table->data = [];
$factors = factor::get_enabled_factors();
$hasmorethanone = factor::user_has_more_than_one_active_factors();
foreach ($factors as $factor) {
// Filter results to match the specified factor.
if (!empty($filterfactor) && $factor->name !== $filterfactor) {
continue;
}
$userfactors = $factor->get_active_user_factors($USER);
if (!$factor->has_setup()) {
continue;
}
foreach ($userfactors as $userfactor) {
// Revoke option.
if ($factor->has_revoke() && $hasmorethanone) {
$content = $headers->remove;
$attributes = [
'data-action' => 'revoke',
'data-factor' => $factor->name,
'data-factorid' => $userfactor->id,
'data-factorname' => $factor->get_display_name(),
'data-devicename' => $userfactor->label,
'aria-label' => get_string('revokefactor', 'tool_mfa'),
'class' => 'btn btn-primary mfa-action-button',
];
$revokebutton = \html_writer::tag('button', $content, $attributes);
} else {
$revokebutton = get_string('statusna');
}
// Replace option.
if ($factor->has_replace()) {
$content = $headers->replace;
$attributes = [
'data-action' => 'replace',
'data-factor' => $factor->name,
'data-factorid' => $userfactor->id,
'data-factorname' => $factor->get_display_name(),
'data-devicename' => $userfactor->label,
'aria-label' => get_string('replacefactor', 'tool_mfa'),
'class' => 'btn btn-primary mfa-action-button',
];
$replacebutton = \html_writer::tag('button', $content, $attributes);
} else {
$replacebutton = get_string('statusna');
}
$timecreated = $userfactor->timecreated == '-' ? '-'
: userdate($userfactor->timecreated, get_string('strftimedatetime'));
$lastverified = $userfactor->lastverified;
if ($lastverified == 0) {
$lastverified = '-';
} else if ($lastverified != '-') {
$lastverified = userdate($userfactor->lastverified, get_string('strftimedatetime'));
$lastverified .= '<br>';
$lastverified .= get_string('ago', 'core_message', format_time(time() - $userfactor->lastverified));
}
$row = new \html_table_row([
$userfactor->label,
$timecreated,
$lastverified,
$replacebutton,
$revokebutton,
]);
$table->data[] = $row;
}
}
// If table has no data, don't output.
if (count($table->data) == 0) {
return '';
}
$html .= \html_writer::table($table);
$html .= '<br>';
return $html;
}
/**
* Generates notification text for display when user cannot login.
*
* @return string $notification
*/
public function not_enough_factors(): string {
global $CFG, $SITE;
$notification = \html_writer::tag('h4', get_string('error:notenoughfactors', 'tool_mfa'));
$notification .= \html_writer::tag('p', get_string('error:reauth', 'tool_mfa'));
// Support link.
$supportemail = $CFG->supportemail;
if (!empty($supportemail)) {
$subject = get_string('email:subject', 'tool_mfa',
format_string($SITE->fullname, true, ['context' => system::instance()]));
$maillink = \html_writer::link("mailto:$supportemail?Subject=$subject", $supportemail);
$notification .= get_string('error:support', 'tool_mfa');
$notification .= \html_writer::tag('p', $maillink);
}
// Support page link.
$supportpage = $CFG->supportpage;
if (!empty($supportpage)) {
$linktext = \html_writer::link($supportpage, $supportpage);
$notification .= $linktext;
}
$return = $this->output->notification($notification, 'notifyerror', false);
// Logout button.
$url = new \moodle_url('/admin/tool/mfa/auth.php', ['logout' => 1]);
$btn = new \single_button($url, get_string('logout'), 'post', \single_button::BUTTON_PRIMARY);
$return .= $this->render($btn);
$return .= $this->get_support_link();
return $return;
}
/**
* Displays a table of all factors in use currently.
*
* @param int $lookback the period to view.
* @return string the HTML for the table
*/
public function factors_in_use_table(int $lookback): string {
global $DB;
$factors = factor::get_factors();
// Setup 2 arrays, one with internal names, one pretty.
$columns = [''];
$displaynames = $columns;
$colclasses = ['center', 'center', 'center', 'center', 'center'];
// Force the first 4 columns to custom data.
$displaynames[] = get_string('totalusers', 'tool_mfa');
$displaynames[] = get_string('usersauthedinperiod', 'tool_mfa');
$displaynames[] = get_string('nonauthusers', 'tool_mfa');
$displaynames[] = get_string('nologinusers', 'tool_mfa');
foreach ($factors as $factor) {
$columns[] = $factor->name;
$displaynames[] = get_string('pluginname', 'factor_'.$factor->name);
$colclasses[] = 'right';
}
// Add total column to the end.
$displaynames[] = get_string('total');
$colclasses[] = 'center';
$table = new \html_table();
$table->head = $displaynames;
$table->align = $colclasses;
$table->attributes['class'] = 'generaltable table table-bordered w-auto';
$table->attributes['style'] = 'width: auto; min-width: 50%; margin-bottom: 0;';
// Manually handle Total users and MFA users.
$alluserssql = "SELECT auth,
COUNT(id)
FROM {user}
WHERE deleted = 0
AND suspended = 0
GROUP BY auth";
$allusersinfo = $DB->get_records_sql($alluserssql, []);
$noncompletesql = "SELECT u.auth, COUNT(u.id)
FROM {user} u
LEFT JOIN {tool_mfa_auth} mfaa ON u.id = mfaa.userid
WHERE u.lastlogin >= ?
AND (mfaa.lastverified < ?
OR mfaa.lastverified IS NULL)
GROUP BY u.auth";
$noncompleteinfo = $DB->get_records_sql($noncompletesql, [$lookback, $lookback]);
$nologinsql = "SELECT auth, COUNT(id)
FROM {user}
WHERE deleted = 0
AND suspended = 0
AND lastlogin < ?
GROUP BY auth";
$nologininfo = $DB->get_records_sql($nologinsql, [$lookback]);
$mfauserssql = "SELECT auth,
COUNT(DISTINCT tm.userid)
FROM {tool_mfa} tm
JOIN {user} u ON u.id = tm.userid
WHERE tm.lastverified >= ?
AND u.deleted = 0
AND u.suspended = 0
GROUP BY u.auth";
$mfausersinfo = $DB->get_records_sql($mfauserssql, [$lookback]);
$factorsusedsql = "SELECT CONCAT(u.auth, '_', tm.factor) as id,
COUNT(*)
FROM {tool_mfa} tm
JOIN {user} u ON u.id = tm.userid
WHERE tm.lastverified >= ?
AND u.deleted = 0
AND u.suspended = 0
AND (tm.revoked = 0 OR (tm.revoked = 1 AND tm.timemodified > ?))
GROUP BY CONCAT(u.auth, '_', tm.factor)";
$factorsusedinfo = $DB->get_records_sql($factorsusedsql, [$lookback, $lookback]);
// Auth rows.
$authtypes = get_enabled_auth_plugins(true);
foreach ($authtypes as $authtype) {
$row = [];
$row[] = \html_writer::tag('b', $authtype);
// Setup the overall totals columns.
$row[] = $allusersinfo[$authtype]->count ?? '-';
$row[] = $mfausersinfo[$authtype]->count ?? '-';
$row[] = $noncompleteinfo[$authtype]->count ?? '-';
$row[] = $nologininfo[$authtype]->count ?? '-';
// Create a running counter for the total.
$authtotal = 0;
// Now for each factor add the count from the factor query, and increment the running total.
foreach ($columns as $column) {
if (!empty($column)) {
// Get the information from the data key.
$key = $authtype . '_' . $column;
$count = $factorsusedinfo[$key]->count ?? 0;
$authtotal += $count;
$row[] = $count ? format_float($count, 0) : '-';
}
}
// Append the total of all factors to final column.
$row[] = $authtotal ? format_float($authtotal, 0) : '-';
$table->data[] = $row;
}
// Total row.
$totals = [0 => html_writer::tag('b', get_string('total'))];
for ($colcounter = 1; $colcounter < count($row); $colcounter++) {
$column = array_column($table->data, $colcounter);
// Transform string to int forcibly, remove -.
$column = array_map(function ($element) {
return $element === '-' ? 0 : (int) $element;
}, $column);
$columnsum = array_sum($column);
$colvalue = $columnsum === 0 ? '-' : $columnsum;
$totals[$colcounter] = $colvalue;
}
$table->data[] = $totals;
// Wrap in a div to cleanly scroll.
return \html_writer::div(\html_writer::table($table), '', ['style' => 'overflow:auto;']);
}
/**
* Displays a table of all factors in use currently.
*
* @return string the HTML for the table
*/
public function factors_locked_table(): string {
global $DB;
$factors = factor::get_factors();
$table = new \html_table();
$table->attributes['class'] = 'generaltable table table-bordered w-auto';
$table->attributes['style'] = 'width: auto; min-width: 50%';
$table->head = [
'factor' => get_string('factor', 'tool_mfa'),
'active' => get_string('active'),
'locked' => get_string('state:locked', 'tool_mfa'),
'actions' => get_string('actions'),
];
$table->align = [
'left',
'left',
'right',
'right',
];
$table->data = [];
$locklevel = (int) get_config('tool_mfa', 'lockout');
foreach ($factors as $factor) {
$sql = "SELECT COUNT(DISTINCT(userid))
FROM {tool_mfa}
WHERE factor = ?
AND lockcounter >= ?
AND revoked = 0";
$lockedusers = $DB->count_records_sql($sql, [$factor->name, $locklevel]);
$enabled = $factor->is_enabled() ? \html_writer::tag('b', get_string('yes')) : get_string('no');
$actions = \html_writer::link( new \moodle_url($this->page->url,
['reset' => $factor->name, 'sesskey' => sesskey()]), get_string('performbulk', 'tool_mfa'));
$lockedusers = \html_writer::link(new \moodle_url($this->page->url, ['view' => $factor->name]), $lockedusers);
$table->data[] = [
$factor->get_display_name(),
$enabled,
$lockedusers,
$actions,
];
}
return \html_writer::table($table);
}
/**
* Displays a table of all users with a locked instance of the given factor.
*
* @param object_factor $factor the factor class
* @return string the HTML for the table
*/
public function factor_locked_users_table(object_factor $factor): string {
global $DB;
$table = new \html_table();
$table->attributes['class'] = 'generaltable table table-bordered w-auto';
$table->attributes['style'] = 'width: auto; min-width: 50%';
$table->head = [
'userid' => get_string('userid', 'grades'),
'fullname' => get_string('fullname'),
'factorip' => get_string('ipatcreation', 'tool_mfa'),
'lastip' => get_string('lastip'),
'modified' => get_string('modified'),
'actions' => get_string('actions'),
];
$table->align = [
'left',
'left',
'left',
'left',
'left',
'right',
];
$table->data = [];
$locklevel = (int) get_config('tool_mfa', 'lockout');
$sql = "SELECT mfa.id as mfaid, u.*, mfa.createdfromip, mfa.timemodified
FROM {tool_mfa} mfa
JOIN {user} u ON mfa.userid = u.id
WHERE factor = ?
AND lockcounter >= ?
AND revoked = 0";
$records = $DB->get_records_sql($sql, [$factor->name, $locklevel]);
foreach ($records as $record) {
// Construct profile link.
$proflink = \html_writer::link(new \moodle_url('/user/profile.php',
['id' => $record->id]), fullname($record));
// IP link.
$creatediplink = \html_writer::link(new \moodle_url('/iplookup/index.php',
['ip' => $record->createdfromip]), $record->createdfromip);
$lastiplink = \html_writer::link(new \moodle_url('/iplookup/index.php',
['ip' => $record->lastip]), $record->lastip);
// Deep link to logs.
$logicon = $this->pix_icon('i/report', get_string('userlogs', 'tool_mfa'));
$actions = \html_writer::link(new \moodle_url('/report/log/index.php', [
'id' => 1, // Site.
'user' => $record->id,
]), $logicon);
$action = new \confirm_action(get_string('resetfactorconfirm', 'tool_mfa', fullname($record)));
$actions .= $this->action_link(
new \moodle_url($this->page->url, ['reset' => $factor->name, 'id' => $record->id, 'sesskey' => sesskey()]),
$this->pix_icon('t/delete', get_string('resetconfirm', 'tool_mfa')),
$action
);
$table->data[] = [
$record->id,
$proflink,
$creatediplink,
$lastiplink,
userdate($record->timemodified, get_string('strftimedatetime', 'langconfig')),
$actions,
];
}
return \html_writer::table($table);
}
/**
* Returns a rendered support link.
* If the MFA guidance page is enabled, this is returned.
* Otherwise, the site support link is returned.
* If neither support link is configured, an empty string is returned.
*
* @return string
*/
public function get_support_link(): string {
// Try the guidance page link first.
if (get_config('tool_mfa', 'guidance')) {
return $this->render_from_template('tool_mfa/guide_link', []);
} else {
return $this->output->supportemail([], true);
}
}
/**
* Renders an mform element from a template
*
* In certain situations, includes a script element which adds autosubmission behaviour.
*
* @param mixed $element element
* @param bool $required if input is required field
* @param bool $advanced if input is an advanced field
* @param string|null $error error message to display
* @param bool $ingroup True if this element is rendered as part of a group
* @return mixed string|bool
*/
public function mform_element(mixed $element, bool $required,
bool $advanced, string|null $error, bool $ingroup): string|bool {
$script = null;
if ($element instanceof \tool_mfa\local\form\verification_field) {
if ($this->page->pagelayout === 'secure') {
$script = $element->secure_js();
}
}
$result = parent::mform_element($element, $required, $advanced, $error, $ingroup);
if (!empty($script) && $result !== false) {
$result .= $script;
}
return $result;
}
/**
* Renders the verification form.
*
* @param object_factor $factor The factor to render the form for.
* @param login_form $form The login form object.
* @return string
* @throws \coding_exception
* @throws \dml_exception
* @throws \moodle_exception
*/
public function verification_form(object_factor $factor, login_form $form): string {
$allloginfactors = factor::get_all_user_login_factors();
$additionalfactors = [];
$disabledfactors = [];
$displaycount = 0;
$disablefactor = false;
foreach ($allloginfactors as $loginfactor) {
if ($loginfactor->name != $factor->name) {
$additionalfactor = [
'name' => $loginfactor->name,
'icon' => $loginfactor->get_icon(),
'loginoption' => get_string('loginoption', 'factor_' . $loginfactor->name),
];
// We mark the factor as disabled if it is locked.
// We store the disabled factors in a separate array so that they can be displayed at the bottom of the template.
if ($loginfactor->get_state() == factor::STATE_LOCKED) {
$additionalfactor['loginoption'] = get_string('locked', 'tool_mfa', $additionalfactor['loginoption']);
$additionalfactor['disable'] = true;
$disabledfactors[] = $additionalfactor;
} else {
$additionalfactors[] = $additionalfactor;
}
$displaycount++;
}
}
// We merge the additional factors placing the disabled ones last.
$alladitionalfactors = array_merge($additionalfactors, $disabledfactors);
$hasadditionalfactors = $displaycount > 0;
$authurl = new \moodle_url('/admin/tool/mfa/auth.php');
// Set the form to better display vertically.
$form->set_display_vertical();
// Check if we need to display a remaining attempts message.
$remattempts = $factor->get_remaining_attempts();
$verificationerror = $form->get_element_error('verificationcode');
if ($remattempts < get_config('tool_mfa', 'lockout') && !empty($verificationerror)) {
// Update the validation error for the code form field to include the remaining attempts.
$remattemptsstr = get_string('lockoutnotification', 'tool_mfa', $factor->get_remaining_attempts());
$updatederror = $verificationerror . '&nbsp;' . $remattemptsstr;
$form->set_element_error('verificationcode', $updatederror);
}
// If all attempts for this factor have been used, disable the form.
// This forces the user to choose another factor or cancel their login.
if ($remattempts <= 0) {
$disablefactor = true;
$form->freeze('verificationcode');
// Handle the trust factor if present.
if ($form->element_exists('factor_token_trust')) {
$form->freeze('factor_token_trust');
}
}
$context = [
'logintitle' => get_string('logintitle', 'factor_'.$factor->name),
'logindesc' => $factor->get_login_desc(),
'factoricon' => $factor->get_icon(),
'form' => $form->render(),
'hasadditionalfactors' => $hasadditionalfactors,
'additionalfactors' => $alladitionalfactors,
'authurl' => $authurl->out(),
'sesskey' => sesskey(),
'supportlink' => $this->get_support_link(),
'disablefactor' => $disablefactor
];
return $this->render_from_template('tool_mfa/verification_form', $context);
}
}
@@ -0,0 +1,386 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace tool_mfa\plugininfo;
use moodle_url;
use stdClass;
/**
* Subplugin info class.
*
* @package tool_mfa
* @author Mikhail Golenkov <golenkovm@gmail.com>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class factor extends \core\plugininfo\base {
/** @var string */
const STATE_UNKNOWN = 'unknown';
/** @var string */
const STATE_PASS = 'pass';
/** @var string */
const STATE_FAIL = 'fail';
/** @var string */
const STATE_NEUTRAL = 'neutral';
/** @var string Locked state is identical to neutral, but can't be overridden */
const STATE_LOCKED = 'locked';
/**
* Finds all MFA factors.
*
* @return array of factor objects.
*/
public static function get_factors(): array {
$return = [];
$factors = \core_plugin_manager::instance()->get_plugins_of_type('factor');
foreach ($factors as $factor) {
$classname = '\\factor_'.$factor->name.'\\factor';
if (class_exists($classname)) {
$return[] = new $classname($factor->name);
}
}
return self::sort_factors_by_order($return);
}
/**
* Sorts factors by configured order.
*
* @param array $unsorted of factor objects
* @return array of factor objects
* @throws \dml_exception
*/
public static function sort_factors_by_order(array $unsorted): array {
$sorted = [];
$orderarray = explode(',', get_config('tool_mfa', 'factor_order'));
foreach ($orderarray as $order => $factorname) {
foreach ($unsorted as $key => $factor) {
if ($factor->name == $factorname) {
$sorted[] = $factor;
unset($unsorted[$key]);
}
}
}
$sorted = array_merge($sorted, $unsorted);
return $sorted;
}
/**
* Finds factor by its name.
*
* @param string $name
*
* @return mixed factor object or false if factor not found.
*/
public static function get_factor(string $name): object|bool {
$factors = \core_plugin_manager::instance()->get_plugins_of_type('factor');
foreach ($factors as $factor) {
if ($name == $factor->name) {
$classname = '\\factor_'.$factor->name.'\\factor';
if (class_exists($classname)) {
return new $classname($factor->name);
}
}
}
return false;
}
/**
* Finds all enabled factors.
*
* @return array of factor objects
*/
public static function get_enabled_factors(): array {
$return = [];
$factors = self::get_factors();
foreach ($factors as $factor) {
if ($factor->is_enabled()) {
$return[] = $factor;
}
}
return $return;
}
/**
* Finds active factors for a user.
* If user is not specified, current user is used.
*
* @param mixed $user user object or null.
* @return array of factor objects.
*/
public static function get_active_user_factor_types(mixed $user = null): array {
global $USER;
if (is_null($user)) {
$user = $USER;
}
$return = [];
$factors = self::get_enabled_factors();
foreach ($factors as $factor) {
$userfactors = $factor->get_active_user_factors($user);
if (count($userfactors) > 0) {
$return[] = $factor;
}
}
return $return;
}
/**
* Returns next factor to authenticate user.
* Only returns factors that require user input.
*
* @return mixed factor object the next factor to be authenticated or false.
*/
public static function get_next_user_login_factor(): mixed {
$factors = self::get_active_user_factor_types();
foreach ($factors as $factor) {
if (!$factor->has_input()) {
continue;
}
if ($factor->get_state() == self::STATE_UNKNOWN) {
return $factor;
}
}
return new \tool_mfa\local\factor\fallback();
}
/**
* Returns all factors that require user input.
*
* @return array of factor objects.
*/
public static function get_all_user_login_factors(): array {
$factors = self::get_active_user_factor_types();
$loginfactors = [];
foreach ($factors as $factor) {
if ($factor->has_input()) {
$loginfactors[] = $factor;
}
}
return $loginfactors;
}
/**
* Returns the list of available actions with factor.
*
* @return array
*/
public static function get_factor_actions(): array {
$actions = [];
$actions[] = 'setup';
$actions[] = 'revoke';
$actions[] = 'enable';
$actions[] = 'revoke';
$actions[] = 'disable';
$actions[] = 'up';
$actions[] = 'down';
$actions[] = 'manage';
$actions[] = 'replace';
return $actions;
}
/**
* Returns the information about plugin availability
*
* True means that the plugin is enabled. False means that the plugin is
* disabled. Null means that the information is not available, or the
* plugin does not support configurable availability or the availability
* can not be changed.
*
* @return null|bool
*/
public function is_enabled(): null|bool {
if (!$this->rootdir) {
// Plugin missing.
return false;
}
$factor = $this->get_factor($this->name);
if ($factor) {
return $factor->is_enabled();
}
return false;
}
/**
* Returns section name for settings.
*
* @return string
*/
public function get_settings_section_name(): string {
return $this->type . '_' . $this->name;
}
/**
* Loads factor settings to the settings tree
*
* This function usually includes settings.php file in plugins folder.
* Alternatively it can create a link to some settings page (instance of admin_externalpage)
*
* @param \part_of_admin_tree $adminroot
* @param string $parentnodename
* @param bool $hassiteconfig whether the current user has moodle/site:config capability
*/
public function load_settings(\part_of_admin_tree $adminroot, $parentnodename, $hassiteconfig): void {
if (!$this->is_installed_and_upgraded()) {
return;
}
if (!$hassiteconfig || !file_exists($this->full_path('settings.php'))) {
return;
}
$section = $this->get_settings_section_name();
$settings = new \admin_settingpage($section, $this->displayname, 'moodle/site:config', $this->is_enabled() === false);
if ($adminroot->fulltree) {
include($this->full_path('settings.php'));
}
$adminroot->add($parentnodename, $settings);
}
/**
* Checks that given factor exists.
*
* @param string $factorname
*
* @return bool
*/
public static function factor_exists(string $factorname): bool {
$factor = self::get_factor($factorname);
return !$factor ? false : true;
}
/**
* Returns instance of any factor from the factorid.
*
* @param int $factorid
*
* @return stdClass|null Factor instance or nothing if not found.
*/
public static function get_instance_from_id(int $factorid): stdClass|null {
global $DB;
return $DB->get_record('tool_mfa', ['id' => $factorid]);
}
/**
* Return URL used for management of plugins of this type.
*
* @return moodle_url
*/
public static function get_manage_url(): moodle_url {
return new moodle_url('/admin/settings.php', [
'section' => 'managemfa',
]);
}
/**
* These subplugins can be uninstalled.
*
* @return bool
*/
public function is_uninstall_allowed(): bool {
return $this->name !== 'nosetup';
}
/**
* Pre-uninstall hook.
*
* This is intended for disabling of plugin, some DB table purging, etc.
*
* NOTE: to be called from uninstall_plugin() only.
* @private
*/
public function uninstall_cleanup() {
global $DB, $CFG;
$DB->delete_records('tool_mfa', ['factor' => $this->name]);
$DB->delete_records('tool_mfa_secrets', ['factor' => $this->name]);
$order = explode(',', get_config('tool_mfa', 'factor_order'));
if (in_array($this->name, $order)) {
$order = array_diff($order, [$this->name]);
\tool_mfa\manager::set_factor_config(['factor_order' => implode(',', $order)], 'tool_mfa');
}
parent::uninstall_cleanup();
}
/**
* Sorts factors by state.
*
* @param array $factors The factors to sort.
* @param string $state The state to sort by.
* @return array $factors The sorted factors.
*/
public static function sort_factors_by_state(array $factors, string $state): array {
usort($factors, function ($a, $b) use ($state) {
$statea = $a->get_state();
$stateb = $b->get_state();
if ($statea === $state && $stateb !== $state) {
return -1; // A comes before B.
}
if ($stateb === $state && $statea !== $state) {
return 1; // B comes before A.
}
return 0; // They are the same, keep current order.
});
return $factors;
}
/**
* Check if the current user has more than one active factor.
*
* @return bool Returns true if there are more than one.
*/
public static function user_has_more_than_one_active_factors(): bool {
$factors = self::get_active_user_factor_types();
$count = count(array_filter($factors, function($factor) {
// Include only user factors that can be set.
return $factor->has_input();
}));
return $count > 1;
}
}
+217
View File
@@ -0,0 +1,217 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Privacy provider.
*
* @package tool_mfa
* @author Mikhail Golenkov <golenkovm@gmail.com>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace tool_mfa\privacy;
use core_privacy\local\metadata\collection;
use core_privacy\local\request\contextlist;
use core_privacy\local\request\approved_contextlist;
use core_privacy\local\request\approved_userlist;
use core_privacy\local\request\writer;
use core_privacy\local\request\userlist;
/**
* Privacy provider
*
* @package tool_mfa
*/
class provider implements
\core_privacy\local\metadata\provider,
\core_privacy\local\request\data_provider {
/**
* Returns metadata about this plugin's privacy policy.
*
* @param collection $collection The initialised collection to add items to.
* @return collection A listing of user data stored through this system.
*/
public static function get_metadata(collection $collection): collection {
$collection->add_database_table(
'tool_mfa',
[
'id' => 'privacy:metadata:tool_mfa:id',
'userid' => 'privacy:metadata:tool_mfa:userid',
'factor' => 'privacy:metadata:tool_mfa:factor',
'secret' => 'privacy:metadata:tool_mfa:secret',
'label' => 'privacy:metadata:tool_mfa:label',
'timecreated' => 'privacy:metadata:tool_mfa:timecreated',
'createdfromip' => 'privacy:metadata:tool_mfa:createdfromip',
'timemodified' => 'privacy:metadata:tool_mfa:timemodified',
'lastverified' => 'privacy:metadata:tool_mfa:lastverified',
],
'privacy:metadata:tool_mfa'
);
$collection->add_database_table(
'tool_mfa_secrets',
[
'userid' => 'privacy:metadata:tool_mfa_secrets:userid',
'factor' => 'privacy:metadata:tool_mfa_secrets:factor',
'secret' => 'privacy:metadata:tool_mfa_secrets:secret',
'sessionid' => 'privacy:metadata:tool_mfa_secrets:sessionid',
],
'privacy:metadata:tool_mfa_secrets'
);
$collection->add_database_table(
'tool_mfa_auth',
[
'userid' => 'privacy:metadata:tool_mfa_auth:userid',
'lastverified' => 'privacy:metadata:tool_mfa_auth:lastverified',
],
'privacy:metadata:tool_mfa_auth'
);
return $collection;
}
/**
* Get the list of contexts that contain user information for the given user.
*
* @param int $userid the userid to search.
* @return contextlist the contexts in which data is contained.
*/
public static function get_contexts_for_userid(int $userid): contextlist {
$contextlist = new \core_privacy\local\request\contextlist();
$contextlist->add_user_context($userid);
$contextlist->add_system_context();
return $contextlist;
}
/**
* Gets the list of users who have data with a context. Secrets context is a subset of this table.
*
* @param userlist $userlist the userlist containing users who have data in this context.
* @return void
*/
public static function get_users_in_context(userlist $userlist): void {
$context = $userlist->get_context();
// If current context is system, all users are contained within, get all users.
if ($context->contextlevel == CONTEXT_SYSTEM) {
$sql = "
SELECT *
FROM {tool_mfa}";
$userlist->add_from_sql('userid', $sql, []);
}
}
/**
* Exports all data stored in provided contexts for user. Secrets should not be exported as they are transient.
*
* @param approved_contextlist $contextlist the list of contexts to export for.
* @return void
*/
public static function export_user_data(approved_contextlist $contextlist): void {
global $DB;
$userid = $contextlist->get_user()->id;
foreach ($contextlist as $context) {
// If not in system context, exit loop.
if ($context->contextlevel == CONTEXT_SYSTEM) {
$parentclass = [];
// Get records for user ID.
$rows = $DB->get_records('tool_mfa', ['userid' => $userid]);
if (count($rows) > 0) {
$i = 0;
foreach ($rows as $row) {
$parentclass[$i]['userid'] = $row->userid;
$timecreated = \core_privacy\local\request\transform::datetime($row->timecreated);
$parentclass[$i]['factor'] = $row->factor;
$parentclass[$i]['timecreated'] = $timecreated;
$parentclass[$i]['createdfromip'] = $row->createdfromip;
$timemodified = \core_privacy\local\request\transform::datetime($row->timemodified);
$parentclass[$i]['timemodified'] = $timemodified;
$lastverified = \core_privacy\local\request\transform::datetime($row->lastverified);
$parentclass[$i]['lastverified'] = $lastverified;
$parentclass[$i]['revoked'] = $row->revoked;
$i++;
}
}
// Also get lastverified auth time for user, and add.
$lastverifiedauth = $DB->get_field('tool_mfa_auth', 'lastverified', ['userid' => $userid]);
if (!empty($lastverifiedauth)) {
$lastverifiedauth = \core_privacy\local\request\transform::datetime($lastverifiedauth);
$parentclass['lastverifiedauth'] = $lastverifiedauth;
}
writer::with_context($context)->export_data(
[get_string('privacy:metadata:tool_mfa', 'tool_mfa')],
(object) $parentclass);
}
}
}
/**
* Deletes data for all users in context.
*
* @param context $context The context to delete for.
* @return void
*/
public static function delete_data_for_all_users_in_context(\context $context): void {
global $DB;
// All data contained in system context.
if ($context->contextlevel == CONTEXT_SYSTEM) {
$DB->delete_records('tool_mfa', []);
$DB->delete_records('tool_mfa_secrets', []);
$DB->delete_records('tool_mfa_auth', []);
}
}
/**
* Deletes all data in all provided contexts for user.
*
* @param approved_contextlist $contextlist the list of contexts to delete for.
* @return void
*/
public static function delete_data_for_user(approved_contextlist $contextlist): void {
global $DB;
$userid = $contextlist->get_user()->id;
foreach ($contextlist as $context) {
// If not in system context, skip context.
if ($context->contextlevel == CONTEXT_SYSTEM) {
$DB->delete_records('tool_mfa', ['userid' => $userid]);
$DB->delete_records('tool_mfa_secrets', ['userid' => $userid]);
$DB->delete_records('tool_mfa_auth', ['userid' => $userid]);
}
}
}
/**
* Given a userlist, deletes all data in all provided contexts for the users
*
* @param approved_userlist $userlist the list of users to delete data for
* @return void
*/
public static function delete_data_for_users(approved_userlist $userlist): void {
$users = $userlist->get_users();
foreach ($users as $user) {
// Create contextlist.
$contextlist = new approved_contextlist($user, 'tool_mfa', [CONTEXT_SYSTEM]);
// Call delete data.
self::delete_data_for_user($contextlist);
}
}
}