From 34bc9a2e9f5f49fc9deaa5fcb4a9c57bb9cd67b2 Mon Sep 17 00:00:00 2001 From: David Woloszyn Date: Mon, 4 Mar 2024 18:21:33 +1100 Subject: [PATCH] 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. --- admin/tool/mfa/action.php | 69 ++++--- .../mfa/amd/build/confirmation_modal.min.js | 10 ++ .../amd/build/confirmation_modal.min.js.map | 1 + admin/tool/mfa/amd/src/confirmation_modal.js | 102 +++++++++++ .../local/factor/object_factor_base.php | 48 +++++ ...hp => factor_action_confirmation_form.php} | 27 +-- .../classes/local/form/setup_factor_form.php | 7 +- admin/tool/mfa/classes/output/renderer.php | 168 +++++++++++++----- admin/tool/mfa/classes/plugininfo/factor.php | 17 ++ admin/tool/mfa/db/renamedclasses.php | 30 ++++ admin/tool/mfa/lang/en/deprecated.txt | 4 + admin/tool/mfa/lang/en/tool_mfa.php | 39 ++-- admin/tool/mfa/lib.php | 30 ++++ admin/tool/mfa/templates/mfa_card.mustache | 78 ++++++++ .../tool/mfa/templates/mfa_selector.mustache | 88 +++++++++ admin/tool/mfa/upgrade.txt | 7 + admin/tool/mfa/user_preferences.php | 1 - admin/tool/mfa/version.php | 2 +- 18 files changed, 642 insertions(+), 86 deletions(-) create mode 100644 admin/tool/mfa/amd/build/confirmation_modal.min.js create mode 100644 admin/tool/mfa/amd/build/confirmation_modal.min.js.map create mode 100644 admin/tool/mfa/amd/src/confirmation_modal.js rename admin/tool/mfa/classes/local/form/{revoke_factor_form.php => factor_action_confirmation_form.php} (60%) create mode 100644 admin/tool/mfa/db/renamedclasses.php create mode 100644 admin/tool/mfa/lang/en/deprecated.txt create mode 100644 admin/tool/mfa/templates/mfa_card.mustache create mode 100644 admin/tool/mfa/templates/mfa_selector.mustache create mode 100644 admin/tool/mfa/upgrade.txt diff --git a/admin/tool/mfa/action.php b/admin/tool/mfa/action.php index 95cf9a67329..060d1ae6c54 100644 --- a/admin/tool/mfa/action.php +++ b/admin/tool/mfa/action.php @@ -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; } diff --git a/admin/tool/mfa/amd/build/confirmation_modal.min.js b/admin/tool/mfa/amd/build/confirmation_modal.min.js new file mode 100644 index 00000000000..c43d692d2f7 --- /dev/null +++ b/admin/tool/mfa/amd/build/confirmation_modal.min.js @@ -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 + * @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 \ No newline at end of file diff --git a/admin/tool/mfa/amd/build/confirmation_modal.min.js.map b/admin/tool/mfa/amd/build/confirmation_modal.min.js.map new file mode 100644 index 00000000000..5ed0f5a282e --- /dev/null +++ b/admin/tool/mfa/amd/build/confirmation_modal.min.js.map @@ -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 .\n\n/**\n * Modal for confirming factor actions.\n *\n * @module tool_mfa/confirmation_modal\n * @copyright 2023 David Woloszyn \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"} \ No newline at end of file diff --git a/admin/tool/mfa/amd/src/confirmation_modal.js b/admin/tool/mfa/amd/src/confirmation_modal.js new file mode 100644 index 00000000000..6cde0519a36 --- /dev/null +++ b/admin/tool/mfa/amd/src/confirmation_modal.js @@ -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 . + +/** + * Modal for confirming factor actions. + * + * @module tool_mfa/confirmation_modal + * @copyright 2023 David Woloszyn + * @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(); + }); + +}; diff --git a/admin/tool/mfa/classes/local/factor/object_factor_base.php b/admin/tool/mfa/classes/local/factor/object_factor_base.php index b504376192a..d60ff420488 100644 --- a/admin/tool/mfa/classes/local/factor/object_factor_base.php +++ b/admin/tool/mfa/classes/local/factor/object_factor_base.php @@ -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. * diff --git a/admin/tool/mfa/classes/local/form/revoke_factor_form.php b/admin/tool/mfa/classes/local/form/factor_action_confirmation_form.php similarity index 60% rename from admin/tool/mfa/classes/local/form/revoke_factor_form.php rename to admin/tool/mfa/classes/local/form/factor_action_confirmation_form.php index fc37fb59a94..5e043d185e0 100644 --- a/admin/tool/mfa/classes/local/form/revoke_factor_form.php +++ b/admin/tool/mfa/classes/local/form/factor_action_confirmation_form.php @@ -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 * @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()); } } diff --git a/admin/tool/mfa/classes/local/form/setup_factor_form.php b/admin/tool/mfa/classes/local/form/setup_factor_form.php index 5afe102374e..ca77a8e576e 100644 --- a/admin/tool/mfa/classes/local/form/setup_factor_form.php +++ b/admin/tool/mfa/classes/local/form/setup_factor_form.php @@ -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); - } /** diff --git a/admin/tool/mfa/classes/output/renderer.php b/admin/tool/mfa/classes/output/renderer.php index 50369e536f1..a06ef210f68 100644 --- a/admin/tool/mfa/classes/output/renderer.php +++ b/admin/tool/mfa/classes/output/renderer.php @@ -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 .= '
' . $info['country'] . ' - ' . $info['city']; - $row = new \html_table_row([ - $factor->get_display_name(), $userfactor->label, $timecreated, - $ip, $lastverified, - $revokelink, + $replacebutton, + $revokebutton, ]); $table->data[] = $row; } diff --git a/admin/tool/mfa/classes/plugininfo/factor.php b/admin/tool/mfa/classes/plugininfo/factor.php index 0509ce8a1c3..ce4118d5208 100644 --- a/admin/tool/mfa/classes/plugininfo/factor.php +++ b/admin/tool/mfa/classes/plugininfo/factor.php @@ -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; + } } diff --git a/admin/tool/mfa/db/renamedclasses.php b/admin/tool/mfa/db/renamedclasses.php new file mode 100644 index 00000000000..fdcc9f5b93c --- /dev/null +++ b/admin/tool/mfa/db/renamedclasses.php @@ -0,0 +1,30 @@ +. + +/** + * This file contains mappings for classes that have been renamed. + * + * @package tool_mfa + * @copyright 2024 David Woloszyn + * @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', +]; diff --git a/admin/tool/mfa/lang/en/deprecated.txt b/admin/tool/mfa/lang/en/deprecated.txt new file mode 100644 index 00000000000..91c0589bb67 --- /dev/null +++ b/admin/tool/mfa/lang/en/deprecated.txt @@ -0,0 +1,4 @@ +created,tool_mfa +lastverified,tool_mfa +revoke,tool_mfa +createdfromip,tool_mfa \ No newline at end of file diff --git a/admin/tool/mfa/lang/en/tool_mfa.php b/admin/tool/mfa/lang/en/tool_mfa.php index b8935773463..b0666bc3fc9 100644 --- a/admin/tool/mfa/lang/en/tool_mfa.php +++ b/admin/tool/mfa/lang/en/tool_mfa.php @@ -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.
This may be due to:
1) Steps being locked - please wait a few minutes and try again.
2) Steps being failed - please double check the details for each step.
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'; diff --git a/admin/tool/mfa/lib.php b/admin/tool/mfa/lib.php index 5343b1990bf..b3b2c4fdade 100644 --- a/admin/tool/mfa/lib.php +++ b/admin/tool/mfa/lib.php @@ -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(); +} diff --git a/admin/tool/mfa/templates/mfa_card.mustache b/admin/tool/mfa/templates/mfa_card.mustache new file mode 100644 index 00000000000..0c897e1045d --- /dev/null +++ b/admin/tool/mfa/templates/mfa_card.mustache @@ -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 . +}} +{{! + @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" + } + ] + } + } +}} +
+
+
+ +
+
+
+
+
+

{{label}}

+ {{{info}}} +
+
+
+
+ {{#active}} +
+ {{#str}}active, moodle{{/str}} +
+ {{/active}} + {{#button}} +
+ {{>core/single_button}} +
+ {{/button}} +
+
+ diff --git a/admin/tool/mfa/templates/mfa_selector.mustache b/admin/tool/mfa/templates/mfa_selector.mustache new file mode 100644 index 00000000000..b66b16e2656 --- /dev/null +++ b/admin/tool/mfa/templates/mfa_selector.mustache @@ -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 . +}} +{{! + @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" + } + ] + } + } + ] + } +}} +

{{#str}}mfa, tool_mfa{{/str}}

+

{{#str}}mfa:intro, tool_mfa{{/str}}

+
+ {{#factors}} + {{>tool_mfa/mfa_card}} + {{/factors}} +
diff --git a/admin/tool/mfa/upgrade.txt b/admin/tool/mfa/upgrade.txt new file mode 100644 index 00000000000..2b7dd1cc231 --- /dev/null +++ b/admin/tool/mfa/upgrade.txt @@ -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). diff --git a/admin/tool/mfa/user_preferences.php b/admin/tool/mfa/user_preferences.php index fd77ae015b8..4c6477009d8 100644 --- a/admin/tool/mfa/user_preferences.php +++ b/admin/tool/mfa/user_preferences.php @@ -58,7 +58,6 @@ if (!empty($action)) { } } -echo $OUTPUT->active_factors(); echo $OUTPUT->available_factors(); $renderer = $PAGE->get_renderer('tool_mfa'); diff --git a/admin/tool/mfa/version.php b/admin/tool/mfa/version.php index 6b4d4ab0b9e..bf907a450de 100644 --- a/admin/tool/mfa/version.php +++ b/admin/tool/mfa/version.php @@ -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;