mirror of
https://github.com/moodle/moodle.git
synced 2025-04-20 16:04:25 +02:00
Merge branch 'MDL-79920-main' of https://github.com/davewoloszyn/moodle
This commit is contained in:
commit
8a5022a5e7
@ -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;
|
||||
}
|
||||
|
10
admin/tool/mfa/amd/build/confirmation_modal.min.js
vendored
Normal file
10
admin/tool/mfa/amd/build/confirmation_modal.min.js
vendored
Normal 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
|
1
admin/tool/mfa/amd/build/confirmation_modal.min.js.map
Normal file
1
admin/tool/mfa/amd/build/confirmation_modal.min.js.map
Normal 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"}
|
102
admin/tool/mfa/amd/src/confirmation_modal.js
Normal file
102
admin/tool/mfa/amd/src/confirmation_modal.js
Normal 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();
|
||||
});
|
||||
|
||||
};
|
@ -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.
|
||||
*
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -39,8 +39,9 @@ class verification_field extends \MoodleQuickForm_text {
|
||||
*
|
||||
* @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) {
|
||||
public function __construct($attributes = null, $auth = true, string $elementlabel = null) {
|
||||
global $PAGE;
|
||||
|
||||
// Force attributes.
|
||||
@ -51,7 +52,8 @@ class verification_field extends \MoodleQuickForm_text {
|
||||
$attributes['autocomplete'] = 'one-time-code';
|
||||
$attributes['inputmode'] = 'numeric';
|
||||
$attributes['pattern'] = '[0-9]*';
|
||||
$attributes['class'] = 'tool-mfa-verification-code font-weight-bold';
|
||||
// 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.
|
||||
@ -68,7 +70,8 @@ class verification_field extends \MoodleQuickForm_text {
|
||||
|
||||
// Force element name to match JS.
|
||||
$elementname = 'verificationcode';
|
||||
$elementlabel = get_string('verificationcode', 'tool_mfa');
|
||||
// Overwrite default element label if set.
|
||||
$elementlabel = !empty($elementlabel) ? $elementlabel : get_string('entercode', 'tool_mfa');
|
||||
|
||||
return parent::__construct($elementname, $elementlabel, $attributes);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
30
admin/tool/mfa/db/renamedclasses.php
Normal file
30
admin/tool/mfa/db/renamedclasses.php
Normal 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',
|
||||
];
|
@ -43,19 +43,21 @@ $string['error:badcode'] = 'Code was not found. This may be an old link, a new c
|
||||
$string['error:parameters'] = 'Incorrect page parameters.';
|
||||
$string['error:wrongverification'] = 'Wrong code. Try again.';
|
||||
$string['event:unauthemail'] = 'Unauthorised email received';
|
||||
$string['info'] = 'Built-in factor. A verification code is sent to the user\'s email.';
|
||||
$string['info'] = 'You are using email {$a} to authenticate. This has been set up by your site administrator.';
|
||||
$string['logindesc'] = 'We\'ve just sent a 6-digit code to your email: {$a}';
|
||||
$string['loginoption'] = 'Have a code emailed to you';
|
||||
$string['loginskip'] = "I didn't receive a code";
|
||||
$string['loginsubmit'] = 'Continue';
|
||||
$string['logintitle'] = "Verify it's you by email";
|
||||
$string['managefactor'] = 'Manage email';
|
||||
$string['manageinfo'] = '\'{$a}\' is being used to authenticate. This has been set up by your administrator.';
|
||||
$string['pluginname'] = 'Email';
|
||||
$string['privacy:metadata'] = 'The Email factor plugin does not store any personal data';
|
||||
$string['settings:duration'] = 'Validity duration';
|
||||
$string['settings:duration_help'] = 'The period of time that the code is valid.';
|
||||
$string['settings:suspend'] = 'Suspend unauthorised accounts';
|
||||
$string['settings:suspend_help'] = 'Check this to suspend user accounts if an unauthorised email verification is received.';
|
||||
$string['setupfactor'] = 'Email factor setup';
|
||||
$string['setupfactor'] = 'Set up email';
|
||||
$string['summarycondition'] = 'has valid email setup';
|
||||
$string['unauthloginattempt'] = 'The user with ID {$a->userid} made an unauthorised login attempt using email verification from
|
||||
IP {$a->ip} with browser agent {$a->useragent}.';
|
||||
|
@ -85,6 +85,15 @@ class factor extends object_factor_base {
|
||||
return get_string('setupfactorbutton', 'factor_sms');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the string for manage button on preferences page.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_manage_string(): string {
|
||||
return get_string('managefactorbutton', 'factor_sms');
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines setup_factor form definition page for SMS Factor.
|
||||
*
|
||||
@ -349,13 +358,7 @@ class factor extends object_factor_base {
|
||||
* @return bool
|
||||
*/
|
||||
public function show_setup_buttons(): bool {
|
||||
global $DB, $USER;
|
||||
|
||||
// If there is already a factor setup, don't allow multiple (for now).
|
||||
$record = $DB->get_record('tool_mfa',
|
||||
['userid' => $USER->id, 'factor' => $this->name, 'secret' => '', 'revoked' => 0]);
|
||||
|
||||
return empty($record);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -23,7 +23,8 @@
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
$string['action:revoke'] = 'Revoke mobile phone number';
|
||||
$string['action:manage'] = 'Manage mobile phone number';
|
||||
$string['action:revoke'] = 'Remove mobile phone number';
|
||||
$string['addnumber'] = 'Mobile number';
|
||||
$string['clientnotfound'] = 'AWS service client not found. Client must be fully qualified classname e.g. \Aws\S3\S3Client.';
|
||||
$string['editphonenumber'] = 'Edit phone number';
|
||||
@ -35,15 +36,19 @@ $string['error:wrongphonenumber'] = 'The phone number you provided is not in a v
|
||||
$string['error:wrongverification'] = 'Wrong code. Try again.';
|
||||
$string['event:smssent'] = 'SMS message sent.';
|
||||
$string['event:smssentdescription'] = 'The user with ID {$a->userid} was sent a verification code via SMS. Information: {$a->debuginfo}';
|
||||
$string['info'] = '<p>Set up mobile phone to receive authentication code.</p>';
|
||||
$string['info'] = 'Have a verification code sent to the mobile number you choose.';
|
||||
$string['logindesc'] = 'SMS message containing a 6-digit code sent to mobile number {$a}';
|
||||
$string['loginoption'] = 'Have a code sent to your mobile phone';
|
||||
$string['loginskip'] = "I didn't receive a code";
|
||||
$string['loginsubmit'] = 'Continue';
|
||||
$string['managefactor'] = 'Manage SMS';
|
||||
$string['managefactorbutton'] = 'Manage';
|
||||
$string['manageinfo'] = 'You are using \'{$a}\' to authenticate.';
|
||||
$string['logintitle'] = 'Enter the verification code sent to your mobile';
|
||||
$string['phonehelp'] = 'Enter your mobile number (including country code) to receive a verification code.';
|
||||
$string['pluginname'] = 'SMS mobile phone';
|
||||
$string['privacy:metadata'] = 'The SMS mobile phone factor plugin does not store any personal data.';
|
||||
$string['revokefactorconfirmation'] = 'Remove \'{$a}\' SMS?';
|
||||
$string['settings:aws'] = 'AWS SNS';
|
||||
$string['settings:aws:key'] = 'Key';
|
||||
$string['settings:aws:key_help'] = 'Amazon API key credential.';
|
||||
@ -58,10 +63,10 @@ $string['settings:countrycode_help'] = 'The calling code without the leading + a
|
||||
See this link for a list of calling codes: {$a}';
|
||||
$string['settings:duration'] = 'Validity duration';
|
||||
$string['settings:duration_help'] = 'The period of time that the code is valid.';
|
||||
$string['settings:gateway'] = 'SMS gateway';
|
||||
$string['settings:gateway_help'] = 'The SMS provider for sending messages via.';
|
||||
$string['setupfactor'] = 'SMS setup';
|
||||
$string['setupfactorbutton'] = 'Set up SMS';
|
||||
$string['settings:gateway'] = 'SMS Gateway';
|
||||
$string['settings:gateway_help'] = 'The SMS provider you wish to send messages via';
|
||||
$string['setupfactor'] = 'Set up SMS';
|
||||
$string['setupfactorbutton'] = 'Set up';
|
||||
$string['setupsubmitcode'] = 'Save';
|
||||
$string['setupsubmitphone'] = 'Send code';
|
||||
$string['smsstring'] = '{$a->code} is your {$a->fullname} one-time security code.
|
||||
|
@ -14,21 +14,12 @@ Feature: Login user with sms authentication factor
|
||||
# Set up user SMS factor in user preferences.
|
||||
When I follow "Preferences" in the user menu
|
||||
And I click on "Multi-factor authentication preferences" "link"
|
||||
And I click on "Set up SMS" "button"
|
||||
And I click on "Set up" "button"
|
||||
And I set the field "Mobile number" to "+34649709233"
|
||||
And I press "Send code"
|
||||
And I set the field "Enter code" with valid code
|
||||
Then I press "Save"
|
||||
|
||||
Scenario: Revoke factor
|
||||
Given I click on "Revoke" "link"
|
||||
And I should see "Are you sure you want to revoke factor?"
|
||||
And I press "Revoke"
|
||||
And I should see "successfully revoked"
|
||||
When I log out
|
||||
And I log in as "admin"
|
||||
Then I should see "Unable to authenticate"
|
||||
|
||||
Scenario: Login user successfully with sms verification
|
||||
Given I log out
|
||||
And I log in as "admin"
|
||||
|
@ -12,7 +12,7 @@ Feature: Set up SMS factor in user preferences
|
||||
| enabled | 1 | factor_sms |
|
||||
When I follow "Preferences" in the user menu
|
||||
And I click on "Multi-factor authentication preferences" "link"
|
||||
And I click on "Set up SMS" "button"
|
||||
And I click on "Set up" "button"
|
||||
|
||||
Scenario: Phone number setup form validation
|
||||
Given I set the field "Mobile number" to "++5555sss"
|
||||
|
@ -95,8 +95,7 @@ class factor extends object_factor_base {
|
||||
$uri = $this->generate_totp_uri($secret);
|
||||
$qrcode = new \TCPDF2DBarcode($uri, 'QRCODE');
|
||||
$image = $qrcode->getBarcodePngData(7, 7);
|
||||
$html = \html_writer::tag('p', get_string('setupfactor:scanwithapp', 'factor_totp'));
|
||||
$html .= \html_writer::img('data:image/png;base64,' . base64_encode($image), '', ['width' => '150px']);
|
||||
$html = \html_writer::img('data:image/png;base64,' . base64_encode($image), '', ['width' => '150px']);
|
||||
return $html;
|
||||
}
|
||||
|
||||
@ -143,33 +142,36 @@ class factor extends object_factor_base {
|
||||
// Array of elements to allow XSS.
|
||||
$xssallowedelements = [];
|
||||
|
||||
$mform->addElement('html', $OUTPUT->heading(get_string('setupfactor', 'factor_totp'), 2));
|
||||
$mform->addElement('html', \html_writer::tag('p', get_string('info', 'factor_totp')));
|
||||
$mform->addElement('html', \html_writer::tag('hr', ''));
|
||||
$headingstring = $mform->elementExists('replaceid') ? 'replacefactor' : 'setupfactor';
|
||||
$mform->addElement('html', $OUTPUT->heading(get_string($headingstring, 'factor_totp'), 2));
|
||||
|
||||
$mform->addElement('text', 'devicename', get_string('devicename', 'factor_totp'), [
|
||||
$html = \html_writer::tag('p', get_string('setupfactor:intro', 'factor_totp'));
|
||||
$mform->addElement('html', $html);
|
||||
|
||||
// Device name.
|
||||
$html = \html_writer::tag('p', get_string('setupfactor:instructionsdevicename', 'factor_totp'), ['class' => 'bold']);
|
||||
$mform->addElement('html', $html);
|
||||
|
||||
$mform->addElement('text', 'devicename', get_string('setupfactor:devicename', 'factor_totp'), [
|
||||
'placeholder' => get_string('devicenameexample', 'factor_totp'),
|
||||
'autofocus' => 'autofocus',
|
||||
]);
|
||||
$mform->addHelpButton('devicename', 'devicename', 'factor_totp');
|
||||
$mform->setType('devicename', PARAM_TEXT);
|
||||
$mform->addRule('devicename', get_string('required'), 'required', null, 'client');
|
||||
|
||||
// Scan.
|
||||
$html = \html_writer::tag('p', get_string('setupfactor:devicenameinfo', 'factor_totp'));
|
||||
$mform->addElement('static', 'devicenameinfo', '', $html);
|
||||
|
||||
// Scan QR code.
|
||||
$html = \html_writer::tag('p', get_string('setupfactor:instructionsscan', 'factor_totp'), ['class' => 'bold']);
|
||||
$mform->addElement('html', $html);
|
||||
|
||||
$secretfield = $mform->getElement('secret');
|
||||
$secret = $secretfield->getValue();
|
||||
$qrcode = $this->generate_qrcode($secret);
|
||||
|
||||
$html = \html_writer::tag('p', $qrcode);
|
||||
$xssallowedelements[] = $mform->addElement('static', 'scan', get_string('setupfactor:scan', 'factor_totp'), $html);
|
||||
|
||||
// Link.
|
||||
if (get_config('factor_totp', 'totplink')) {
|
||||
$uri = $this->generate_totp_uri($secret);
|
||||
$html = $OUTPUT->action_link($uri, get_string('setupfactor:linklabel', 'factor_totp'));
|
||||
$xssallowedelements[] = $mform->addElement('static', 'link', get_string('setupfactor:link', 'factor_totp'), $html);
|
||||
$mform->addHelpButton('link', 'setupfactor:link', 'factor_totp');
|
||||
}
|
||||
$mform->addElement('static', 'scan', '', $html);
|
||||
|
||||
// Enter manually.
|
||||
$secret = wordwrap($secret, 4, ' ', true) . '</code>';
|
||||
@ -186,19 +188,15 @@ class factor extends object_factor_base {
|
||||
];
|
||||
|
||||
$html = \html_writer::table($manualtable);
|
||||
$html = \html_writer::tag('p', get_string('setupfactor:enter', 'factor_totp')) . $html;
|
||||
// Wrap the table in a couple of divs to be controlled via bootstrap.
|
||||
$html = \html_writer::div($html, 'card card-body', ['style' => 'padding-left: 0 !important;']);
|
||||
$html = \html_writer::div($html, 'collapse', ['id' => 'collapseManualAttributes']);
|
||||
|
||||
$togglelink = \html_writer::tag('btn', get_string('setupfactor:scanfail', 'factor_totp'), [
|
||||
'class' => 'btn btn-secondary',
|
||||
'type' => 'button',
|
||||
$togglelink = \html_writer::tag('a', get_string('setupfactor:link', 'factor_totp'), [
|
||||
'data-toggle' => 'collapse',
|
||||
'data-target' => '#collapseManualAttributes',
|
||||
'aria-expanded' => 'false',
|
||||
'aria-controls' => 'collapseManualAttributes',
|
||||
'style' => 'font-size: 14px;',
|
||||
'href' => '#',
|
||||
]);
|
||||
|
||||
$html = $togglelink . $html;
|
||||
@ -211,9 +209,17 @@ class factor extends object_factor_base {
|
||||
}
|
||||
}
|
||||
|
||||
$mform->addElement(new \tool_mfa\local\form\verification_field(null, false));
|
||||
// Verification.
|
||||
$html = \html_writer::tag('p', get_string('setupfactor:instructionsverification', 'factor_totp'), ['class' => 'bold']);
|
||||
$mform->addElement('html', $html);
|
||||
|
||||
$verificationfield = new \tool_mfa\local\form\verification_field(
|
||||
attributes: ['class' => 'tool-mfa-verification-code'],
|
||||
auth: false,
|
||||
elementlabel: get_string('setupfactor:verificationcode', 'factor_totp'),
|
||||
);
|
||||
$mform->addElement($verificationfield);
|
||||
$mform->setType('verificationcode', PARAM_ALPHANUM);
|
||||
$mform->addHelpButton('verificationcode', 'verificationcode', 'factor_totp');
|
||||
$mform->addRule('verificationcode', get_string('required'), 'required', null, 'client');
|
||||
|
||||
return $mform;
|
||||
@ -366,7 +372,7 @@ class factor extends object_factor_base {
|
||||
], '*', IGNORE_MULTIPLE);
|
||||
if ($record) {
|
||||
\core\notification::warning(get_string('error:alreadyregistered', 'factor_totp'));
|
||||
return $record;
|
||||
return null;
|
||||
}
|
||||
|
||||
$id = $DB->insert_record('tool_mfa', $row);
|
||||
@ -379,6 +385,35 @@ class factor extends object_factor_base {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* TOTP Factor implementation with replacement of existing factor.
|
||||
*
|
||||
* @param stdClass $data The new factor data.
|
||||
* @param int $id The id of the factor to replace.
|
||||
* @return stdClass|null the factor record, or null.
|
||||
*/
|
||||
public function replace_user_factor(stdClass $data, int $id): stdClass|null {
|
||||
global $DB, $USER;
|
||||
|
||||
$oldrecord = $DB->get_record('tool_mfa', ['id' => $id]);
|
||||
$newrecord = null;
|
||||
|
||||
// Ensure we have a valid existing record before setting the new one.
|
||||
if ($oldrecord) {
|
||||
$newrecord = $this->setup_user_factor($data);
|
||||
}
|
||||
// Ensure the new record was created before revoking the old.
|
||||
if ($newrecord) {
|
||||
$this->revoke_user_factor($id);
|
||||
} else {
|
||||
\core\notification::warning(get_string('error:couldnotreplace', 'tool_mfa'));
|
||||
return null;
|
||||
}
|
||||
$this->create_event_after_factor_setup($USER);
|
||||
|
||||
return $newrecord ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* TOTP Factor implementation.
|
||||
*
|
||||
@ -399,6 +434,13 @@ class factor extends object_factor_base {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* TOTP Factor implementation.
|
||||
*/
|
||||
public function has_replace(): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* TOTP Factor implementation.
|
||||
*
|
||||
@ -447,6 +489,15 @@ class factor extends object_factor_base {
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function get_setup_string(): string {
|
||||
return get_string('factorsetup', 'factor_totp');
|
||||
return get_string('setupfactorbutton', 'factor_totp');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the string for manage button on preferences page.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_manage_string(): string {
|
||||
return get_string('managefactorbutton', 'factor_totp');
|
||||
}
|
||||
}
|
||||
|
2
admin/tool/mfa/factor/totp/lang/en/deprecated.txt
Normal file
2
admin/tool/mfa/factor/totp/lang/en/deprecated.txt
Normal file
@ -0,0 +1,2 @@
|
||||
setupfactor:scanfail,factor_totp
|
||||
setupfactor:scan,factor_totp
|
@ -24,7 +24,8 @@
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
$string['action:revoke'] = 'Revoke time-based one-time password (TOTP) authenticator';
|
||||
$string['action:manage'] = 'Manage time-based one-time password (TOTP) authenticator';
|
||||
$string['action:revoke'] = 'Remove time-based one-time password (TOTP) authenticator';
|
||||
$string['devicename'] = 'Device label';
|
||||
$string['devicename_help'] = 'This is the device you have an authenticator app installed on. You can set up multiple devices so this label helps track which ones are being used. You should set up each device with their own unique code so they can be revoked separately.';
|
||||
$string['devicenameexample'] = 'eg "Work iPhone 11"';
|
||||
@ -36,36 +37,48 @@ $string['error:oldcode'] = 'This code is too old. Please verify the time on your
|
||||
Current system time is {$a}.';
|
||||
$string['error:wrongverification'] = 'Incorrect verification code.';
|
||||
$string['factorsetup'] = 'App setup';
|
||||
$string['info'] = '<p>Use any time-based one-time password (TOTP) authenticator app on your device to generate a verification code, even when it is offline.</p>
|
||||
|
||||
<p>For example <a href="https://2fas.com/">2FAS Auth</a>, <a href="https://freeotp.github.io/">FreeOTP</a>, Google Authenticator, Microsoft Authenticator or Twilio Authy.</p>
|
||||
|
||||
<p>Note: Please ensure your device time and date has been set to "Auto" or "Network provided".</p>';
|
||||
$string['info'] = 'Generate a verification code using an authenticator app.';
|
||||
$string['logindesc'] = 'Use the authenticator app in your mobile device to generate a code.';
|
||||
$string['loginoption'] = 'Use Authenticator application';
|
||||
$string['loginskip'] = 'I don\'t have my device';
|
||||
$string['loginsubmit'] = 'Continue';
|
||||
$string['logintitle'] = 'Verify it\'s you by mobile app';
|
||||
$string['managefactor'] = 'Manage authenticator app';
|
||||
$string['managefactorbutton'] = 'Manage';
|
||||
$string['manageinfo'] = 'You are using \'{$a}\' to authenticate.';
|
||||
$string['pluginname'] = 'Authenticator app';
|
||||
$string['privacy:metadata'] = 'The Authenticator app factor plugin does not store any personal data.';
|
||||
$string['replacefactor'] = 'Replace authenticator app';
|
||||
$string['replacefactorconfirmation'] = 'Replace \'{$a}\' authenticator app?';
|
||||
$string['revokefactorconfirmation'] = 'Remove \'{$a}\' authenticator app?';
|
||||
$string['settings:totplink'] = 'Show mobile app setup link';
|
||||
$string['settings:totplink_help'] = 'If enabled the user will see a 3rd setup option with a direct otpauth:// link';
|
||||
$string['settings:window'] = 'TOTP verification window';
|
||||
$string['settings:window_help'] = 'How long each code is valid for. You can set this to a higher value as a workaround if your users device clocks are often slightly wrong.
|
||||
Rounded down to the nearest 30 seconds, which is the time between new generated codes.';
|
||||
$string['setupfactor'] = 'TOTP authenticator setup';
|
||||
Rounded down to the nearest 30 seconds, which is the time between new generated codes.';
|
||||
$string['setupfactor'] = 'Set up authenticator app';
|
||||
$string['setupfactorbutton'] = 'Set up';
|
||||
$string['setupfactor:account'] = 'Account:';
|
||||
$string['setupfactor:enter'] = 'Enter details manually:';
|
||||
$string['setupfactor:devicename'] = 'Device name';
|
||||
$string['setupfactor:devicenameinfo'] = 'This helps you identify which device receives the verification code.';
|
||||
$string['setupfactor:enter'] = 'Enter details manually';
|
||||
$string['setupfactor:instructionsdevicename'] = '1. Give your device a name.';
|
||||
$string['setupfactor:instructionsscan'] = '2. Scan the QR code with your authenticator app.';
|
||||
$string['setupfactor:instructionsverification'] = '3. Enter the verification code.';
|
||||
$string['setupfactor:intro'] = 'To set up this method, you need to have a device with an authenticator app. If you don\'t have an app, you can download one. For example, <a href="https://2fas.com/" target="_blank">2FAS Auth</a>, <a href="https://freeotp.github.io/" target="_blank">FreeOTP</a>, Google Authenticator, Microsoft Authenticator or Twilio Authy.';
|
||||
$string['setupfactor:key'] = 'Secret key: ';
|
||||
$string['setupfactor:link'] = '<b> OR </b> open mobile app:';
|
||||
$string['setupfactor:link'] = 'Or enter details manually.';
|
||||
$string['setupfactor:link_help'] = 'If you are on a mobile device and already have an authenticator app installed this link may work. Note that using TOTP on the same device as you login on can weaken the benefits of MFA.';
|
||||
$string['setupfactor:linklabel'] = 'Open app already installed on this device';
|
||||
$string['setupfactor:mode'] = 'Mode:';
|
||||
$string['setupfactor:mode:timebased'] = 'Time-based';
|
||||
$string['setupfactor:scan'] = 'Scan QR code:';
|
||||
$string['setupfactor:scanfail'] = 'Can\'t scan?';
|
||||
$string['setupfactor:scanwithapp'] = 'Scan QR code with your chosen authenticator application.';
|
||||
$string['setupfactor:verificationcode'] = 'Verification code';
|
||||
$string['summarycondition'] = 'using a TOTP app';
|
||||
$string['systimeformat'] = '%l:%M:%S %P %Z';
|
||||
$string['verificationcode'] = 'Enter your 6 digit verification code';
|
||||
$string['verificationcode_help'] = 'Open your authenticator app such as Google Authenticator and look for the 6 digit code which matches this site and username';
|
||||
|
||||
// Deprecated since Moodle 4.4.
|
||||
$string['setupfactor:scanfail'] = 'Can\'t scan?';
|
||||
$string['setupfactor:scan'] = 'Scan QR code';
|
||||
|
@ -135,11 +135,11 @@ class factor_test extends \advanced_testcase {
|
||||
'secret' => 'fakekey',
|
||||
'devicename' => 'fakedevice',
|
||||
];
|
||||
$record = $totpfactor->setup_user_factor((object) $totpdata);
|
||||
$totpfactor->setup_user_factor((object) $totpdata);
|
||||
|
||||
// Trying to add the same TOTP should return the existing record (exactly).
|
||||
// Trying to add the same TOTP should return null.
|
||||
$anotherecord = $totpfactor->setup_user_factor((object) $totpdata);
|
||||
$this->assertEquals($record, $anotherecord);
|
||||
$this->assertNull($anotherecord);
|
||||
|
||||
// The total count for factors added should be 1 at this point.
|
||||
$count = $DB->count_records('tool_mfa');
|
||||
|
@ -6,6 +6,6 @@
|
||||
* @author Alex Morris <alex.morris@catalyst.net.nz>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
define("factor_webauthn/register",["factor_webauthn/utils","core/log"],(function(utils,Log){return{init:function(createArgs){createArgs=JSON.parse(createArgs),document.getElementById("factor_webauthn-register").addEventListener("click",(async function(e){if(e.preventDefault(),!navigator.credentials||!navigator.credentials.create)throw new Error("Browser not supported.");if(!1===createArgs.success)throw new Error(createArgs.msg||"unknown error occured");try{utils.recursiveBase64StrToArrayBuffer(createArgs);const cred=await navigator.credentials.create(createArgs),authenticatorResponse={transports:cred.response.getTransports?cred.response.getTransports():null,clientDataJSON:cred.response.clientDataJSON?utils.arrayBufferToBase64(cred.response.clientDataJSON):null,attestationObject:cred.response.attestationObject?utils.arrayBufferToBase64(cred.response.attestationObject):null};document.getElementById("id_response_input").value=JSON.stringify(authenticatorResponse)}catch(e){Log.debug("The request timed out or you have canceled the request. Please try again later.")}}))}}}));
|
||||
define("factor_webauthn/register",["factor_webauthn/utils","core/log"],(function(utils,Log){async function registerSecurityKey(createArgs){try{if(!navigator.credentials||!navigator.credentials.create)throw new Error("Browser not supported.");if(!1===createArgs.success)throw new Error(createArgs.msg||"unknown error occurred");utils.recursiveBase64StrToArrayBuffer(createArgs);const cred=await navigator.credentials.create(createArgs),authenticatorResponse={transports:cred.response.getTransports?cred.response.getTransports():null,clientDataJSON:cred.response.clientDataJSON?utils.arrayBufferToBase64(cred.response.clientDataJSON):null,attestationObject:cred.response.attestationObject?utils.arrayBufferToBase64(cred.response.attestationObject):null};document.getElementById("id_response_input").value=JSON.stringify(authenticatorResponse),document.getElementById("id_submitbutton").disabled=!1}catch(e){Log.debug("The request timed out or you have canceled the request. Please try again later.")}}return{init:function(createArgs){document.getElementById("id_submitbutton").disabled=!0,createArgs=JSON.parse(createArgs),document.getElementById("factor_webauthn-register").addEventListener("click",(function(){registerSecurityKey(createArgs)})),document.getElementById("factor_webauthn-register").addEventListener("keypress",(function(){registerSecurityKey(createArgs)}))}}}));
|
||||
|
||||
//# sourceMappingURL=register.min.js.map
|
@ -1 +1 @@
|
||||
{"version":3,"file":"register.min.js","sources":["../src/register.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 * For collecting WebAuthn authenticator details on factor setup\n *\n * @module factor_webauthn/register\n * @copyright Catalyst IT\n * @author Alex Morris <alex.morris@catalyst.net.nz>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\ndefine(['factor_webauthn/utils', 'core/log'], function(utils, Log) {\n return {\n init: function(createArgs) {\n createArgs = JSON.parse(createArgs);\n document.getElementById('factor_webauthn-register').addEventListener('click', async function(e) {\n e.preventDefault();\n if (!navigator.credentials || !navigator.credentials.create) {\n throw new Error('Browser not supported.');\n }\n\n if (createArgs.success === false) {\n throw new Error(createArgs.msg || 'unknown error occured');\n }\n\n try {\n utils.recursiveBase64StrToArrayBuffer(createArgs);\n const cred = await navigator.credentials.create(createArgs);\n const authenticatorResponse = {\n transports: cred.response.getTransports ? cred.response.getTransports() : null,\n clientDataJSON: cred.response.clientDataJSON ?\n utils.arrayBufferToBase64(cred.response.clientDataJSON) : null,\n attestationObject: cred.response.attestationObject ?\n utils.arrayBufferToBase64(cred.response.attestationObject) : null,\n };\n document.getElementById('id_response_input').value = JSON.stringify(authenticatorResponse);\n } catch (e) {\n Log.debug('The request timed out or you have canceled the request. Please try again later.');\n }\n });\n }\n };\n});\n"],"names":["define","utils","Log","init","createArgs","JSON","parse","document","getElementById","addEventListener","async","e","preventDefault","navigator","credentials","create","Error","success","msg","recursiveBase64StrToArrayBuffer","cred","authenticatorResponse","transports","response","getTransports","clientDataJSON","arrayBufferToBase64","attestationObject","value","stringify","debug"],"mappings":";;;;;;;;AAwBAA,kCAAO,CAAC,wBAAyB,aAAa,SAASC,MAAOC,WACnD,CACHC,KAAM,SAASC,YACXA,WAAaC,KAAKC,MAAMF,YACxBG,SAASC,eAAe,4BAA4BC,iBAAiB,SAASC,eAAeC,MACzFA,EAAEC,kBACGC,UAAUC,cAAgBD,UAAUC,YAAYC,aAC3C,IAAIC,MAAM,8BAGO,IAAvBZ,WAAWa,cACL,IAAID,MAAMZ,WAAWc,KAAO,6BAIlCjB,MAAMkB,gCAAgCf,kBAChCgB,WAAaP,UAAUC,YAAYC,OAAOX,YAC1CiB,sBAAwB,CAC1BC,WAAYF,KAAKG,SAASC,cAAgBJ,KAAKG,SAASC,gBAAkB,KAC1EC,eAAgBL,KAAKG,SAASE,eAC1BxB,MAAMyB,oBAAoBN,KAAKG,SAASE,gBAAkB,KAC9DE,kBAAmBP,KAAKG,SAASI,kBAC7B1B,MAAMyB,oBAAoBN,KAAKG,SAASI,mBAAqB,MAErEpB,SAASC,eAAe,qBAAqBoB,MAAQvB,KAAKwB,UAAUR,uBACtE,MAAOV,GACLT,IAAI4B,MAAM"}
|
||||
{"version":3,"file":"register.min.js","sources":["../src/register.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 * For collecting WebAuthn authenticator details on factor setup\n *\n * @module factor_webauthn/register\n * @copyright Catalyst IT\n * @author Alex Morris <alex.morris@catalyst.net.nz>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\ndefine(['factor_webauthn/utils', 'core/log'], function(utils, Log) {\n /**\n * Register the security key.\n *\n * @param {*} createArgs\n */\n async function registerSecurityKey(createArgs) {\n try {\n if (!navigator.credentials || !navigator.credentials.create) {\n throw new Error('Browser not supported.');\n }\n\n if (createArgs.success === false) {\n throw new Error(createArgs.msg || 'unknown error occurred');\n }\n\n utils.recursiveBase64StrToArrayBuffer(createArgs);\n const cred = await navigator.credentials.create(createArgs);\n const authenticatorResponse = {\n transports: cred.response.getTransports ? cred.response.getTransports() : null,\n clientDataJSON: cred.response.clientDataJSON ?\n utils.arrayBufferToBase64(cred.response.clientDataJSON) : null,\n attestationObject: cred.response.attestationObject ?\n utils.arrayBufferToBase64(cred.response.attestationObject) : null,\n };\n document.getElementById('id_response_input').value = JSON.stringify(authenticatorResponse);\n // Enable the submit button so that we can proceed.\n document.getElementById('id_submitbutton').disabled = false;\n } catch (e) {\n Log.debug('The request timed out or you have canceled the request. Please try again later.');\n }\n }\n\n return {\n init: function(createArgs) {\n // Disable the submit button until we have registered a security key.\n document.getElementById('id_submitbutton').disabled = true;\n createArgs = JSON.parse(createArgs);\n // Register event listeners.\n document.getElementById('factor_webauthn-register').addEventListener('click', function() {\n registerSecurityKey(createArgs);\n });\n document.getElementById('factor_webauthn-register').addEventListener('keypress', function() {\n registerSecurityKey(createArgs);\n });\n }\n };\n});\n"],"names":["define","utils","Log","registerSecurityKey","createArgs","navigator","credentials","create","Error","success","msg","recursiveBase64StrToArrayBuffer","cred","authenticatorResponse","transports","response","getTransports","clientDataJSON","arrayBufferToBase64","attestationObject","document","getElementById","value","JSON","stringify","disabled","e","debug","init","parse","addEventListener"],"mappings":";;;;;;;;AAwBAA,kCAAO,CAAC,wBAAyB,aAAa,SAASC,MAAOC,oBAM3CC,oBAAoBC,oBAEtBC,UAAUC,cAAgBD,UAAUC,YAAYC,aAC3C,IAAIC,MAAM,8BAGO,IAAvBJ,WAAWK,cACL,IAAID,MAAMJ,WAAWM,KAAO,0BAGtCT,MAAMU,gCAAgCP,kBAChCQ,WAAaP,UAAUC,YAAYC,OAAOH,YAC1CS,sBAAwB,CAC1BC,WAAYF,KAAKG,SAASC,cAAgBJ,KAAKG,SAASC,gBAAkB,KAC1EC,eAAgBL,KAAKG,SAASE,eAC1BhB,MAAMiB,oBAAoBN,KAAKG,SAASE,gBAAkB,KAC9DE,kBAAmBP,KAAKG,SAASI,kBAC7BlB,MAAMiB,oBAAoBN,KAAKG,SAASI,mBAAqB,MAErEC,SAASC,eAAe,qBAAqBC,MAAQC,KAAKC,UAAUX,uBAEpEO,SAASC,eAAe,mBAAmBI,UAAW,EACxD,MAAOC,GACLxB,IAAIyB,MAAM,0FAIX,CACHC,KAAM,SAASxB,YAEXgB,SAASC,eAAe,mBAAmBI,UAAW,EACtDrB,WAAamB,KAAKM,MAAMzB,YAExBgB,SAASC,eAAe,4BAA4BS,iBAAiB,SAAS,WAC1E3B,oBAAoBC,eAExBgB,SAASC,eAAe,4BAA4BS,iBAAiB,YAAY,WAC7E3B,oBAAoBC"}
|
@ -23,33 +23,49 @@
|
||||
*/
|
||||
|
||||
define(['factor_webauthn/utils', 'core/log'], function(utils, Log) {
|
||||
/**
|
||||
* Register the security key.
|
||||
*
|
||||
* @param {*} createArgs
|
||||
*/
|
||||
async function registerSecurityKey(createArgs) {
|
||||
try {
|
||||
if (!navigator.credentials || !navigator.credentials.create) {
|
||||
throw new Error('Browser not supported.');
|
||||
}
|
||||
|
||||
if (createArgs.success === false) {
|
||||
throw new Error(createArgs.msg || 'unknown error occurred');
|
||||
}
|
||||
|
||||
utils.recursiveBase64StrToArrayBuffer(createArgs);
|
||||
const cred = await navigator.credentials.create(createArgs);
|
||||
const authenticatorResponse = {
|
||||
transports: cred.response.getTransports ? cred.response.getTransports() : null,
|
||||
clientDataJSON: cred.response.clientDataJSON ?
|
||||
utils.arrayBufferToBase64(cred.response.clientDataJSON) : null,
|
||||
attestationObject: cred.response.attestationObject ?
|
||||
utils.arrayBufferToBase64(cred.response.attestationObject) : null,
|
||||
};
|
||||
document.getElementById('id_response_input').value = JSON.stringify(authenticatorResponse);
|
||||
// Enable the submit button so that we can proceed.
|
||||
document.getElementById('id_submitbutton').disabled = false;
|
||||
} catch (e) {
|
||||
Log.debug('The request timed out or you have canceled the request. Please try again later.');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init: function(createArgs) {
|
||||
// Disable the submit button until we have registered a security key.
|
||||
document.getElementById('id_submitbutton').disabled = true;
|
||||
createArgs = JSON.parse(createArgs);
|
||||
document.getElementById('factor_webauthn-register').addEventListener('click', async function(e) {
|
||||
e.preventDefault();
|
||||
if (!navigator.credentials || !navigator.credentials.create) {
|
||||
throw new Error('Browser not supported.');
|
||||
}
|
||||
|
||||
if (createArgs.success === false) {
|
||||
throw new Error(createArgs.msg || 'unknown error occured');
|
||||
}
|
||||
|
||||
try {
|
||||
utils.recursiveBase64StrToArrayBuffer(createArgs);
|
||||
const cred = await navigator.credentials.create(createArgs);
|
||||
const authenticatorResponse = {
|
||||
transports: cred.response.getTransports ? cred.response.getTransports() : null,
|
||||
clientDataJSON: cred.response.clientDataJSON ?
|
||||
utils.arrayBufferToBase64(cred.response.clientDataJSON) : null,
|
||||
attestationObject: cred.response.attestationObject ?
|
||||
utils.arrayBufferToBase64(cred.response.attestationObject) : null,
|
||||
};
|
||||
document.getElementById('id_response_input').value = JSON.stringify(authenticatorResponse);
|
||||
} catch (e) {
|
||||
Log.debug('The request timed out or you have canceled the request. Please try again later.');
|
||||
}
|
||||
// Register event listeners.
|
||||
document.getElementById('factor_webauthn-register').addEventListener('click', function() {
|
||||
registerSecurityKey(createArgs);
|
||||
});
|
||||
document.getElementById('factor_webauthn-register').addEventListener('keypress', function() {
|
||||
registerSecurityKey(createArgs);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -90,6 +90,13 @@ class factor extends object_factor_base {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebAuthn Factor implementation.
|
||||
*/
|
||||
public function has_replace(): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebAuthn Factor implementation.
|
||||
*
|
||||
@ -145,7 +152,16 @@ class factor extends object_factor_base {
|
||||
* @return string
|
||||
*/
|
||||
public function get_setup_string(): string {
|
||||
return get_string('setupfactor', 'factor_webauthn');
|
||||
return get_string('setupfactorbutton', 'factor_webauthn');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the string for manage button on preferences page.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_manage_string(): string {
|
||||
return get_string('managefactorbutton', 'factor_webauthn');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -245,16 +261,34 @@ class factor extends object_factor_base {
|
||||
* @return \MoodleQuickForm $mform
|
||||
*/
|
||||
public function setup_factor_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm {
|
||||
global $PAGE, $USER, $SESSION;
|
||||
global $PAGE, $USER, $SESSION, $OUTPUT;
|
||||
|
||||
$headingstring = $mform->elementExists('replaceid') ? 'replacefactor' : 'setupfactor';
|
||||
$mform->addElement('html', $OUTPUT->heading(get_string($headingstring, 'factor_webauthn'), 2));
|
||||
|
||||
$html = \html_writer::tag('p', get_string('setupfactor:intro', 'factor_webauthn'));
|
||||
$mform->addElement('html', $html);
|
||||
|
||||
// Security key name.
|
||||
$mform->addElement('html', \html_writer::tag('p', get_string('setupfactor:instructionssecuritykeyname', 'factor_webauthn'),
|
||||
['class' => 'bold']));
|
||||
|
||||
$mform->addElement('text', 'webauthn_name', get_string('authenticatorname', 'factor_webauthn'));
|
||||
$mform->setType('webauthn_name', PARAM_ALPHANUM);
|
||||
$mform->setType('webauthn_name', PARAM_TEXT);
|
||||
$mform->addRule('webauthn_name', get_string('required'), 'required', null, 'client');
|
||||
|
||||
$html = \html_writer::tag('p', get_string('setupfactor:securitykeyinfo', 'factor_webauthn'));
|
||||
$mform->addElement('static', 'devicenameinfo', '', $html);
|
||||
|
||||
// Register security key.
|
||||
$mform->addElement('html', \html_writer::tag('p',
|
||||
get_string('setupfactor:instructionsregistersecuritykey', 'factor_webauthn'), ['class' => 'bold']));
|
||||
|
||||
$registerbtn = \html_writer::tag('btn', get_string('register', 'factor_webauthn'), [
|
||||
'class' => 'btn btn-primary',
|
||||
'type' => 'button',
|
||||
'id' => 'factor_webauthn-register'
|
||||
'id' => 'factor_webauthn-register',
|
||||
'tabindex' => '0',
|
||||
]);
|
||||
$mform->addElement('static', 'register', '', $registerbtn);
|
||||
|
||||
@ -325,8 +359,18 @@ class factor extends object_factor_base {
|
||||
$row->lastverified = time();
|
||||
$row->revoked = 0;
|
||||
|
||||
$id = $DB->insert_record('tool_mfa', $row);
|
||||
// Check if a record with this configuration already exists, warning the user accordingly.
|
||||
$record = $DB->get_record('tool_mfa', [
|
||||
'userid' => $row->userid,
|
||||
'secret' => $row->secret,
|
||||
'factor' => $row->factor,
|
||||
], '*', IGNORE_MULTIPLE);
|
||||
if ($record) {
|
||||
\core\notification::warning(get_string('error:alreadyregistered', 'factor_webauthn'));
|
||||
return null;
|
||||
}
|
||||
|
||||
$id = $DB->insert_record('tool_mfa', $row);
|
||||
$record = $DB->get_record('tool_mfa', array('id' => $id));
|
||||
$this->create_event_after_factor_setup($USER);
|
||||
|
||||
@ -335,4 +379,33 @@ class factor extends object_factor_base {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebAuthn Factor implementation with replacement of existing factor.
|
||||
*
|
||||
* @param stdClass $data The new factor data.
|
||||
* @param int $id The id of the factor to replace.
|
||||
* @return stdClass|null the factor record, or null.
|
||||
*/
|
||||
public function replace_user_factor(stdClass $data, int $id): stdClass|null {
|
||||
global $DB, $USER;
|
||||
|
||||
$oldrecord = $DB->get_record('tool_mfa', ['id' => $id]);
|
||||
$newrecord = null;
|
||||
|
||||
// Ensure we have a valid existing record before setting the new one.
|
||||
if ($oldrecord) {
|
||||
$newrecord = $this->setup_user_factor($data);
|
||||
}
|
||||
// Ensure the new record was created before revoking the old.
|
||||
if ($newrecord) {
|
||||
$this->revoke_user_factor($id);
|
||||
} else {
|
||||
\core\notification::warning(get_string('error:couldnotreplace', 'tool_mfa'));
|
||||
return null;
|
||||
}
|
||||
$this->create_event_after_factor_setup($USER);
|
||||
|
||||
return $newrecord ?? null;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -23,7 +23,8 @@
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
$string['action:revoke'] = 'Revoke authenticator';
|
||||
$string['action:manage'] = 'Manage security key';
|
||||
$string['action:revoke'] = 'Remove security key';
|
||||
$string['authenticator:ble'] = 'BLE';
|
||||
$string['authenticator:hybrid'] = 'Hybrid';
|
||||
$string['authenticator:internal'] = 'Internal';
|
||||
@ -31,21 +32,33 @@ $string['authenticator:nfc'] = 'NFC';
|
||||
$string['authenticator:usb'] = 'USB';
|
||||
$string['authenticatorname'] = 'Security key name';
|
||||
$string['error'] = 'Failed to authenticate';
|
||||
$string['info'] = '<p>Use a security key</p>';
|
||||
$string['logindesc'] = 'Click continue to use your authenticator token or security key.';
|
||||
$string['loginoption'] = 'Use authenticator token';
|
||||
$string['error:alreadyregistered'] = 'This security key secret has already been registered.';
|
||||
$string['info'] = 'Use a physical security key or fingerprint scanner.';
|
||||
$string['logindesc'] = 'Click continue to use your security key.';
|
||||
$string['loginoption'] = 'Use security key';
|
||||
$string['loginskip'] = 'I don\'t have my security key';
|
||||
$string['loginsubmit'] = 'Continue';
|
||||
$string['logintitle'] = 'Verify it\'s you by authenticator token';
|
||||
$string['logintitle'] = 'Verify it\'s you by security key';
|
||||
$string['pluginname'] = 'Security key';
|
||||
$string['privacy:metadata'] = 'The Security key factor plugin does not store any personal data.';
|
||||
$string['register'] = 'Register authenticator';
|
||||
$string['register'] = 'Register security key';
|
||||
$string['replacefactor'] = 'Replace security key';
|
||||
$string['replacefactorconfirmation'] = 'Replace \'{$a}\' security key?';
|
||||
$string['revokefactorconfirmation'] = 'Remove \'{$a}\' security key?';
|
||||
$string['settings:authenticatortypes'] = 'Types of authenticator';
|
||||
$string['settings:authenticatortypes_help'] = 'Toggle certain types of authenticators';
|
||||
$string['settings:userverification'] = 'User verification';
|
||||
$string['settings:userverification_help'] = 'Serves to ensure the person authenticating is in fact who they say they are. User verification can take various forms, such as password, PIN, fingerprint, etc.';
|
||||
$string['setupfactor'] = 'Setup authenticator';
|
||||
$string['setupfactor'] = 'Set up security key';
|
||||
$string['setupfactorbutton'] = 'Set up';
|
||||
$string['setupfactor:instructionsregistersecuritykey'] = '2. Register a security key.';
|
||||
$string['setupfactor:instructionssecuritykeyname'] = '1. Give your key a name.';
|
||||
$string['setupfactor:intro'] = 'A security key is a physical device that you can use to authenticate yourself. Security keys can be USB tokens, Bluetooth devices, or event built-in fingerprint scanners on your phone or computer.';
|
||||
$string['setupfactor:securitykeyinfo'] = 'This helps you identify which security key you are using.';
|
||||
$string['summarycondition'] = 'using a WebAuthn supported authenticator';
|
||||
$string['managefactor'] = 'Manage security key';
|
||||
$string['managefactorbutton'] = 'Manage';
|
||||
$string['manageinfo'] = 'You are using \'{$a}\' to authenticate.';
|
||||
$string['userverification:discouraged'] = 'User verification should not be employed, for example to minimize user interaction';
|
||||
$string['userverification:preferred'] = 'User verification is preferred, authentication will not fail if user verification is missing';
|
||||
$string['userverification:required'] = 'User verification is required (e.g. by pin). Authentication fails if key does not have user verification';
|
||||
|
4
admin/tool/mfa/lang/en/deprecated.txt
Normal file
4
admin/tool/mfa/lang/en/deprecated.txt
Normal file
@ -0,0 +1,4 @@
|
||||
created,tool_mfa
|
||||
lastverified,tool_mfa
|
||||
revoke,tool_mfa
|
||||
createdfromip,tool_mfa
|
@ -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';
|
||||
|
@ -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();
|
||||
}
|
||||
|
78
admin/tool/mfa/templates/mfa_card.mustache
Normal file
78
admin/tool/mfa/templates/mfa_card.mustache
Normal 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>
|
||||
|
88
admin/tool/mfa/templates/mfa_selector.mustache
Normal file
88
admin/tool/mfa/templates/mfa_selector.mustache
Normal 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>
|
@ -0,0 +1,69 @@
|
||||
@tool @tool_mfa
|
||||
Feature: Set up and manage user factors
|
||||
In order to set up or manage my user factor
|
||||
As a user
|
||||
I need to configure the user factor settings in my preferences
|
||||
|
||||
Background:
|
||||
Given I log in as "admin"
|
||||
And the following config values are set as admin:
|
||||
| enabled | 1 | tool_mfa |
|
||||
|
||||
Scenario: I see the correct buttons for factor setup and management displayed
|
||||
Given the following config values are set as admin:
|
||||
| enabled | 1 | factor_email |
|
||||
And the following config values are set as admin:
|
||||
| enabled | 1 | factor_webauthn |
|
||||
And the following config values are set as admin:
|
||||
| enabled | 1 | factor_totp |
|
||||
And the following "tool_mfa > User factors" exist:
|
||||
| username | factor | label |
|
||||
| admin | email | test@test.com |
|
||||
| admin | webauthn | MacBook |
|
||||
And I follow "Preferences" in the user menu
|
||||
When I click on "Multi-factor authentication preferences" "link"
|
||||
# This is the only factor not yet set up.
|
||||
Then I should not see "Active" in the "#factor-card-totp" "css_element"
|
||||
# The following factors are already set up.
|
||||
And I should see "Active" in the "#factor-card-email" "css_element"
|
||||
And I should see "Active" in the "#factor-card-webauthn" "css_element"
|
||||
And I click on "Set up authenticator app" "button"
|
||||
And I should see "Set up authenticator app"
|
||||
And I click on "Cancel" "button"
|
||||
And I click on "Manage security key" "button"
|
||||
And I should see "Manage security key"
|
||||
|
||||
@javascript
|
||||
Scenario: I can revoke a factor only when there is more than one active factor
|
||||
Given the following config values are set as admin:
|
||||
| enabled | 1 | factor_webauthn |
|
||||
And the following config values are set as admin:
|
||||
| enabled | 1 | factor_sms |
|
||||
And the following "tool_mfa > User factors" exist:
|
||||
| username | factor | label |
|
||||
| admin | sms | +409111222 |
|
||||
| admin | webauthn | MacBook |
|
||||
And I follow "Preferences" in the user menu
|
||||
And I click on "Multi-factor authentication preferences" "link"
|
||||
And I click on "Manage SMS" "button"
|
||||
And I click on "Remove" "button" in the "+409111222" "table_row"
|
||||
When I click on "Yes, remove" "button" in the "Remove '+409111222' SMS?" "dialogue"
|
||||
Then I should see "'SMS mobile phone - +409111222' successfully removed"
|
||||
# Now there is only one active factor left.
|
||||
And I click on "Manage security key" "button"
|
||||
And I should see "Replace" in the "MacBook" "table_row"
|
||||
And I should not see "Remove" in the "MacBook" "table_row"
|
||||
|
||||
@javascript
|
||||
Scenario: I can replace a factor
|
||||
Given the following config values are set as admin:
|
||||
| enabled | 1 | factor_webauthn |
|
||||
And the following "tool_mfa > User factors" exist:
|
||||
| username | factor | label |
|
||||
| admin | webauthn | MacBook |
|
||||
And I follow "Preferences" in the user menu
|
||||
And I click on "Multi-factor authentication preferences" "link"
|
||||
And I click on "Manage security key" "button"
|
||||
And I click on "Replace" "button" in the "MacBook" "table_row"
|
||||
When I click on "Yes, replace" "button" in the "Replace 'MacBook' security key?" "dialogue"
|
||||
Then I should see "Replace security key"
|
46
admin/tool/mfa/tests/generator/behat_tool_mfa_generator.php
Normal file
46
admin/tool/mfa/tests/generator/behat_tool_mfa_generator.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
// This file is part of Moodle - https://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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Behat data generator for tool_mfa.
|
||||
*
|
||||
* @package tool_mfa
|
||||
* @category test
|
||||
* @copyright 2024 David Woloszyn <david.woloszyn@moodle.com>
|
||||
* @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class behat_tool_mfa_generator extends behat_generator_base {
|
||||
|
||||
/**
|
||||
* Get the list of creatable entities for a tool_mfa.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function get_creatable_entities(): array {
|
||||
|
||||
return [
|
||||
'User factors' => [
|
||||
'singular' => 'User factor',
|
||||
'datagenerator' => 'user_factors',
|
||||
'required' => [
|
||||
'username',
|
||||
'factor',
|
||||
'label',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
64
admin/tool/mfa/tests/generator/lib.php
Normal file
64
admin/tool/mfa/tests/generator/lib.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?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/>.
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
global $CFG;
|
||||
require_once(__DIR__ . '/../../lib.php');
|
||||
|
||||
/**
|
||||
* Data generator for tool_mfa plugin.
|
||||
*
|
||||
* @package tool_mfa
|
||||
* @category test
|
||||
* @copyright 2024 David Woloszyn <david.woloszyn@moodle.com>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class tool_mfa_generator extends component_generator_base {
|
||||
/**
|
||||
* Create user factors.
|
||||
*
|
||||
* @param array $record
|
||||
* @return stdClass
|
||||
*/
|
||||
public function create_user_factors(array $record): \stdClass {
|
||||
global $DB;
|
||||
|
||||
$factorobject = \tool_mfa\plugininfo\factor::get_factor($record['factor']);
|
||||
if (!$factorobject) {
|
||||
throw new coding_exception('Unknown factor supplied.');
|
||||
}
|
||||
|
||||
$user = $DB->get_record('user', ['username' => $record['username']]);
|
||||
if (!$user) {
|
||||
throw new coding_exception('No user found with that username.');
|
||||
}
|
||||
|
||||
$record = (object) array_merge([
|
||||
'userid' => $user->id,
|
||||
'secret' => '555553',
|
||||
'timecreated' => time() - DAYSECS,
|
||||
'createdfromip' => '0:0:0:0:0:0:0:1',
|
||||
'timemodified' => time() - MINSECS,
|
||||
'lastverified' => time(),
|
||||
'revoked' => 0,
|
||||
'lockcounter' => 0,
|
||||
], $record);
|
||||
$record->id = $DB->insert_record('tool_mfa', $record);
|
||||
|
||||
return $record;
|
||||
}
|
||||
}
|
@ -82,4 +82,37 @@ class object_factor_base_test extends \advanced_testcase {
|
||||
$this->assertTrue($totpfactor->revoke_user_factor($factorinstance2->id));
|
||||
$this->assertEquals(0, count($totpfactor->get_active_user_factors($user)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the replacement of a factor.
|
||||
*
|
||||
* @covers ::setup_user_factor
|
||||
* @covers ::replace_user_factor
|
||||
*/
|
||||
public function test_replace_user_factor(): void {
|
||||
$this->resetAfterTest();
|
||||
$user = $this->getDataGenerator()->create_user();
|
||||
$this->setUser($user);
|
||||
|
||||
$factor = \tool_mfa\plugininfo\factor::get_factor('totp');
|
||||
|
||||
// Set up the factor.
|
||||
$data1 = new \stdClass();
|
||||
$data1->secret = 'fakesecret1';
|
||||
$data1->devicename = 'fakedevice1';
|
||||
$factor1 = $factor->setup_user_factor($data1);
|
||||
|
||||
// Prepare some replacement data.
|
||||
$data2 = new \stdClass();
|
||||
$data2->secret = 'fakesecret2';
|
||||
$data2->devicename = 'fakedevice2';
|
||||
|
||||
// Replace the active factor with the replacement data.
|
||||
$factor2 = $factor->replace_user_factor($data2, $factor1->id);
|
||||
|
||||
// Check the active factor is the newer one.
|
||||
$activefactors = $factor->get_active_user_factors($user);
|
||||
$this->assertEquals(1, count($activefactors));
|
||||
$this->assertEquals($factor2->id, $activefactors[0]->id);
|
||||
}
|
||||
}
|
||||
|
@ -75,4 +75,48 @@ class plugininfo_factor_test extends \advanced_testcase {
|
||||
$this->assertEquals(2, count(\tool_mfa\plugininfo\factor::get_active_user_factor_types()));
|
||||
$this->assertEquals('fallback', \tool_mfa\plugininfo\factor::get_next_user_login_factor()->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if a user has more than one active factor.
|
||||
*
|
||||
* @covers ::user_has_more_than_one_active_factors
|
||||
*/
|
||||
public function test_user_has_more_than_one_active_factors(): void {
|
||||
global $DB;
|
||||
|
||||
$this->resetAfterTest(true);
|
||||
|
||||
// Create a user.
|
||||
$user = $this->getDataGenerator()->create_user();
|
||||
$this->setUser($user);
|
||||
|
||||
// Create two active user factors.
|
||||
set_config('enabled', 1, 'factor_totp');
|
||||
set_config('enabled', 1, 'factor_webauthn');
|
||||
|
||||
$data = new \stdClass();
|
||||
$data->userid = $user->id;
|
||||
$data->factor = 'totp';
|
||||
$data->label = 'testtotp';
|
||||
$data->revoked = 0;
|
||||
$DB->insert_record('tool_mfa', $data);
|
||||
|
||||
$data = new \stdClass();
|
||||
$data->userid = $user->id;
|
||||
$data->factor = 'webauthn';
|
||||
$data->label = 'testwebauthn';
|
||||
$data->revoked = 0;
|
||||
$factorid = $DB->insert_record('tool_mfa', $data);
|
||||
|
||||
// Test there is more than one active factor.
|
||||
$hasmorethanonefactor = \tool_mfa\plugininfo\factor::user_has_more_than_one_active_factors();
|
||||
$this->assertTrue($hasmorethanonefactor);
|
||||
|
||||
// Revoke a factor.
|
||||
$DB->set_field('tool_mfa', 'revoked', 1, ['id' => $factorid]);
|
||||
|
||||
// There should no longer be more than one active factor.
|
||||
$hasmorethanonefactor = \tool_mfa\plugininfo\factor::user_has_more_than_one_active_factors();
|
||||
$this->assertFalse($hasmorethanonefactor);
|
||||
}
|
||||
}
|
||||
|
7
admin/tool/mfa/upgrade.txt
Normal file
7
admin/tool/mfa/upgrade.txt
Normal 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).
|
@ -58,7 +58,6 @@ if (!empty($action)) {
|
||||
}
|
||||
}
|
||||
|
||||
echo $OUTPUT->active_factors();
|
||||
echo $OUTPUT->available_factors();
|
||||
|
||||
$renderer = $PAGE->get_renderer('tool_mfa');
|
||||
|
@ -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;
|
||||
|
@ -3154,3 +3154,8 @@ blockquote {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
/* General card customisations. */
|
||||
.card.active {
|
||||
border-color: $gray-500;
|
||||
}
|
||||
|
@ -204,6 +204,28 @@ $iconsizes: map-merge((
|
||||
}
|
||||
}
|
||||
|
||||
.icon-circle {
|
||||
display: inline-block;
|
||||
background-color: darken($gray-400, 4%);
|
||||
border-radius: 50%;
|
||||
padding: 1.3rem;
|
||||
|
||||
.icon {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
max-width: unset;
|
||||
max-height: unset;
|
||||
color: $black;
|
||||
}
|
||||
|
||||
&.reversed {
|
||||
background-color: darken($gray-400, 8%);
|
||||
.icon {
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make activtity colours available for custom modules.
|
||||
:root {
|
||||
@each $type, $value in $activity-icon-colors {
|
||||
|
@ -26060,6 +26060,11 @@ blockquote {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* General card customisations. */
|
||||
.card.active {
|
||||
border-color: #8f959e;
|
||||
}
|
||||
|
||||
.action-menu .dropdown-toggle {
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
@ -26367,6 +26372,26 @@ body.behat-site .action-menu .dropdown-subpanel-content.show {
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.icon-circle {
|
||||
display: inline-block;
|
||||
background-color: #c2cad1;
|
||||
border-radius: 50%;
|
||||
padding: 1.3rem;
|
||||
}
|
||||
.icon-circle .icon {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
max-width: unset;
|
||||
max-height: unset;
|
||||
color: #000;
|
||||
}
|
||||
.icon-circle.reversed {
|
||||
background-color: #b7c0c8;
|
||||
}
|
||||
.icon-circle.reversed .icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
:root {
|
||||
--activityadministration: invert(45%) sepia(46%) saturate(3819%) hue-rotate(260deg) brightness(101%) contrast(87%);
|
||||
--activityassessment: invert(36%) sepia(98%) saturate(6969%) hue-rotate(315deg) brightness(90%) contrast(119%);
|
||||
|
@ -26060,6 +26060,11 @@ blockquote {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* General card customisations. */
|
||||
.card.active {
|
||||
border-color: #8f959e;
|
||||
}
|
||||
|
||||
.action-menu .dropdown-toggle {
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
@ -26367,6 +26372,26 @@ body.behat-site .action-menu .dropdown-subpanel-content.show {
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.icon-circle {
|
||||
display: inline-block;
|
||||
background-color: #c2cad1;
|
||||
border-radius: 50%;
|
||||
padding: 1.3rem;
|
||||
}
|
||||
.icon-circle .icon {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
max-width: unset;
|
||||
max-height: unset;
|
||||
color: #000;
|
||||
}
|
||||
.icon-circle.reversed {
|
||||
background-color: #b7c0c8;
|
||||
}
|
||||
.icon-circle.reversed .icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
:root {
|
||||
--activityadministration: invert(45%) sepia(46%) saturate(3819%) hue-rotate(260deg) brightness(101%) contrast(87%);
|
||||
--activityassessment: invert(36%) sepia(98%) saturate(6969%) hue-rotate(315deg) brightness(90%) contrast(119%);
|
||||
|
Loading…
x
Reference in New Issue
Block a user