MDL-79920 tool_mfa: Improve MFA management for users

Includes the ability to replace/update a factor. Major changes to
classes and strings were performed to allow for these improvements.
This commit is contained in:
David Woloszyn 2024-03-04 18:21:33 +11:00
parent b2ed67eb43
commit 34bc9a2e9f
18 changed files with 642 additions and 86 deletions

View File

@ -25,7 +25,6 @@
require_once(__DIR__ . '/../../../config.php');
use tool_mfa\local\form\setup_factor_form;
use tool_mfa\local\form\revoke_factor_form;
require_login(null, false);
if (isguestuser()) {
@ -63,7 +62,6 @@ $context = context_user::instance($USER->id);
$PAGE->set_context($context);
$PAGE->set_url('/admin/tool/mfa/action.php');
$PAGE->set_pagelayout('standard');
$PAGE->set_title(get_string($action.'factor', 'tool_mfa'));
$PAGE->set_cacheable(false);
if ($node = $PAGE->settingsnav->find('usercurrentsettings', null)) {
@ -77,7 +75,8 @@ switch ($action) {
redirect($returnurl);
}
$PAGE->navbar->add(get_string('setupfactor', 'factor_'.$factor));
$PAGE->set_title(get_string('setupfactor', 'tool_mfa'));
$PAGE->navbar->add($factorobject->get_setup_string());
$OUTPUT = $PAGE->get_renderer('tool_mfa');
$form = new setup_factor_form($currenturl, ['factorname' => $factor]);
@ -106,37 +105,35 @@ switch ($action) {
break;
case 'revoke':
// Ensure sesskey is valid.
require_sesskey();
if (!$factorobject || !$factorobject->has_revoke()) {
throw new moodle_exception('error:revoke', 'tool_mfa', $returnurl);
case 'replace':
// Replace works much the same as setup.
if (!$factorobject || !$factorobject->has_replace()) {
redirect($returnurl);
}
$PAGE->navbar->add(get_string('action:revoke', 'factor_'.$factor));
$PAGE->set_title(get_string('replacefactor', 'tool_mfa'));
$PAGE->navbar->add($factorobject->get_setup_string());
$OUTPUT = $PAGE->get_renderer('tool_mfa');
$revokeparams = [
'factorname' => $factorobject->get_display_name(),
'devicename' => $factorobject->get_label($factorid),
];
$form = new revoke_factor_form($currenturl, $revokeparams);
// Use setup factor form, but pass in additional id for replacement.
$form = new setup_factor_form($currenturl, ['factorname' => $factor, 'replaceid' => $factorid]);
if ($form->is_submitted()) {
$form->is_validated();
if ($form->is_cancelled()) {
$factorobject->setup_factor_form_is_cancelled($factorid);
redirect($returnurl);
}
if ($form->get_data()) {
if ($factorobject->revoke_user_factor($factorid)) {
$finalurl = new moodle_url($returnurl, ['action' => 'revoked', 'factorid' => $factorid]);
if ($data = $form->get_data()) {
$record = $factorobject->replace_user_factor($data, $factorid);
if (!empty($record)) {
$factorobject->set_state(\tool_mfa\plugininfo\factor::STATE_PASS);
$finalurl = new moodle_url($returnurl, ['action' => 'setup', 'factorid' => $record->id]);
redirect($finalurl);
}
throw new moodle_exception('error:revoke', 'tool_mfa', $returnurl);
throw new moodle_exception('error:setupfactor', 'tool_mfa', $returnurl);
}
}
@ -145,6 +142,38 @@ switch ($action) {
break;
case 'revoke':
// Ensure sesskey is valid.
require_sesskey();
$PAGE->set_title(get_string('revokefactor', 'tool_mfa'));
if (!$factorobject || !$factorobject->has_revoke()) {
throw new moodle_exception('error:revoke', 'tool_mfa', $returnurl);
}
if ($factorobject->revoke_user_factor($factorid)) {
$finalurl = new moodle_url($returnurl, ['action' => 'revoked', 'factorid' => $factorid]);
redirect($finalurl);
}
throw new moodle_exception('error:revoke', 'tool_mfa', $returnurl);
break;
case 'manage':
$PAGE->set_title(get_string('managefactor', 'tool_mfa'));
$PAGE->navbar->add(get_string('action:manage', 'factor_'.$factor));
$OUTPUT = $PAGE->get_renderer('tool_mfa');
echo $OUTPUT->header();
echo $OUTPUT->heading(get_string('managefactor', 'factor_' . $factorobject->name));
echo $OUTPUT->active_factors($factor);
echo $OUTPUT->single_button($returnurl, get_string('back'));
// JS for modal confirming replace and revoke actions.
$PAGE->requires->js_call_amd('tool_mfa/confirmation_modal', 'init', [$context->id]);
break;
default:
break;
}

View File

@ -0,0 +1,10 @@
define("tool_mfa/confirmation_modal",["exports","core/modal_events","core/modal_save_cancel","core/notification","core/str","core/url","core/fragment"],(function(_exports,_modal_events,_modal_save_cancel,_notification,_str,_url,_fragment){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}
/**
* Modal for confirming factor actions.
*
* @module tool_mfa/confirmation_modal
* @copyright 2023 David Woloszyn <david.woloszyn@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_modal_events=_interopRequireDefault(_modal_events),_modal_save_cancel=_interopRequireDefault(_modal_save_cancel),_notification=_interopRequireDefault(_notification),_url=_interopRequireDefault(_url),_fragment=_interopRequireDefault(_fragment);const SELECTORS_ACTION=".mfa-action-button";_exports.init=contextId=>{registerEventListeners(contextId)};const registerEventListeners=contextId=>{document.addEventListener("click",(e=>{const action=e.target.closest(SELECTORS_ACTION);action&&buildModal(action,contextId).catch(_notification.default.exception)}))},buildModal=async(element,contextId)=>{const data={action:element.getAttribute("data-action"),factor:element.getAttribute("data-factor"),factorid:element.getAttribute("data-factorid"),devicename:element.getAttribute("data-devicename"),actionurl:_url.default.relativeUrl("/admin/tool/mfa/action.php")};"revoke"===data.action?(data.title=await(0,_str.getString)("revokefactorconfirmation","factor_"+data.factor,data.devicename),data.buttontext=await(0,_str.getString)("yesremove","tool_mfa")):"replace"===data.action&&(data.title=await(0,_str.getString)("replacefactorconfirmation","factor_"+data.factor,data.devicename),data.buttontext=await(0,_str.getString)("yesreplace","tool_mfa"));const modal=await _modal_save_cancel.default.create({title:data.title,body:_fragment.default.loadFragment("tool_mfa","factor_action_confirmation_form",contextId,data),show:!0,buttons:{save:data.buttontext,cancel:(0,_str.getString)("cancel","moodle")}});modal.getRoot().on(_modal_events.default.save,(()=>{modal.getRoot().find("form").submit()}))}}));
//# sourceMappingURL=confirmation_modal.min.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"confirmation_modal.min.js","sources":["../src/confirmation_modal.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Modal for confirming factor actions.\n *\n * @module tool_mfa/confirmation_modal\n * @copyright 2023 David Woloszyn <david.woloszyn@moodle.com>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport ModalEvents from 'core/modal_events';\nimport ModalSaveCancel from 'core/modal_save_cancel';\nimport Notification from 'core/notification';\nimport {getString} from 'core/str';\nimport Url from 'core/url';\nimport Fragment from 'core/fragment';\n\nconst SELECTORS = {\n ACTION: '.mfa-action-button',\n};\n\n/**\n * Entrypoint of the js.\n *\n * @method init\n * @param {Number} contextId Context ID of the user.\n */\nexport const init = (contextId) => {\n registerEventListeners(contextId);\n};\n\n/**\n * Register event listeners.\n *\n * @method registerEventListeners\n * @param {Number} contextId Context ID of the user.\n */\nconst registerEventListeners = (contextId) => {\n document.addEventListener('click', (e) => {\n const action = e.target.closest(SELECTORS.ACTION);\n if (action) {\n buildModal(action, contextId).catch(Notification.exception);\n }\n });\n};\n\n/**\n * Build the modal with the provided data.\n *\n * @method buildModal\n * @param {object} element The button element.\n * @param {Number} contextId Context ID of the user.\n */\nconst buildModal = async(element, contextId) => {\n\n // Prepare data for modal.\n const data = {\n action: element.getAttribute('data-action'),\n factor: element.getAttribute('data-factor'),\n factorid: element.getAttribute('data-factorid'),\n devicename: element.getAttribute('data-devicename'),\n actionurl: Url.relativeUrl('/admin/tool/mfa/action.php'),\n };\n\n // Customise modal depending on action being performed.\n if (data.action === 'revoke') {\n data.title = await getString('revokefactorconfirmation', 'factor_' + data.factor, data.devicename);\n data.buttontext = await getString('yesremove', 'tool_mfa');\n\n } else if (data.action === 'replace') {\n data.title = await getString('replacefactorconfirmation', 'factor_' + data.factor, data.devicename);\n data.buttontext = await getString('yesreplace', 'tool_mfa');\n }\n\n const modal = await ModalSaveCancel.create({\n title: data.title,\n body: Fragment.loadFragment('tool_mfa', 'factor_action_confirmation_form', contextId, data),\n show: true,\n buttons: {\n 'save': data.buttontext,\n 'cancel': getString('cancel', 'moodle'),\n },\n });\n\n modal.getRoot().on(ModalEvents.save, () => {\n modal.getRoot().find('form').submit();\n });\n\n};\n"],"names":["SELECTORS","contextId","registerEventListeners","document","addEventListener","e","action","target","closest","buildModal","catch","Notification","exception","async","element","data","getAttribute","factor","factorid","devicename","actionurl","Url","relativeUrl","title","buttontext","modal","ModalSaveCancel","create","body","Fragment","loadFragment","show","buttons","getRoot","on","ModalEvents","save","find","submit"],"mappings":";;;;;;;4UA8BMA,iBACM,mCASSC,YACjBC,uBAAuBD,kBASrBC,uBAA0BD,YAC5BE,SAASC,iBAAiB,SAAUC,UAC1BC,OAASD,EAAEE,OAAOC,QAAQR,kBAC5BM,QACAG,WAAWH,OAAQL,WAAWS,MAAMC,sBAAaC,eAYvDH,WAAaI,MAAMC,QAASb,mBAGxBc,KAAO,CACTT,OAAQQ,QAAQE,aAAa,eAC7BC,OAAQH,QAAQE,aAAa,eAC7BE,SAAUJ,QAAQE,aAAa,iBAC/BG,WAAYL,QAAQE,aAAa,mBACjCI,UAAWC,aAAIC,YAAY,+BAIX,WAAhBP,KAAKT,QACLS,KAAKQ,YAAc,kBAAU,2BAA4B,UAAYR,KAAKE,OAAQF,KAAKI,YACvFJ,KAAKS,iBAAmB,kBAAU,YAAa,aAExB,YAAhBT,KAAKT,SACZS,KAAKQ,YAAc,kBAAU,4BAA6B,UAAYR,KAAKE,OAAQF,KAAKI,YACxFJ,KAAKS,iBAAmB,kBAAU,aAAc,mBAG9CC,YAAcC,2BAAgBC,OAAO,CACvCJ,MAAOR,KAAKQ,MACZK,KAAMC,kBAASC,aAAa,WAAY,kCAAmC7B,UAAWc,MACtFgB,MAAM,EACNC,QAAS,MACGjB,KAAKS,mBACH,kBAAU,SAAU,aAItCC,MAAMQ,UAAUC,GAAGC,sBAAYC,MAAM,KACjCX,MAAMQ,UAAUI,KAAK,QAAQC"}

View File

@ -0,0 +1,102 @@
// 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/>.
/**
* Modal for confirming factor actions.
*
* @module tool_mfa/confirmation_modal
* @copyright 2023 David Woloszyn <david.woloszyn@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import ModalEvents from 'core/modal_events';
import ModalSaveCancel from 'core/modal_save_cancel';
import Notification from 'core/notification';
import {getString} from 'core/str';
import Url from 'core/url';
import Fragment from 'core/fragment';
const SELECTORS = {
ACTION: '.mfa-action-button',
};
/**
* Entrypoint of the js.
*
* @method init
* @param {Number} contextId Context ID of the user.
*/
export const init = (contextId) => {
registerEventListeners(contextId);
};
/**
* Register event listeners.
*
* @method registerEventListeners
* @param {Number} contextId Context ID of the user.
*/
const registerEventListeners = (contextId) => {
document.addEventListener('click', (e) => {
const action = e.target.closest(SELECTORS.ACTION);
if (action) {
buildModal(action, contextId).catch(Notification.exception);
}
});
};
/**
* Build the modal with the provided data.
*
* @method buildModal
* @param {object} element The button element.
* @param {Number} contextId Context ID of the user.
*/
const buildModal = async(element, contextId) => {
// Prepare data for modal.
const data = {
action: element.getAttribute('data-action'),
factor: element.getAttribute('data-factor'),
factorid: element.getAttribute('data-factorid'),
devicename: element.getAttribute('data-devicename'),
actionurl: Url.relativeUrl('/admin/tool/mfa/action.php'),
};
// Customise modal depending on action being performed.
if (data.action === 'revoke') {
data.title = await getString('revokefactorconfirmation', 'factor_' + data.factor, data.devicename);
data.buttontext = await getString('yesremove', 'tool_mfa');
} else if (data.action === 'replace') {
data.title = await getString('replacefactorconfirmation', 'factor_' + data.factor, data.devicename);
data.buttontext = await getString('yesreplace', 'tool_mfa');
}
const modal = await ModalSaveCancel.create({
title: data.title,
body: Fragment.loadFragment('tool_mfa', 'factor_action_confirmation_form', contextId, data),
show: true,
buttons: {
'save': data.buttontext,
'cancel': getString('cancel', 'moodle'),
},
});
modal.getRoot().on(ModalEvents.save, () => {
modal.getRoot().find('form').submit();
});
};

View File

@ -146,6 +146,19 @@ abstract class object_factor_base implements object_factor {
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.
*
@ -218,6 +231,20 @@ abstract class object_factor_base implements object_factor {
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).
*
@ -332,6 +359,18 @@ abstract class object_factor_base implements object_factor {
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.
@ -539,6 +578,15 @@ abstract class object_factor_base implements object_factor {
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.
*

View File

@ -21,29 +21,36 @@ defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir . "/formslib.php");
/**
* Revoke factor form
* 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 revoke_factor_form extends \moodleform {
class factor_action_confirmation_form extends \moodleform {
/**
* {@inheritDoc}
* @see moodleform::definition()
* Form definition.
*/
public function definition(): void {
global $OUTPUT;
$mform = $this->_form;
$factorname = $this->_customdata['factorname'];
$factor = $this->_customdata['factor'];
$devicename = $this->_customdata['devicename'];
$factorid = $this->_customdata['factorid'];
$action = $this->_customdata['action'];
$mform->addElement('html', $OUTPUT->heading(get_string('areyousure', 'tool_mfa'), 4));
$mform->addElement('html', $OUTPUT->heading(get_string('factor', 'tool_mfa').': '.$factorname, 5));
$mform->addElement('html', $OUTPUT->heading(get_string('devicename', 'tool_mfa').': '.$devicename, 5));
$mform->addElement('html', get_string('confirmation' . $action, 'tool_mfa', $devicename));
$this->add_action_buttons(true, get_string('revoke', 'tool_mfa'));
$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());
}
}

View File

@ -36,12 +36,17 @@ class setup_factor_form extends \moodleform {
*/
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);
}
/**

View File

@ -69,18 +69,78 @@ class renderer extends \plugin_renderer_base {
* @return string
*/
public function available_factors(): string {
$html = $this->output->heading(get_string('preferences:availablefactors', 'tool_mfa'), 2);
global $USER;
$factors = factor::get_enabled_factors();
$data = [];
foreach ($factors as $factor) {
// TODO is_configured / is_ready.
if (!$factor->has_setup() || !$factor->show_setup_buttons()) {
// 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;
}
$html .= $this->setup_factor($factor);
$userfactors = $factor->get_active_user_factors($USER);
$active = !empty($userfactors) ?? false;
$button = null;
$icon = $factor->get_icon();
$params = [
'action' => 'setup',
'factor' => $factor->name,
'sesskey' => sesskey(),
];
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 $html;
return $this->render_from_template('tool_mfa/mfa_selector', $data);
}
/**
@ -88,8 +148,12 @@ class renderer extends \plugin_renderer_base {
*
* @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']);
@ -109,53 +173,56 @@ class renderer extends \plugin_renderer_base {
}
/**
* Defines section with active user's factors.
* 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 {
public function active_factors(string $filterfactor = null): string {
global $USER, $CFG;
require_once($CFG->dirroot . '/iplookup/lib.php');
$html = $this->output->heading(get_string('preferences:activefactors', 'tool_mfa'), 2);
$html = '';
$headers = get_strings([
'factor',
'devicename',
'created',
'createdfromip',
'lastverified',
'revoke',
'added',
'lastused',
'replace',
'remove',
], 'tool_mfa');
$table = new \html_table();
$table->id = 'active_factors';
$table->attributes['class'] = 'generaltable table table-bordered';
$table->head = [
$headers->factor,
$headers->devicename,
$headers->created,
$headers->createdfromip,
$headers->lastverified,
$headers->revoke,
$headers->added,
$headers->lastused,
$headers->replace,
$headers->remove,
];
$table->colclasses = [
'leftalign',
'leftalign',
'centeralign',
'centeralign',
'centeralign',
'centeralign',
'centeralign',
'centeralign',
'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()) {
@ -163,15 +230,39 @@ class renderer extends \plugin_renderer_base {
}
foreach ($userfactors as $userfactor) {
if ($factor->has_revoke()) {
$revokeparams = [
'action' => 'revoke', 'factor' => $factor->name,
'factorid' => $userfactor->id, 'sesskey' => sesskey(),
// 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',
];
$revokeurl = new \moodle_url('action.php', $revokeparams);
$revokelink = \html_writer::link($revokeurl, $headers->revoke);
$revokebutton = \html_writer::tag('button', $content, $attributes);
} else {
$revokelink = '';
$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 == '-' ? '-'
@ -185,17 +276,12 @@ class renderer extends \plugin_renderer_base {
$lastverified .= get_string('ago', 'core_message', format_time(time() - $userfactor->lastverified));
}
$info = iplookup_find_location($userfactor->createdfromip);
$ip = $userfactor->createdfromip;
$ip .= '<br>' . $info['country'] . ' - ' . $info['city'];
$row = new \html_table_row([
$factor->get_display_name(),
$userfactor->label,
$timecreated,
$ip,
$lastverified,
$revokelink,
$replacebutton,
$revokebutton,
]);
$table->data[] = $row;
}

View File

@ -205,6 +205,8 @@ class factor extends \core\plugininfo\base {
$actions[] = 'disable';
$actions[] = 'up';
$actions[] = 'down';
$actions[] = 'manage';
$actions[] = 'replace';
return $actions;
}
@ -366,4 +368,19 @@ class factor extends \core\plugininfo\base {
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;
}
}

View File

@ -0,0 +1,30 @@
<?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/>.
/**
* This file contains mappings for classes that have been renamed.
*
* @package tool_mfa
* @copyright 2024 David Woloszyn <david.woloszyn@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$renamedclasses = [
// Since Moodle 4.4.
'tool_mfa\\local\\form\\revoke_factor_form' => 'tool_mfa\\local\\form\\factor_action_confirmation_form',
];

View File

@ -0,0 +1,4 @@
created,tool_mfa
lastverified,tool_mfa
revoke,tool_mfa
createdfromip,tool_mfa

View File

@ -26,18 +26,21 @@
defined('MOODLE_INTERNAL') || die();
$string['achievedweight'] = 'Achieved weight';
$string['added'] = 'Added';
$string['alltime'] = 'All time';
$string['areyousure'] = 'Are you sure you want to revoke factor?';
$string['areyousure'] = 'Are you sure you want to remove this factor?';
$string['cancellogin'] = 'Cancel login';
$string['combination'] = 'Combination';
$string['confirmationreplace'] = 'You will be immediately required to set up another \'{$a}\'. Please make sure you are ready to complete the setup process.';
$string['confirmationrevoke'] = 'You will no longer be able to use \'{$a}\' to log in to this site.';
$string['connector'] = 'AND';
$string['created'] = 'Created';
$string['createdfromip'] = 'Created from IP';
$string['debugmode:heading'] = 'Debug mode';
$string['devicename'] = 'Device';
$string['entercode'] = 'Enter code';
$string['email:subject'] = 'Unable to log in to {$a}';
$string['enablefactor'] = 'Enable factor';
$string['error:actionnotfound'] = 'Action \'{$a}\' not supported';
$string['error:couldnotreplace'] = 'Could not replace this factor.';
$string['error:directaccess'] = 'This page shouldn\'t be accessed directly';
$string['error:factornotenabled'] = 'Multi-factor authentication factor \'{$a}\' not enabled';
$string['error:factornotfound'] = 'Multi-factor authentication factor \'{$a}\' not found';
@ -45,7 +48,7 @@ $string['error:isguestuser'] = 'Guests are not allowed here.';
$string['error:notenoughfactors'] = 'Unable to authenticate';
$string['error:reauth'] = 'We couldn\'t confirm your identity sufficiently to meet the site authentication security policy.<br>This may be due to: <br> 1) Steps being locked - please wait a few minutes and try again.
<br> 2) Steps being failed - please double check the details for each step. <br> 3) Steps were skipped - please reload this page or try logging in again.';
$string['error:revoke'] = 'Can\'t revoke factor';
$string['error:revoke'] = 'Can\'t remove factor';
$string['error:setupfactor'] = 'Can\'t set up factor';
$string['error:support'] = 'If you are still unable to log in, or believe you are seeing this in error, please email:';
$string['error:wrongfactorid'] = 'Factor ID \'{$a}\' is incorrect';
@ -58,22 +61,25 @@ $string['event:userpassedmfa'] = 'Verification passed';
$string['event:userrevokedfactor'] = 'Factor revocation';
$string['event:usersetupfactor'] = 'Factor setup';
$string['factor'] = 'Factor';
$string['factorreplace'] = 'Factor \'{$a}\' successfully replaced.';
$string['factorreport'] = 'All factor report';
$string['factorreset'] = 'Your multi-factor authentication \'{$a->factor}\' has been reset by a site administrator. You may need to set up this factor again. {$a->url}';
$string['factorresetall'] = 'All your multi-factor authentication factors have been reset by a site administrator. You may need to set up these factors again. {$a}';
$string['factorrevoked'] = 'Factor \'{$a}\' successfully revoked.';
$string['factorsetup'] = 'Factor \'{$a}\' successfully set up.';
$string['factorrevoked'] = '\'{$a}\' successfully removed.';
$string['factorsetup'] = '\'{$a}\' successfully set up.';
$string['fallback'] = 'Fallback factor';
$string['fallback_info'] = 'This factor is a fallback if no other factors are configured. This factor will always fail.';
$string['guidance'] = 'Multi-factor authentication user guide';
$string['inputrequired'] = 'User input';
$string['ipatcreation'] = 'IP address when factor created';
$string['lastverified'] = 'Last verified';
$string['lastused'] = 'Last used';
$string['locked'] = '{$a} (Unavailable)';
$string['lockedusersforallfactors'] = 'Locked users: All factors';
$string['lockedusersforfactor'] = 'Locked users: {$a}';
$string['lockoutnotification'] = 'You have {$a} attempts left.';
$string['managefactor'] = 'Manage factor';
$string['mfa'] = 'Multi-factor authentication';
$string['mfa:intro'] = 'Easily set up and manage multi-factor authentication.';
$string['mfa:mfaaccess'] = 'Interact with MFA';
$string['mfareports'] = 'MFA reports';
$string['mfasettings'] = 'Manage multi-factor authentication';
@ -108,6 +114,9 @@ $string['privacy:metadata:tool_mfa_secrets:secret'] = 'The secret security code.
$string['privacy:metadata:tool_mfa_secrets:sessionid'] = 'The session ID this secret is associated with.';
$string['privacy:metadata:tool_mfa_secrets:userid'] = 'The user this secret is associated with.';
$string['redirecterrordetected'] = 'Unsupported redirect detected, script execution terminated. Redirection error occured between MFA and {$a}.';
$string['remove'] = 'Remove';
$string['replace'] = 'Replace';
$string['replacefactor'] = 'Replace factor';
$string['resetconfirm'] = 'Reset user factor';
$string['resetfactor'] = 'Reset user authentication factors';
$string['resetfactorconfirm'] = 'Are you sure you wish to reset this factor for {$a}?';
@ -115,8 +124,7 @@ $string['resetfactorplaceholder'] = 'Username or email';
$string['resetsuccess'] = 'Factor \'{$a->factor}\' successfully reset for user \'{$a->username}\'.';
$string['resetsuccessbulk'] = 'Factor \'{$a}\' successfully reset for provided users.';
$string['resetuser'] = 'User:';
$string['revoke'] = 'Revoke';
$string['revokefactor'] = 'Revoke factor';
$string['revokefactor'] = 'Remove factor';
$string['selectfactor'] = 'Select factor to reset:';
$string['selectperiod'] = 'Select a lookback period for the report:';
$string['settings:combinations'] = 'Summary of good conditions for login';
@ -141,8 +149,7 @@ $string['settings:redir_exclusions'] = 'URLS which should not redirect the MFA c
$string['settings:redir_exclusions_help'] = 'Each new line is a relative URL from the siteroot for which the MFA check will not redirect from';
$string['settings:weight'] = 'Factor weight';
$string['settings:weight_help'] = 'The weight of this factor if passed. A user needs at least 100 points to log in.';
$string['setup'] = 'Setup';
$string['setupfactor'] = 'Setup factor';
$string['setupfactor'] = 'Set up factor';
$string['setuprequired'] = 'User setup';
$string['state:fail'] = 'Fail';
$string['state:locked'] = 'Locked';
@ -159,7 +166,15 @@ $string['usernotfound'] = 'Unable to locate user.';
$string['usersauthedinperiod'] = 'Logged in';
$string['verification'] = '2-step verification';
$string['verification_desc'] = 'To keep your account safe, we need to check that this is really you.';
$string['verificationcode'] = 'Enter code';
$string['verificationcode'] = 'Verification code';
$string['verificationcode_help'] = 'The verification code provided by the current authentication factor.';
$string['verifyalt'] = 'Try another way to verify:';
$string['weight'] = 'Weight';
$string['yesremove'] = 'Yes, remove';
$string['yesreplace'] = 'Yes, replace';
// Deprecated since Moodle 4.4.
$string['created'] = 'Created';
$string['lastverified'] = 'Last verified';
$string['revoke'] = 'Revoke';
$string['createdfromip'] = 'Created from IP';

View File

@ -138,3 +138,33 @@ function tool_mfa_pluginfile(stdClass $course, stdClass $cm, context $context, s
return true;
}
/**
* Fragment to confirm a factor action using the confirmation form.
*
* @param array $args Arguments to the form.
* @return null|string The rendered form.
*/
function tool_mfa_output_fragment_factor_action_confirmation_form($args) {
// Check args are not empty.
foreach ($args as $key => $arg) {
if (empty($arg)) {
throw new \moodle_exception('missingparam', 'error', '', $key);
}
}
$customdata = [
'action' => $args['action'],
'factor' => $args['factor'],
'factorid' => $args['factorid'],
'devicename' => $args['devicename'],
];
// Indicate we are performing a replacement by include the replace id.
if ($args['action'] === 'replace') {
$customdata['replaceid'] = $args['factorid'];
}
$mform = new tool_mfa\local\form\factor_action_confirmation_form($args['actionurl'], $customdata);
return $mform->render();
}

View File

@ -0,0 +1,78 @@
{{!
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/>.
}}
{{!
@template tool_mfa/mfa_card
This template renders the singular card for an authentication factor.
Example context (json):
{
"label": "SMS Mobile phone",
"name": "sms",
"info": "Have a verification number sent to the mobile number you choose.",
"icon": "fa-commenting-o",
"active": true,
"button": {
"id": "single_button123",
"method": "post",
"action": "action.php",
"label": "Setup",
"params": [
{
"name": "sesskey",
"value": "123XYZ"
},
{
"name": "factor",
"value": "sms"
},
{
"name": "action",
"value": "setup"
}
]
}
}
}}
<div class="card dashboard-card {{#active}}active{{/active}}" role="listitem" id="factor-card-{{name}}" aria-labelledby="factor-name-{{name}} {{#active}}active-factor-{{name}}{{/active}}">
<div class="card-img p-3">
<div class="icon-circle {{^active}}reversed{{/active}}">
<i class="icon iconsize-big fa {{icon}}" role="presentation"></i>
</div>
</div>
<div class="card-body p-3">
<div class="d-flex">
<div class="flex-grow-1">
<h3 class="h5" id="factor-name-{{name}}">{{label}}</h3>
{{{info}}}
</div>
</div>
</div>
<div class="d-flex align-items-end justify-content-between p-3">
{{#active}}
<div class="d-flex">
<strong><span class="text-success" id="active-factor-{{name}}">{{#str}}active, moodle{{/str}}</span></strong>
</div>
{{/active}}
{{#button}}
<div class="d-flex ml-auto">
{{>core/single_button}}
</div>
{{/button}}
</div>
</div>

View File

@ -0,0 +1,88 @@
{{!
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/>.
}}
{{!
@template tool_mfa/mfa_selector
This template renders the cards view for displaying available authentications to a user.
Example context (json):
{
"factors": [
{
"label": "SMS Mobile phone",
"name": "sms",
"info": "Have a verification number sent to the mobile number you choose.",
"icon": "fa-commenting-o",
"active": true,
"button": {
"id": "single_button123",
"method": "post",
"action": "action.php",
"label": "Setup",
"params": [
{
"name": "sesskey",
"value": "123XYZ"
},
{
"name": "factor",
"value": "sms"
},
{
"name": "action",
"value": "manage"
}
]
}
},
{
"label": "Authenticator app",
"name": "totp",
"info": "Use an authenticator app on your mobile phone.",
"icon": "fa-mobile-screen",
"active": false,
"button": {
"id": "single_button456",
"method": "post",
"action": "action.php",
"label": "Setup",
"params": [
{
"name": "sesskey",
"value": "123XYZ"
},
{
"name": "factor",
"value": "totp"
},
{
"name": "action",
"value": "setup"
}
]
}
}
]
}
}}
<h2>{{#str}}mfa, tool_mfa{{/str}}</h2>
<p>{{#str}}mfa:intro, tool_mfa{{/str}}</p>
<div class="card-deck dashboard-card-deck" id="mfalist" data-region="card-deck" role="list">
{{#factors}}
{{>tool_mfa/mfa_card}}
{{/factors}}
</div>

View File

@ -0,0 +1,7 @@
This files describes API changes for code that uses MFA.
=== 4.4 ===
* The method tool_mfa\output\renderer::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() from MFA renderer.
* The class tool_mfa\local\form\revoke_factor_form is renamed to factor_action_confirmation_form to better suit the other actions
it is performing (replace and revoke).

View File

@ -58,7 +58,6 @@ if (!empty($action)) {
}
}
echo $OUTPUT->active_factors();
echo $OUTPUT->available_factors();
$renderer = $PAGE->get_renderer('tool_mfa');

View File

@ -25,7 +25,7 @@
defined('MOODLE_INTERNAL') || die();
$plugin->version = 2023100900; // The current plugin version (Date: YYYYMMDDXX).
$plugin->version = 2024030402; // The current plugin version (Date: YYYYMMDDXX).
$plugin->requires = 2023100400; // Requires this Moodle version.
$plugin->component = 'tool_mfa'; // Full name of the plugin (used for diagnostics).
$plugin->maturity = MATURITY_STABLE;