MDL-79920 factor_webauthn: Improvements to the webauthn setup

This commit is contained in:
David Woloszyn 2024-03-04 17:00:40 +11:00
parent 34bc9a2e9f
commit 71a5622c71
5 changed files with 140 additions and 38 deletions

View File

@ -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

View File

@ -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"}

View File

@ -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);
});
}
};

View File

@ -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;
}
}

View File

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