diff --git a/admin/tool/mfa/action.php b/admin/tool/mfa/action.php index 416dd265bd5..95cf9a67329 100644 --- a/admin/tool/mfa/action.php +++ b/admin/tool/mfa/action.php @@ -29,7 +29,7 @@ use tool_mfa\local\form\revoke_factor_form; require_login(null, false); if (isguestuser()) { - throw new require_login_exception('Guests are not allowed here.'); + throw new require_login_exception('error:isguestuser', 'tool_mfa'); } $action = optional_param('action', '', PARAM_ALPHANUMEXT); @@ -85,6 +85,7 @@ switch ($action) { $form->is_validated(); if ($form->is_cancelled()) { + $factorobject->setup_factor_form_is_cancelled($factorid); redirect($returnurl); } diff --git a/admin/tool/mfa/classes/local/factor/object_factor.php b/admin/tool/mfa/classes/local/factor/object_factor.php index f51a618db6b..7b6fbd7cbcf 100644 --- a/admin/tool/mfa/classes/local/factor/object_factor.php +++ b/admin/tool/mfa/classes/local/factor/object_factor.php @@ -65,7 +65,7 @@ interface object_factor { * Defines setup_factor form definition page for particular factor. * * @param \MoodleQuickForm $mform - * @return object $mform + * @return \MoodleQuickForm $mform * @throws \coding_exception */ public function setup_factor_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm; @@ -74,7 +74,7 @@ interface object_factor { * Defines setup_factor form definition page after form data has been set. * * @param \MoodleQuickForm $mform - * @return object $mform + * @return \MoodleQuickForm $mform * @throws \coding_exception */ public function setup_factor_form_definition_after_data(\MoodleQuickForm $mform): \MoodleQuickForm; @@ -92,7 +92,7 @@ interface object_factor { * Defines login form definition page for particular factor. * * @param \MoodleQuickForm $mform - * @return object $mform + * @return \MoodleQuickForm $mform * @throws \coding_exception */ public function login_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm; @@ -101,7 +101,7 @@ interface object_factor { * Defines login form definition page after form data has been set. * * @param \MoodleQuickForm $mform - * @return object $mform + * @return \MoodleQuickForm $mform * @throws \coding_exception */ public function login_form_definition_after_data(\MoodleQuickForm $mform): \MoodleQuickForm; @@ -115,6 +115,21 @@ interface object_factor { */ public function login_form_validation(array $data): array; + /** + * Setups in given factor when the form is cancelled + * + * @param int $factorid + * @return void + */ + public function setup_factor_form_is_cancelled(int $factorid): void; + + /** + * Setup submit button string in given factor + * + * @return string|null + */ + public function setup_factor_form_submit_button_string(): ?string; + /** * Setups given factor and adds it to user's active factors list. * Returns true if factor has been successfully added, otherwise false. diff --git a/admin/tool/mfa/classes/local/factor/object_factor_base.php b/admin/tool/mfa/classes/local/factor/object_factor_base.php index e21b0acbd93..b504376192a 100644 --- a/admin/tool/mfa/classes/local/factor/object_factor_base.php +++ b/admin/tool/mfa/classes/local/factor/object_factor_base.php @@ -152,7 +152,7 @@ abstract class object_factor_base implements object_factor { * Dummy implementation. Should be overridden in child class. * * @param \MoodleQuickForm $mform - * @return object $mform + * @return \MoodleQuickForm $mform */ public function setup_factor_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm { return $mform; @@ -164,7 +164,7 @@ abstract class object_factor_base implements object_factor { * Dummy implementation. Should be overridden in child class. * * @param \MoodleQuickForm $mform - * @return object $mform + * @return \MoodleQuickForm $mform */ public function setup_factor_form_definition_after_data(\MoodleQuickForm $mform): \MoodleQuickForm { return $mform; @@ -183,6 +183,28 @@ abstract class object_factor_base implements object_factor { return []; } + /** + * Setups in given factor when the form is cancelled + * + * Dummy implementation. Should be overridden in child class. + * + * @param int $factorid + * @return void + */ + public function setup_factor_form_is_cancelled(int $factorid): void { + } + + /** + * Setup submit button string in given factor + * + * Dummy implementation. Should be overridden in child class. + * + * @return string|null + */ + public function setup_factor_form_submit_button_string(): ?string { + return null; + } + /** * Setups given factor and adds it to user's active factors list. * Returns true if factor has been successfully added, otherwise false. @@ -232,7 +254,7 @@ abstract class object_factor_base implements object_factor { * Dummy implementation. Should be overridden in child class. * * @param \MoodleQuickForm $mform - * @return object $mform + * @return \MoodleQuickForm $mform */ public function login_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm { return $mform; @@ -244,7 +266,7 @@ abstract class object_factor_base implements object_factor { * Dummy implementation. Should be overridden in child class. * * @param \MoodleQuickForm $mform - * @return object $mform + * @return \MoodleQuickForm $mform */ public function login_form_definition_after_data(\MoodleQuickForm $mform): \MoodleQuickForm { return $mform; diff --git a/admin/tool/mfa/classes/local/form/setup_factor_form.php b/admin/tool/mfa/classes/local/form/setup_factor_form.php index b9bdb9d068a..5afe102374e 100644 --- a/admin/tool/mfa/classes/local/form/setup_factor_form.php +++ b/admin/tool/mfa/classes/local/form/setup_factor_form.php @@ -71,7 +71,7 @@ class setup_factor_form extends \moodleform { $factor = \tool_mfa\plugininfo\factor::get_factor($factorname); $mform = $factor->setup_factor_form_definition_after_data($mform); $this->xss_whitelist_static_form_elements($mform); - $this->add_action_buttons(); + $this->add_action_buttons(true, $factor->setup_factor_form_submit_button_string()); } /** diff --git a/admin/tool/mfa/factor/email/classes/factor.php b/admin/tool/mfa/factor/email/classes/factor.php index 73085f7e614..1f2c755672d 100644 --- a/admin/tool/mfa/factor/email/classes/factor.php +++ b/admin/tool/mfa/factor/email/classes/factor.php @@ -49,7 +49,7 @@ class factor extends object_factor_base { * E-Mail Factor implementation. * * @param \MoodleQuickForm $mform Form to inject global elements into. - * @return object $mform + * @return \MoodleQuickForm $mform */ public function login_form_definition_after_data(\MoodleQuickForm $mform): \MoodleQuickForm { $this->generate_and_email_code(); diff --git a/admin/tool/mfa/factor/sms/classes/admin_settings_aws_region.php b/admin/tool/mfa/factor/sms/classes/admin_settings_aws_region.php new file mode 100644 index 00000000000..71f9c81ed7b --- /dev/null +++ b/admin/tool/mfa/factor/sms/classes/admin_settings_aws_region.php @@ -0,0 +1,75 @@ +. + +/** + * Admin setting for AWS regions. + * + * @package factor_sms + * @author Dmitrii Metelkin + * @copyright 2020 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + namespace factor_sms; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot . '/lib/adminlib.php'); + +/** + * Admin setting for a list of AWS regions. + * + * @package factor_sms + * @copyright 2020 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class admin_settings_aws_region extends \admin_setting_configtext { + + /** + * Return part of form with setting. + * + * @param mixed $data array or string depending on setting + * @param string $query + * @return string + */ + public function output_html($data, $query='') { + global $CFG, $OUTPUT; + + $default = $this->get_defaultsetting(); + $options = []; + $all = require_once($CFG->dirroot . '/lib/aws-sdk/src/data/endpoints.json.php'); + $ends = $all['partitions'][0]['regions']; + if ($ends) { + foreach ($ends as $key => $value) { + $options[] = [ + 'value' => $key, + 'label' => $key . ' - ' . $value['description'], + ]; + } + } + + $context = [ + 'list' => $this->get_full_name(), + 'name' => $this->get_full_name(), + 'id' => $this->get_id(), + 'value' => $data, + 'size' => $this->size, + 'options' => $options, + ]; + $element = $OUTPUT->render_from_template('factor_sms/setting_aws_region', $context); + return format_admin_setting($this, $this->visiblename, $element, $this->description, true, '', $default, $query); + } +} diff --git a/admin/tool/mfa/factor/sms/classes/event/sms_sent.php b/admin/tool/mfa/factor/sms/classes/event/sms_sent.php new file mode 100644 index 00000000000..3421fd161f2 --- /dev/null +++ b/admin/tool/mfa/factor/sms/classes/event/sms_sent.php @@ -0,0 +1,62 @@ +. + +namespace factor_sms\event; + +/** + * Event for a sent SMS + * + * @package factor_sms + * @author Alex Morris + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class sms_sent extends \core\event\base { + + /** + * Init sms sent event + */ + protected function init() { + $this->data['crud'] = 'r'; + $this->data['edulevel'] = self::LEVEL_OTHER; + } + + /** + * Returns non-localised event description with id's for admin use only. + * + * @return string + */ + public function get_description(): string { + + $content = [ + 'userid' => $this->other['userid'], + 'debuginfo' => is_array($this->other['debug']) ? json_encode($this->other['debug']) : $this->other['debug'], + ]; + + return get_string('event:smssentdescription', 'factor_sms', $content); + } + + /** + * Returns localised general event name. + * + * Override in subclass, we can not make it static and abstract at the same time. + * + * @return string + */ + public static function get_name(): string { + return get_string('event:smssent', 'factor_sms'); + } +} diff --git a/admin/tool/mfa/factor/sms/classes/factor.php b/admin/tool/mfa/factor/sms/classes/factor.php new file mode 100644 index 00000000000..6e3be4c0cd1 --- /dev/null +++ b/admin/tool/mfa/factor/sms/classes/factor.php @@ -0,0 +1,457 @@ +. + +namespace factor_sms; + +use moodle_url; +use stdClass; +use tool_mfa\local\factor\object_factor_base; + +/** + * SMS Factor implementation. + * + * @package factor_sms + * @subpackage tool_mfa + * @author Peter Burnett + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class factor extends object_factor_base { + + /** @var string Factor icon */ + protected $icon = 'fa-commenting-o'; + + /** + * Defines login form definition page for SMS Factor. + * + * @param \MoodleQuickForm $mform + * @return \MoodleQuickForm $mform + */ + public function login_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm { + $mform->addElement(new \tool_mfa\local\form\verification_field()); + $mform->setType('verificationcode', PARAM_ALPHANUM); + return $mform; + } + + /** + * Defines login form definition page after form data has been set. + * + * @param \MoodleQuickForm $mform Form to inject global elements into. + * @return \MoodleQuickForm $mform + */ + public function login_form_definition_after_data(\MoodleQuickForm $mform): \MoodleQuickForm { + $this->generate_and_sms_code(); + + // Disable the form check prompt. + $mform->disable_form_change_checker(); + return $mform; + } + + /** + * Implements login form validation for SMS Factor. + * + * @param array $data + * @return array + */ + public function login_form_validation(array $data): array { + $return = []; + + if (!$this->check_verification_code($data['verificationcode'])) { + $return['verificationcode'] = get_string('error:wrongverification', 'factor_sms'); + } + + return $return; + } + + /** + * Gets the string for setup button on preferences page. + * + * @return string + */ + public function get_setup_string(): string { + return get_string('setupfactorbutton', 'factor_sms'); + } + + /** + * Defines setup_factor form definition page for SMS Factor. + * + * @param \MoodleQuickForm $mform + * @return \MoodleQuickForm $mform + */ + public function setup_factor_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm { + global $OUTPUT, $USER, $DB; + + if (!empty( + $phonenumber = $DB->get_field('tool_mfa', 'label', ['factor' => $this->name, 'userid' => $USER->id, 'revoked' => 0]) + )) { + redirect( + new \moodle_url('/admin/tool/mfa/user_preferences.php'), + get_string('factorsetup', 'tool_mfa', $phonenumber), + null, + \core\output\notification::NOTIFY_SUCCESS); + } + + $mform->addElement('html', $OUTPUT->heading(get_string('setupfactor', 'factor_sms'), 2)); + + if (empty($this->get_phonenumber())) { + $mform->addElement('hidden', 'verificationcode', 0); + $mform->setType('verificationcode', PARAM_ALPHANUM); + + // Add field for phone number setup. + $mform->addElement('text', 'phonenumber', get_string('addnumber', 'factor_sms'), + [ + 'autocomplete' => 'tel', + 'inputmode' => 'tel', + ]); + $mform->setType('phonenumber', PARAM_TEXT); + + // HTML to display a message about the phone number. + $message = \html_writer::tag('div', '', ['class' => 'col-md-3']); + $message .= \html_writer::tag( + 'div', \html_writer::tag('p', get_string('phonehelp', 'factor_sms')), ['class' => 'col-md-9']); + $mform->addElement('html', \html_writer::tag('div', $message, ['class' => 'row'])); + } + + return $mform; + } + + /** + * Defines setup_factor form definition page after form data has been set. + * + * @param \MoodleQuickForm $mform + * @return \MoodleQuickForm $mform + */ + public function setup_factor_form_definition_after_data(\MoodleQuickForm $mform): \MoodleQuickForm { + global $OUTPUT; + + $phonenumber = $this->get_phonenumber(); + if (empty($phonenumber)) { + return $mform; + } + + $duration = get_config('factor_sms', 'duration'); + $code = $this->secretmanager->create_secret($duration, true); + if (!empty($code)) { + $this->sms_verification_code($code, $phonenumber); + } + $message = get_string('logindesc', 'factor_sms', '' . $phonenumber . '
'); + $message .= get_string('editphonenumberinfo', 'factor_sms'); + $mform->addElement('html', \html_writer::tag('p', $OUTPUT->notification($message, 'success'))); + + $mform->addElement(new \tool_mfa\local\form\verification_field()); + $mform->setType('verificationcode', PARAM_ALPHANUM); + + $editphonenumber = \html_writer::link( + new \moodle_url('/admin/tool/mfa/factor/sms/editphonenumber.php', ['sesskey' => sesskey()]), + get_string('editphonenumber', 'factor_sms'), + ['class' => 'btn btn-secondary', 'type' => 'button']); + + $mform->addElement('html', \html_writer::tag('div', $editphonenumber, ['class' => 'float-sm-left col-md-4'])); + + // Disable the form check prompt. + $mform->disable_form_change_checker(); + + return $mform; + } + + /** + * Returns the phone number from the current session or from the user profile data. + * @return string|null + */ + private function get_phonenumber(): ?string { + global $SESSION, $USER, $DB; + + if (!empty($SESSION->tool_mfa_sms_number)) { + return $SESSION->tool_mfa_sms_number; + } + $phonenumber = $DB->get_field('tool_mfa', 'label', ['factor' => $this->name, 'userid' => $USER->id, 'revoked' => 0]); + if (!empty($phonenumber)) { + return $phonenumber; + } + + return null; + } + + /** + * Returns an array of errors, where array key = field id and array value = error text. + * + * @param array $data + * @return array + */ + public function setup_factor_form_validation(array $data): array { + $errors = []; + + // Phone number validation. + if (!empty($data["phonenumber"]) && empty(helper::is_valid_phonenumber($data["phonenumber"]))) { + $errors['phonenumber'] = get_string('error:wrongphonenumber', 'factor_sms'); + + } else if (!empty($this->get_phonenumber())) { + // Code validation. + if (empty($data["verificationcode"])) { + $errors['verificationcode'] = get_string('error:emptyverification', 'factor_sms'); + } else if ($this->secretmanager->validate_secret($data['verificationcode']) !== $this->secretmanager::VALID) { + $errors['verificationcode'] = get_string('error:wrongverification', 'factor_sms'); + } + } + + return $errors; + } + + /** + * Reset values of the session data of the given factor. + * + * @param int $factorid + * @return void + */ + public function setup_factor_form_is_cancelled(int $factorid): void { + global $SESSION; + if (!empty($SESSION->tool_mfa_sms_number)) { + unset($SESSION->tool_mfa_sms_number); + } + // Clean temp secrets code. + $secretmanager = new \tool_mfa\local\secret_manager('sms'); + $secretmanager->cleanup_temp_secrets(); + } + + /** + * Setup submit button string in given factor + * + * @return string|null + */ + public function setup_factor_form_submit_button_string(): ?string { + global $SESSION; + if (!empty($SESSION->tool_mfa_sms_number)) { + return get_string('setupsubmitcode', 'factor_sms'); + } + return get_string('setupsubmitphone', 'factor_sms'); + } + + /** + * Adds an instance of the factor for a user, from form data. + * + * @param stdClass $data + * @return stdClass|null the factor record, or null. + */ + public function setup_user_factor(stdClass $data): ?stdClass { + global $DB, $SESSION, $USER; + + // Handle phone number submission. + if (empty($SESSION->tool_mfa_sms_number)) { + $SESSION->tool_mfa_sms_number = !empty($data->phonenumber) ? $data->phonenumber : ''; + + $addurl = new \moodle_url('/admin/tool/mfa/action.php', [ + 'action' => 'setup', + 'factor' => 'sms', + ]); + redirect($addurl); + } + + // If the user somehow gets here through form resubmission. + // We dont want two phones active. + if ($DB->record_exists('tool_mfa', ['userid' => $USER->id, 'factor' => $this->name, 'revoked' => 0])) { + return null; + } + + $time = time(); + $label = $this->get_phonenumber(); + + $row = new \stdClass(); + $row->userid = $USER->id; + $row->factor = $this->name; + $row->secret = ''; + $row->label = $label; + $row->timecreated = $time; + $row->createdfromip = $USER->lastip; + $row->timemodified = $time; + $row->lastverified = $time; + $row->revoked = 0; + + $id = $DB->insert_record('tool_mfa', $row); + $record = $DB->get_record('tool_mfa', ['id' => $id]); + $this->create_event_after_factor_setup($USER); + + // Remove session phone number. + unset($SESSION->tool_mfa_sms_number); + + return $record; + } + + /** + * Returns an array of all user factors of given type. + * + * @param stdClass $user the user to check against. + * @return array + */ + public function get_all_user_factors(stdClass $user): array { + global $DB; + + $sql = 'SELECT * + FROM {tool_mfa} + WHERE userid = ? + AND factor = ? + AND label IS NOT NULL + AND revoked = 0'; + + return $DB->get_records_sql($sql, [$user->id, $this->name]); + } + + /** + * Returns the information about factor availability. + * + * @return bool + */ + public function is_enabled(): bool { + if (empty(get_config('factor_sms', 'gateway'))) { + return false; + } + + $class = '\factor_sms\local\smsgateway\\' . get_config('factor_sms', 'gateway'); + if (!call_user_func($class . '::is_gateway_enabled')) { + return false; + } + return parent::is_enabled(); + } + + /** + * Decides if a factor requires input from the user to verify. + * + * @return bool + */ + public function has_input(): bool { + return true; + } + + /** + * Decides if factor needs to be setup by user and has setup_form. + * + * @return bool + */ + public function has_setup(): bool { + return true; + } + + /** + * Decides if the setup buttons should be shown on the preferences page. + * + * @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); + } + + /** + * Returns true if factor class has factor records that might be revoked. + * It means that user can revoke factor record from their profile. + * + * @return bool + */ + public function has_revoke(): bool { + return true; + } + + /** + * Generates and sms' the code for login to the user, stores codes in DB. + * + * @return int|null the instance ID being used. + */ + private function generate_and_sms_code(): ?int { + global $DB, $USER; + + $duration = get_config('factor_sms', 'duration'); + $instance = $DB->get_record('tool_mfa', ['factor' => $this->name, 'userid' => $USER->id, 'revoked' => 0]); + if (empty($instance)) { + return null; + } + $secret = $this->secretmanager->create_secret($duration, false); + // There is a new code that needs to be sent. + if (!empty($secret)) { + // Grab the singleton SMS record. + $this->sms_verification_code($secret, $instance->label); + } + return $instance->id; + } + + /** + * This function sends an SMS code to the user based on the phonenumber provided. + * + * @param int $secret the secret to send. + * @param string|null $phonenumber the phonenumber to send the verification code to. + * @return void + */ + private function sms_verification_code(int $secret, ?string $phonenumber): void { + global $CFG, $SITE; + + // Here we should get the information, then construct the message. + $url = new moodle_url($CFG->wwwroot); + $content = [ + 'fullname' => $SITE->fullname, + 'url' => $url->get_host(), + 'code' => $secret, + ]; + $message = get_string('smsstring', 'factor_sms', $content); + + $class = '\factor_sms\local\smsgateway\\' . get_config('factor_sms', 'gateway'); + $gateway = new $class(); + $gateway->send_sms_message($message, $phonenumber); + } + + /** + * Verifies entered code against stored DB record. + * + * @param string $enteredcode + * @return bool + */ + private function check_verification_code(string $enteredcode): bool { + return ($this->secretmanager->validate_secret($enteredcode) === \tool_mfa\local\secret_manager::VALID) ? true : false; + } + + /** + * Returns all possible states for a user. + * + * @param \stdClass $user + */ + public function possible_states(\stdClass $user): array { + return [ + \tool_mfa\plugininfo\factor::STATE_PASS, + \tool_mfa\plugininfo\factor::STATE_NEUTRAL, + \tool_mfa\plugininfo\factor::STATE_FAIL, + \tool_mfa\plugininfo\factor::STATE_UNKNOWN, + ]; + } + + /** + * Get the login description associated with this factor. + * Override for factors that have a user input. + * + * @return string The login option. + */ + public function get_login_desc(): string { + + $phonenumber = $this->get_phonenumber(); + + if (empty($phonenumber)) { + return get_string('errorsmssent', 'factor_sms'); + } else { + return get_string('logindesc', 'factor_' . $this->name, $phonenumber); + } + } +} diff --git a/admin/tool/mfa/factor/sms/classes/helper.php b/admin/tool/mfa/factor/sms/classes/helper.php new file mode 100644 index 00000000000..03f7498c568 --- /dev/null +++ b/admin/tool/mfa/factor/sms/classes/helper.php @@ -0,0 +1,67 @@ +. + +namespace factor_sms; + +/** + * Helper class for shared sms gateway functions + * + * @package factor_sms + * @author Alex Morris + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class helper { + + /** + * This function internationalises a number to E.164 standard. + * https://46elks.com/kb/e164 + * + * @param string $phonenumber the phone number to format. + * @return string the formatted phone number. + */ + public static function format_number(string $phonenumber): string { + // Remove all whitespace, dashes and brackets. + $phonenumber = preg_replace('/[ \(\)-]/', '', $phonenumber); + + // Number is already in international format. Do nothing. + if (str_starts_with ($phonenumber, '+')) { + return $phonenumber; + } + + // Strip leading 0 if found. + if (str_starts_with ($phonenumber, '0')) { + $phonenumber = substr($phonenumber, 1); + } + + // Prepend country code. + $countrycode = get_config('factor_sms', 'countrycode'); + $phonenumber = !empty($countrycode) ? '+' . $countrycode . $phonenumber : $phonenumber; + + return $phonenumber; + } + + /** + * Validate phone number with E.164 format. https://en.wikipedia.org/wiki/E.164 + * + * @param string $phonenumber from the given user input + * @return bool + */ + public static function is_valid_phonenumber(string $phonenumber) : bool { + $phonenumber = self::format_number($phonenumber); + return (preg_match("/^\+[1-9]\d{1,14}$/", $phonenumber)) ? true : false; + } +} diff --git a/admin/tool/mfa/factor/sms/classes/local/aws_helper.php b/admin/tool/mfa/factor/sms/classes/local/aws_helper.php new file mode 100644 index 00000000000..299edc234d1 --- /dev/null +++ b/admin/tool/mfa/factor/sms/classes/local/aws_helper.php @@ -0,0 +1,100 @@ +. + +/** + * AWS helper class. Contains useful functions when interacting with the SDK. + * + * @package factor_sms + * @author Peter Burnett + * @copyright 2020 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace factor_sms\local; + +use Aws\CommandInterface; +use Aws\AwsClient; +use Psr\Http\Message\RequestInterface; + +/** + * This class contains functions that help plugins to interact with the AWS SDK. + * + * @copyright 2020 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class aws_helper { + + /** + * This creates a proxy string suitable for use with the AWS SDK. + * + * @return string the string to use for proxy settings. + */ + public static function get_proxy_string(): string { + global $CFG; + $proxy = ''; + if (empty($CFG->proxytype)) { + return $proxy; + } + if ($CFG->proxytype === 'SOCKS5') { + // If it is a SOCKS proxy, append the protocol info. + $protocol = 'socks5://'; + } else { + $protocol = ''; + } + if (!empty($CFG->proxyhost)) { + $proxy = $CFG->proxyhost; + if (!empty($CFG->proxyport)) { + $proxy .= ':'. $CFG->proxyport; + } + if (!empty($CFG->proxyuser) && !empty($CFG->proxypassword)) { + $proxy = $protocol . $CFG->proxyuser . ':' . $CFG->proxypassword . '@' . $proxy; + } + } + return $proxy; + } + + /** + * Configure the provided AWS client to route traffic via the moodle proxy for any hosts not excluded. + * + * @param AwsClient $client + * @return AwsClient + */ + public static function configure_client_proxy(AwsClient $client): AwsClient { + $client->getHandlerList()->appendBuild(self::add_proxy_when_required(), 'proxy_bypass'); + return $client; + } + + /** + * Generate a middleware higher order function to wrap the handler and append proxy configuration based on target. + * + * @return callable Middleware high order callable. + */ + protected static function add_proxy_when_required(): callable { + return function (callable $fn) { + return function (CommandInterface $command, ?RequestInterface $request = null) use ($fn) { + if (isset($request)) { + $target = (string) $request->getUri(); + if (!is_proxybypass($target)) { + $command['@http']['proxy'] = self::get_proxy_string(); + } + } + + $promise = $fn($command, $request); + return $promise; + }; + }; + } +} diff --git a/admin/tool/mfa/factor/sms/classes/local/client_factory.php b/admin/tool/mfa/factor/sms/classes/local/client_factory.php new file mode 100644 index 00000000000..a8be7b5b355 --- /dev/null +++ b/admin/tool/mfa/factor/sms/classes/local/client_factory.php @@ -0,0 +1,62 @@ +. + +/** + * AWS Client factory. Retrieves a client with moodle specific HTTP configuration. + * + * @package factor_sms + * @author Peter Burnett + * @copyright 2022 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace factor_sms\local; +use Aws\AwsClient; + +/** + * AWS Client factory. Retrieves a client with moodle specific HTTP configuration. + * + * @copyright 2022 Catalyst IT + * @author Peter Burnett + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class client_factory { + /** + * Get an AWS client with moodle specific HTTP configuration. + * + * @param string $class Fully qualified AWS classname e.g. \Aws\S3\S3Client + * @param array $opts array of constructor options for AWS Client. + * @return AwsClient + */ + public static function get_client(string $class, array $opts): AwsClient { + // Modify the opts to add HTTP timeouts. + if (empty($opts['http'])) { + $opts['http'] = ['connect_timeout' => HOURSECS]; + } else if (!array_key_exists('connect_timeout', $opts['http'])) { + // Try not to override existing settings. + $opts['http']['connect_timeout'] = HOURSECS; + } + + // Blindly trust the call here. If it exceptions, the raw message is the most useful. + $client = new $class($opts); + if (!$client instanceof \Aws\AwsClient) { + throw new \moodle_exception('clientnotfound', 'factor_sms'); + } + + // Now we can configure the proxy with the routing aware middleware. + return aws_helper::configure_client_proxy($client); + } +} diff --git a/admin/tool/mfa/factor/sms/classes/local/smsgateway/aws_sns.php b/admin/tool/mfa/factor/sms/classes/local/smsgateway/aws_sns.php new file mode 100644 index 00000000000..b86cbe2138d --- /dev/null +++ b/admin/tool/mfa/factor/sms/classes/local/smsgateway/aws_sns.php @@ -0,0 +1,156 @@ +. + +namespace factor_sms\local\smsgateway; + +use factor_sms\admin_settings_aws_region; +use factor_sms\event\sms_sent; +use factor_sms\local\aws_helper; + +/** + * AWS SNS SMS Gateway class + * + * @package factor_sms + * @author Peter Burnett + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class aws_sns implements gateway_interface { + + /** + * Create an instance of this class. + */ + public function __construct() { + global $CFG; + require_once($CFG->libdir . '/aws-sdk/src/functions.php'); + require_once($CFG->libdir . '/guzzlehttp/guzzle/src/functions_include.php'); + require_once($CFG->libdir . '/guzzlehttp/promises/src/functions_include.php'); + } + + /** + * Sends a message using the AWS SNS API + * + * @param string $messagecontent the content to send in the SMS message. + * @param string $phonenumber the destination for the message. + * @return bool true on message send success + */ + public function send_sms_message(string $messagecontent, string $phonenumber): bool { + global $SITE, $USER; + + $config = get_config('factor_sms'); + + // Setup client params and instantiate client. + $params = [ + 'version' => 'latest', + 'region' => $config->api_region, + 'http' => ['proxy' => aws_helper::get_proxy_string()], + ]; + if (!$config->usecredchain) { + $params['credentials'] = [ + 'key' => $config->api_key, + 'secret' => $config->api_secret, + ]; + } + $client = new \Aws\Sns\SnsClient($params); + + // Transform the phone number to international standard. + $phonenumber = \factor_sms\helper::format_number($phonenumber); + + // Setup the sender information. + $senderid = $SITE->shortname; + // Remove spaces and non-alphanumeric characters from ID. + $senderid = preg_replace("/[^A-Za-z0-9]/", '', trim($senderid)); + // We have to truncate the senderID to 11 chars. + $senderid = substr($senderid, 0, 11); + + if (defined('BEHAT_SITE_RUNNING')) { + // Fake SMS sending in behat. + return true; + } + + try { + // These messages need to be transactional. + $client->SetSMSAttributes([ + 'attributes' => [ + 'DefaultSMSType' => 'Transactional', + 'DefaultSenderID' => $senderid, + ], + ]); + + // Actually send the message. + $result = $client->publish([ + 'Message' => $messagecontent, + 'PhoneNumber' => $phonenumber, + ]); + + $data = [ + 'relateduserid' => null, + 'context' => \context_user::instance($USER->id), + 'other' => [ + 'userid' => $USER->id, + 'debug' => [ + 'messageid' => $result->get('MessageId'), + ], + ], + ]; + $event = sms_sent::create($data); + $event->trigger(); + + return true; + } catch (\Aws\Exception\AwsException $e) { + throw new \moodle_exception('errorawsconection', 'factor_sms', '', $e->getAwsErrorMessage()); + } + } + + /** + * Add gateway specific settings to the SMS factor settings page. + * + * @param \admin_settingpage $settings + * @return void + */ + public static function add_settings(\admin_settingpage $settings): void { + global $CFG; + + require_once($CFG->dirroot . '/admin/tool/mfa/factor/sms/classes/admin_settings_aws_region.php'); + $settings->add(new \admin_setting_configcheckbox('factor_sms/usecredchain', + get_string('settings:aws:usecredchain', 'factor_sms'), '', 0)); + + if (!get_config('factor_sms', 'usecredchain')) { + // AWS Settings. + $settings->add(new \admin_setting_configtext('factor_sms/api_key', + get_string('settings:aws:key', 'factor_sms'), + get_string('settings:aws:key_help', 'factor_sms'), '')); + + $settings->add(new \admin_setting_configpasswordunmask('factor_sms/api_secret', + get_string('settings:aws:secret', 'factor_sms'), + get_string('settings:aws:secret_help', 'factor_sms'), '')); + } + + $settings->add(new admin_settings_aws_region('factor_sms/api_region', + get_string('settings:aws:region', 'factor_sms'), + get_string('settings:aws:region_help', 'factor_sms'), + 'ap-southeast-2')); + } + + /** + * Returns whether or not the gateway is enabled + * + * @return bool + */ + public static function is_gateway_enabled(): bool { + return true; + } +} diff --git a/admin/tool/mfa/factor/sms/classes/local/smsgateway/gateway_interface.php b/admin/tool/mfa/factor/sms/classes/local/smsgateway/gateway_interface.php new file mode 100644 index 00000000000..56d83b4c9d9 --- /dev/null +++ b/admin/tool/mfa/factor/sms/classes/local/smsgateway/gateway_interface.php @@ -0,0 +1,53 @@ +. + +/** + * SMS Gateway interface + * + * @package factor_sms + * @author Peter Burnett + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace factor_sms\local\smsgateway; + +interface gateway_interface { + + /** + * Sends an SMS message + * + * @param string $messagecontent the content to send in the SMS message. + * @param string $phonenumber the destination for the message. + * @return bool true on message send success + */ + public function send_sms_message(string $messagecontent, string $phonenumber): bool; + + /** + * Add gateway specific settings to the SMS factor settings page. + * + * @param \admin_settingpage $settings + * @return void + */ + public static function add_settings(\admin_settingpage $settings): void; + + /** + * Returns whether or not the gateway is enabled + * + * @return bool + */ + public static function is_gateway_enabled(): bool; +} diff --git a/admin/tool/mfa/factor/sms/classes/privacy/provider.php b/admin/tool/mfa/factor/sms/classes/privacy/provider.php new file mode 100644 index 00000000000..52002184ae3 --- /dev/null +++ b/admin/tool/mfa/factor/sms/classes/privacy/provider.php @@ -0,0 +1,40 @@ +. + +namespace factor_sms\privacy; + +use core_privacy\local\metadata\null_provider; + +/** + * Privacy provider. + * + * @package factor_sms + * @author Peter Burnett + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements null_provider { + + /** + * Get the language string identifier with the component's language + * file to explain why this plugin stores no data. + * + * @return string + */ + public static function get_reason(): string { + return 'privacy:metadata'; + } +} diff --git a/admin/tool/mfa/factor/sms/editphonenumber.php b/admin/tool/mfa/factor/sms/editphonenumber.php new file mode 100644 index 00000000000..1294f1ec9e4 --- /dev/null +++ b/admin/tool/mfa/factor/sms/editphonenumber.php @@ -0,0 +1,44 @@ +. + +/** + * Edit phonenumber redirect + * + * @package factor_sms + * @copyright 2023 Raquel Ortega + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once(__DIR__ . '../../../../../../config.php'); + +require_login(null, false); +if (isguestuser()) { + throw new require_login_exception('error:isguestuser', 'tool_mfa'); +} + +$sesskey = optional_param('sesskey', false, PARAM_TEXT); +require_sesskey(); + +// Remove session phone number. +unset($SESSION->tool_mfa_sms_number); +// Clean temp secrets code. +$secretmanager = new \tool_mfa\local\secret_manager('sms'); +$secretmanager->cleanup_temp_secrets(); + +redirect(new \moodle_url('/admin/tool/mfa/action.php', [ + 'action' => 'setup', + 'factor' => 'sms', +])); diff --git a/admin/tool/mfa/factor/sms/lang/en/factor_sms.php b/admin/tool/mfa/factor/sms/lang/en/factor_sms.php new file mode 100644 index 00000000000..5776db5c2ae --- /dev/null +++ b/admin/tool/mfa/factor/sms/lang/en/factor_sms.php @@ -0,0 +1,70 @@ +. + +/** + * Language strings. + * + * @package factor_sms + * @author Peter Burnett + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['action:revoke'] = 'Revoke 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'; +$string['editphonenumberinfo'] = "If you didn't get the code or entered the wrong number, please edit number and try again."; +$string['errorawsconection'] = 'Error connecting to AWS server: {$a}'; +$string['errorsmssent'] = 'Error sending a SMS message containing your verification code.'; +$string['error:emptyverification'] = 'Empty code. Try again.'; +$string['error:wrongphonenumber'] = 'The phone number you provided is not in a valid format.'; +$string['error:wrongverification'] = 'Wrong code. Try again.'; +$string['event:smssent'] = 'SMS Message sent'; +$string['event:smssentdescription'] = 'The user with id {$a->userid} had a verification code sent to them via SMS
Information: {$a->debuginfo}'; +$string['info'] = '

Setup Mobile phone to receive authentication code.

'; +$string['logindesc'] = 'We\'ve just sent an SMS containing a 6-digit code to your mobile number: {$a}'; +$string['loginoption'] = 'Have a code sent to you mobile phone'; +$string['loginskip'] = "I didn't receive a code"; +$string['loginsubmit'] = 'Continue'; +$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 mobile phone SMS factor plugin does not store any personal data'; +$string['settings:aws'] = 'AWS SNS'; +$string['settings:aws:key'] = 'Key'; +$string['settings:aws:key_help'] = 'Amazon API key credential.'; +$string['settings:aws:region'] = 'Region'; +$string['settings:aws:region_help'] = 'Amazon API gateway region.'; +$string['settings:aws:secret'] = 'Secret'; +$string['settings:aws:secret_help'] = 'Amazon API secret credential.'; +$string['settings:aws:usecredchain'] = 'Use the default credential provider chain to find AWS credentials'; +$string['settings:countrycode'] = 'Country number code'; +$string['settings:countrycode_help'] = 'The calling code without the leading + as a default if users do not enter an international number with a + prefix. + +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 you wish to send messages via'; +$string['setupfactor'] = 'SMS Setup'; +$string['setupfactorbutton'] = 'Setup SMS'; +$string['setupsubmitcode'] = 'Save'; +$string['setupsubmitphone'] = 'Send code'; +$string['smsstring'] = '{$a->code} is your {$a->fullname} one-time security code. + +@{$a->url} #{$a->code}'; +$string['summarycondition'] = 'Using an SMS one-time security code'; diff --git a/admin/tool/mfa/factor/sms/settings.php b/admin/tool/mfa/factor/sms/settings.php new file mode 100644 index 00000000000..2bd2c89eb4a --- /dev/null +++ b/admin/tool/mfa/factor/sms/settings.php @@ -0,0 +1,66 @@ +. + +/** + * Settings + * + * @package factor_sms + * @author Peter Burnett + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); +global $CFG, $OUTPUT; + +$enabled = new admin_setting_configcheckbox('factor_sms/enabled', + new lang_string('settings:enablefactor', 'tool_mfa'), + new lang_string('settings:enablefactor_help', 'tool_mfa'), 0); +$enabled->set_updatedcallback(function () { + \tool_mfa\manager::do_factor_action('sms', get_config('factor_sms', 'enabled') ? 'enable' : 'disable'); +}); +$settings->add($enabled); + +$settings->add(new admin_setting_configtext('factor_sms/weight', + new lang_string('settings:weight', 'tool_mfa'), + new lang_string('settings:weight_help', 'tool_mfa'), 100, PARAM_INT)); + +$settings->add(new admin_setting_configduration('factor_sms/duration', + get_string('settings:duration', 'tool_mfa'), + get_string('settings:duration_help', 'tool_mfa'), 30 * MINSECS, MINSECS)); + +$codeslink = 'https://en.wikipedia.org/wiki/List_of_country_calling_codes'; +$link = \html_writer::link($codeslink, $codeslink); + +$settings->add(new admin_setting_configtext('factor_sms/countrycode', + get_string('settings:countrycode', 'factor_sms'), + get_string('settings:countrycode_help', 'factor_sms', $link), '', PARAM_INT)); + +$gateways = [ + 'aws_sns' => get_string('settings:aws', 'factor_sms'), +]; + +$settings->add(new admin_setting_configselect('factor_sms/gateway', + get_string('settings:gateway', 'factor_sms'), + get_string('settings:gateway_help', 'factor_sms'), + 'aws_sns', $gateways)); + +if (empty(get_config('factor_sms', 'gateway'))) { + return; +} + +$class = '\factor_sms\local\smsgateway\\' . get_config('factor_sms', 'gateway'); +call_user_func($class . '::add_settings', $settings); diff --git a/admin/tool/mfa/factor/sms/templates/setting_aws_region.mustache b/admin/tool/mfa/factor/sms/templates/setting_aws_region.mustache new file mode 100644 index 00000000000..8a21034fec8 --- /dev/null +++ b/admin/tool/mfa/factor/sms/templates/setting_aws_region.mustache @@ -0,0 +1,50 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template factor_sms/setting_aws_region + + Admin aws region setting template. + + Context variables required for this template: + * list - form list name + * name - form element name + * id - element id + * value - element value + * size - element size + * options - list of data list options: label, value. + + Example context (json): + { + "list": "test", + "name": "test", + "id": "test0", + "value": "A tall, dark stranger will have more fun than you.", + "size": "21", + "options": [ { "label": "eu-north-1 - Europe (Stockholm)", "value": "eu-north-1" } ] + } +}} +{{! + Setting config aws region +}} +
+ + + {{#options}} + + {{/options}} + +
diff --git a/admin/tool/mfa/factor/sms/tests/admin_settings_aws_region_test.php b/admin/tool/mfa/factor/sms/tests/admin_settings_aws_region_test.php new file mode 100644 index 00000000000..4be49989f43 --- /dev/null +++ b/admin/tool/mfa/factor/sms/tests/admin_settings_aws_region_test.php @@ -0,0 +1,64 @@ +. + +/** + * factor_sms unit tests. + * + * @package factor_sms + * @author Mikhail Golenkov + * @copyright 2020 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace factor_sms; + +/** + * Testcase for the list of AWS regions admin setting. + * + * @package factor_sms + * @author Mikhail Golenkov + * @copyright 2020 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \admin_settings_aws_region_test + */ +class admin_settings_aws_region_test extends \advanced_testcase { + + /** + * Cleanup after all tests are executed. + * + * @return void + */ + public function tearDown(): void { + $admin = admin_get_root(); + $admin->purge_children(true); + } + /** + * Test that output_html() method works and returns HTML string with expected content. + */ + public function test_output_html(): void { + $this->resetAfterTest(); + $setting = new admin_settings_aws_region('test_aws_region', + 'Test visible name', 'Test description', 'Test default setting'); + $html = $setting->output_html(''); + $this->assertTrue(str_contains($html, 'Test visible name')); + $this->assertTrue(str_contains($html, 'Test description')); + $this->assertTrue(str_contains($html, 'Default: Test default setting')); + $this->assertTrue(str_contains($html, + 'assertTrue(str_contains($html, '')); + $this->assertTrue(str_contains($html, '