diff --git a/admin/tool/mfa/factor/webauthn/amd/build/register.min.js b/admin/tool/mfa/factor/webauthn/amd/build/register.min.js index 367816fe59c..5407918eb3c 100644 --- a/admin/tool/mfa/factor/webauthn/amd/build/register.min.js +++ b/admin/tool/mfa/factor/webauthn/amd/build/register.min.js @@ -6,6 +6,6 @@ * @author Alex Morris * @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 \ No newline at end of file diff --git a/admin/tool/mfa/factor/webauthn/amd/build/register.min.js.map b/admin/tool/mfa/factor/webauthn/amd/build/register.min.js.map index c36e0d4f151..4e97dbce344 100644 --- a/admin/tool/mfa/factor/webauthn/amd/build/register.min.js.map +++ b/admin/tool/mfa/factor/webauthn/amd/build/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 .\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 \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"} \ No newline at end of file +{"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 .\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 \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"} \ No newline at end of file diff --git a/admin/tool/mfa/factor/webauthn/amd/src/register.js b/admin/tool/mfa/factor/webauthn/amd/src/register.js index ada314075a8..7a23a392377 100644 --- a/admin/tool/mfa/factor/webauthn/amd/src/register.js +++ b/admin/tool/mfa/factor/webauthn/amd/src/register.js @@ -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); }); } }; diff --git a/admin/tool/mfa/factor/webauthn/classes/factor.php b/admin/tool/mfa/factor/webauthn/classes/factor.php index a37e80cf277..2ddfa834e24 100644 --- a/admin/tool/mfa/factor/webauthn/classes/factor.php +++ b/admin/tool/mfa/factor/webauthn/classes/factor.php @@ -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; + } + } diff --git a/admin/tool/mfa/factor/webauthn/lang/en/factor_webauthn.php b/admin/tool/mfa/factor/webauthn/lang/en/factor_webauthn.php index 430a1af6165..146e33572f4 100644 --- a/admin/tool/mfa/factor/webauthn/lang/en/factor_webauthn.php +++ b/admin/tool/mfa/factor/webauthn/lang/en/factor_webauthn.php @@ -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'] = '

Use a security key

'; -$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';