Skip to content

Commit

Permalink
MDL-79920 factor_totp: Improvements to the totp setup
Browse files Browse the repository at this point in the history
  • Loading branch information
davewoloszyn committed Apr 4, 2024
1 parent 71a5622 commit dbef09a
Show file tree
Hide file tree
Showing 5 changed files with 114 additions and 45 deletions.
9 changes: 6 additions & 3 deletions admin/tool/mfa/classes/local/form/verification_field.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -51,7 +52,8 @@ public function __construct($attributes = null, $auth = true) {
$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.
Expand All @@ -68,7 +70,8 @@ public function __construct($attributes = null, $auth = true) {

// 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);
}
Expand Down
105 changes: 78 additions & 27 deletions admin/tool/mfa/factor/totp/classes/factor.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,7 @@ public function generate_qrcode(string $secret): string {
$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;
}

Expand Down Expand Up @@ -143,33 +142,36 @@ public function setup_factor_form_definition_after_data(\MoodleQuickForm $mform)
// 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>';
Expand All @@ -186,19 +188,15 @@ public function setup_factor_form_definition_after_data(\MoodleQuickForm $mform)
];

$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;
Expand All @@ -211,9 +209,17 @@ public function setup_factor_form_definition_after_data(\MoodleQuickForm $mform)
}
}

$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;
Expand Down Expand Up @@ -366,7 +372,7 @@ public function setup_user_factor(stdClass $data): stdClass|null {
], '*', IGNORE_MULTIPLE);
if ($record) {
\core\notification::warning(get_string('error:alreadyregistered', 'factor_totp'));
return $record;
return null;
}

$id = $DB->insert_record('tool_mfa', $row);
Expand All @@ -379,6 +385,35 @@ public function setup_user_factor(stdClass $data): stdClass|null {
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.
*
Expand All @@ -399,6 +434,13 @@ public function has_revoke(): bool {
return true;
}

/**
* TOTP Factor implementation.
*/
public function has_replace(): bool {
return true;
}

/**
* TOTP Factor implementation.
*
Expand Down Expand Up @@ -447,6 +489,15 @@ public function possible_states(stdClass $user): array {
* {@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 changes: 2 additions & 0 deletions admin/tool/mfa/factor/totp/lang/en/deprecated.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
setupfactor:scanfail,factor_totp
setupfactor:scan,factor_totp
37 changes: 25 additions & 12 deletions admin/tool/mfa/factor/totp/lang/en/factor_totp.php
Original file line number Diff line number Diff line change
Expand Up @@ -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"';
Expand All @@ -36,36 +37,48 @@
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';
6 changes: 3 additions & 3 deletions admin/tool/mfa/factor/totp/tests/factor_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,11 @@ public function test_wont_store_same_secret_twice() {
'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');
Expand Down

0 comments on commit dbef09a

Please sign in to comment.