diff --git a/README.md b/README.md new file mode 100644 index 0000000..e44579f --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# Email One-Time Password Authentication + +Matches only valid email from allowed domain as username. Validates client +credentials and password if exists in current session or generates ones for +session time on empty password treated as one-time password request. + +On first login account is created if not prevented on global level +and parts of email address may be mapped to profile fields using +PCRE expressions. + +See [setting form help](settings.php) for mapping usage example. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..5493b83 --- /dev/null +++ b/TODO.md @@ -0,0 +1,5 @@ +TODO: + +- unit tests +- js snippet to change login form (ex. to add "Generate OTP" button) on valid + email as username diff --git a/auth.php b/auth.php new file mode 100644 index 0000000..41e2018 --- /dev/null +++ b/auth.php @@ -0,0 +1,222 @@ +. + +/** + * Email OTP authentication plugin. + * + * @see self::user_login() + * @package auth_emailotp + * @copyright 2020 Pawel Suwinski + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->libdir.'/authlib.php'); + +use core\output\notification; + +/** + * Email OTP authentication plugin. + * + * @see self::user_login() + * @package auth_emailotp + * @copyright 2020 Pawel Suwinski + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class auth_plugin_emailotp extends auth_plugin_base { + + /** + * The name of the component. Used by the configuration. + */ + const COMPONENT_NAME = 'auth_emailotp'; + + /** + * Constructor. + */ + public function __construct() { + $this->authtype = 'emailotp'; + $this->config = get_config(self::COMPONENT_NAME); + } + + /** + * Old syntax of class constructor. Deprecated in PHP7. + * + * @deprecated since Moodle 3.1 + */ + public function auth_plugin_emailotp() { + debugging('Use of class name as constructor is deprecated', DEBUG_DEVELOPER); + self::__construct(); + } + + /** + * Matches only valid and allowed email as username. Validates credentials + * and password if exists in current session or generates ones for session + * time on empty password treated as one-time password request. + * + * @param string $username The username + * @param string $password The password + * @return bool Authentication success or failure. + */ + public function user_login($username, $password) { + global $CFG, $DB; + if (!validate_email($username) || email_is_not_allowed($username)) { + return false; + } + // OTP already generated and base credentials matches. + if (isset($_SESSION[self::COMPONENT_NAME]) && + $_SESSION[self::COMPONENT_NAME]['credentials'] === static::get_credentials($username)) { + return empty($password) + ? (bool) $this->redirect($username, notification::NOTIFY_INFO) + : password_verify($password, $_SESSION[self::COMPONENT_NAME]['password']); + } + // OTP request - do not proceed on preventaccountcreation when user not exits. + if (!isset($_SESSION[self::COMPONENT_NAME]) && empty($password) && ( + empty($CFG->authpreventaccountcreation) || $DB->get_field('user', 'id', [ + 'username' => $username, + 'mnethostid' => $CFG->mnet_localhost_id, + 'auth' => $this->authtype, + 'deleted' => 0, + ]))) { + $this->redirect($username, $this->gen_otp($username) + ? notification::NOTIFY_SUCCESS + : notification::NOTIFY_ERROR + ); + } + return false; + } + + /** + * {@inheritdoc} + */ + public function is_synchronised_with_external() { + return false; + } + + /** + * {@inheritdoc} + */ + public function is_internal() { + return false; + } + + /** + * {@inheritdoc} + */ + public function can_be_manually_set() { + return true; + } + + /** + * {@inheritdoc} + */ + public function user_authenticated_hook(&$user, $username, $password) { + // Destroy credentials - is already used. + if (isset($_SESSION[self::COMPONENT_NAME])) { + unset($_SESSION[self::COMPONENT_NAME]); + } + } + + /** + * {@inheritdoc} + */ + public function get_userinfo($username) { + $this->get_custom_user_profile_fields(); + $fields = array('email' => $username); + if ($this->config->fieldsmapping_pattern && + $this->config->fieldsmapping_mapping) { + $fields += array_filter( + (new \auth_emailotp\fields_mapper( + $this->config->fieldsmapping_pattern, + strtolower($username), + ))->map(array_map(function($mapping) { + return trim($mapping); + }, explode(PHP_EOL, $this->config->fieldsmapping_mapping))), + function($key) { + return in_array($key, $this->userfields) || + in_array($key, $this->customfields); + }, + ARRAY_FILTER_USE_KEY + ); + } + return $fields; + } + + /** + * get_credentials + * + * @param string $username + * @return void + */ + protected static function get_credentials($username) { + return array( + 'username' => $username, + 'user_agent' => $_SERVER['HTTP_USER_AGENT'], + 'ip' => getremoteaddr(), + ); + } + + /** + * gen_otp + * + * @param string $username + * @return bool + */ + protected function gen_otp(string $username) { + global $CFG; + $newpassword = generate_password(); + $_SESSION[self::COMPONENT_NAME] = array( + 'credentials' => static::get_credentials($username), + 'password' => password_hash($newpassword, PASSWORD_DEFAULT), + ); + $a = (object)array( + 'username' => $username, + 'password' => $newpassword, + ); + return email_to_user( + (object)array( + 'id' => -1, + 'auth' => $this->authtype, + 'username ' => $username, + 'email' => $username, + ), + core_user::get_support_user(), + sprintf( + '%s: %s', + format_string(get_site()->fullname), + (string)new lang_string('otpgeneratedsubj', self::COMPONENT_NAME, $a, $CFG->lang) + ), + (string)new lang_string('otpgeneratedtext', self::COMPONENT_NAME, $a, $CFG->lang) + ); + } + + /** + * redirect + * + * @param string $username + * @param string $msg + * @return void + */ + protected function redirect(string $username, string $msg) { + global $CFG; + redirect( + get_login_url().'?username='.urlencode($username), + (string)new lang_string('otpsent'.$msg, self::COMPONENT_NAME, null, $CFG->lang), + null, + $msg + ); + } +} diff --git a/classes/fields_mapper.php b/classes/fields_mapper.php new file mode 100644 index 0000000..1bc76ad --- /dev/null +++ b/classes/fields_mapper.php @@ -0,0 +1,127 @@ +. + +/** + * Fields mapper. + * + * @package auth_emailotp + * @copyright 2020 Pawel Suwinski + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace auth_emailotp; + +defined('MOODLE_INTERNAL') || die(); + +/** + * fields_mapper + * + * Example of usage: + * + * (new fields_mapper( + * '#/?P[^\.]+)\.(?P[^@]+)@(?P[^\.]+).*#', + * 'my.name@corp.com' + * ))->map([ + * 'firstname:FIRST:ucfirst', + * 'lastname:LAST:ucfirst', + * 'institution:COMPANY:strtoupper, + * ]); + * + * gives: + * + * ['firstname' => 'My', 'lastname' => 'Name', 'institution' => 'CORP'] + * + * @package auth_emailotp + * @copyright 2020 Pawel Suwinski + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class fields_mapper { + + const FORMAT = '^(\w+)\W(.+)'; + + protected $replacepairs = null; + protected $allowedfilters = ['ucwords', 'ucfirst', 'strtoupper']; + + /** + * __construct + * + * @param string $pattern Named capturing groups regexp patttern + * @param string $subject The string being translated + * @return void + */ + public function __construct(string $pattern, string $subject) { + $errhandler = set_error_handler(function ($severity, $message) { + if (error_reporting() & $severity) { + throw new \RuntimeException($message, $severity); + } + }); + if (preg_match($pattern, $subject, $replacepairs)) { + array_shift($replacepairs); + $this->replacepairs = $replacepairs; + } + set_error_handler($errhandler); + } + + /** + * set_allowed_filters + * + * @param array $allowedfilters Unary string functions names + * @throws InvalidArgumentException|ReflectionException + * @return self + */ + public function set_allowed_filters(array $allowedfilters) { + foreach ($allowedfilters as $filter) { + $reflection = new \ReflectionFunction($filter); + if ($reflection->getNumberOfParameters() !== 1 || + $reflection->getNumberOfParameters()[0]->getType()->getName() != 'string') { + throw new \InvalidArgumentException('Expected unary string function as a filter!'); + } + } + $this->allowedfilters = $allowedfilters; + return $this; + } + + /** + * map_fields + * + * @param array $mapping + * @return array + */ + public function map(array $mapping) { + if (empty($this->replacepairs)) { + return array(); + } + $allowedfilters = !empty($this->allowedfilters) + ? '('.implode('|', $this->allowedfilters).')' + : ''; + $fields = array(); + foreach ($mapping as $map) { + $matches = array(); + !empty($allowedfilters) && + preg_match('/'.self::FORMAT.'\W'.$allowedfilters.'$/', $map, $matches) || + preg_match('/'.self::FORMAT.'$/', $map, $matches); + if (count($matches) < 3) { + continue; + } + $value = strtr($matches[2], $this->replacepairs); + if (isset($matches[3])) { + $value = call_user_func($matches[3], $value); + } + $fields[$matches[1]] = $value; + } + return $fields; + } +} diff --git a/classes/privacy/provider.php b/classes/privacy/provider.php new file mode 100644 index 0000000..5f50326 --- /dev/null +++ b/classes/privacy/provider.php @@ -0,0 +1,41 @@ +. +/** + * Privacy Subsystem implementation for auth_emailotp. + * + * @package auth_emailotp + * @copyright 2020 Pawel Suwinski + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace auth_emailotp\privacy; + +defined('MOODLE_INTERNAL') || die(); +/** + * Privacy Subsystem for auth_emailotp implementing null_provider. + * + * @copyright 2020 Pawel Suwinski + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements \core_privacy\local\metadata\null_provider { + /** + * @return string + */ + public static function get_reason() : string { + return 'privacy:metadata'; + } +} + diff --git a/db/install.php b/db/install.php new file mode 100644 index 0000000..d37aa70 --- /dev/null +++ b/db/install.php @@ -0,0 +1,31 @@ +. + +/** + * Email OTP authentication plugin install code. + * + * @package auth_emailotp + * @copyright 2020 Pawel Suwinski + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Function to install auth_emailotp. + */ +function xmldb_auth_emailotp_install() { +} diff --git a/db/upgrade.php b/db/upgrade.php new file mode 100644 index 0000000..b2b57c4 --- /dev/null +++ b/db/upgrade.php @@ -0,0 +1,36 @@ +. + +/** + * Email OTP authentication plugin upgrade code + * + * @package auth_emailotp + * @copyright 2020 Pawel Suwinski + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Function to upgrade auth_emailotp. + * @param int $oldversion the version we are upgrading from + * @return bool result + */ +function xmldb_auth_emailotp_upgrade($oldversion) { + global $CFG; + + return true; +} diff --git a/lang/en/auth_emailotp.php b/lang/en/auth_emailotp.php new file mode 100644 index 0000000..c401e0c --- /dev/null +++ b/lang/en/auth_emailotp.php @@ -0,0 +1,58 @@ +. + +/** + * Strings for component 'auth_emailotp', language 'en'. + * + * @package auth_emailotp + * @copyright 2020 Pawel Suwinski + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['pluginname'] = 'Email OTP'; +$string['otpgeneratedsubj'] = 'One-time password'; +$string['otpgeneratedtext'] = 'One-time password for current session: {$a->password}'; +$string['otpsentsuccess'] = 'One-time password was sent to given email.'; +$string['otpsenterror'] = 'An error occurred while sending one-time password.'; +$string['otpsentinfo'] = 'One-time password for current session was already generated and sent to email.'; +$string['fieldsmapping'] = 'User profile fields mapping'; +$string['fieldsmapping_pattern'] = 'Pattern'; +$string['fieldsmapping_pattern_help'] = 'Capturing groups PCRE patttern.'; +$string['fieldsmapping_mapping'] = 'Mapping'; +$string['fieldsmapping_mapping_help'] = 'Mapping expressions.'; +$string['fieldsmapping_help'] = <<<'EOT' +

Usage example:

+ +Pattern:
+
+'#/?P[^\.]+)\.(?P[^@]+)@(?P[^\.]+).*#',
+
+ +Mapping:
+
+firstname:FIRST:ucfirst
+lastname:LAST:ucfirst
+institution:COMPANY:strtoupper
+
+ +

maps my.name@corp.com to:

+ +firstname: My
+lastname: Name
+institution: CORP
+ +

Allowed modifiers: ucfirst, ucwords, strtoupper.

+EOT; diff --git a/lang/pl/auth_emailotp.php b/lang/pl/auth_emailotp.php new file mode 100644 index 0000000..7f92af9 --- /dev/null +++ b/lang/pl/auth_emailotp.php @@ -0,0 +1,58 @@ +. + +/** + * Strings for component 'auth_emailotp', language 'pl'. + * + * @package auth_emailotp + * @copyright 2020 Pawel Suwinski + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['pluginname'] = 'Email OTP'; +$string['otpgeneratedsubj'] = 'Hasło jednorazowe'; +$string['otpgeneratedtext'] = 'Hasło jednorazowe dla bieżącej sesji: {$a->password}'; +$string['otpsentsuccess'] = 'Hasło jednorazowe zostało wysłane na podany adres email.'; +$string['otpsenterror'] = 'Wystąpił błąd podczas wysyłania hasła jednorazowego.'; +$string['otpsentinfo'] = 'Hasło jednorazowe dla bieżącej sesji już zostało wygenerowane i wyłane.'; +$string['fieldsmapping'] = 'Mapowanie pól profilu użytkownika'; +$string['fieldsmapping_pattern'] = 'Wzorzec'; +$string['fieldsmapping_pattern_help'] = 'Grupujące wyrażenie regularne PCRE.'; +$string['fieldsmapping_mapping'] = 'Mapowanie'; +$string['fieldsmapping_mapping_help'] = 'Wyrażenie mapujące.'; +$string['fieldsmapping_help'] = <<<'EOT' +

Przykład użycia:

+ +Wzorzec:
+
+'#/?P[^\.]+)\.(?P[^@]+)@(?P[^\.]+).*#',
+
+ +Mapowanie:
+
+firstname:FIRST:ucfirst
+lastname:LAST:ucfirst
+institution:COMPANY:strtoupper
+
+ +

odwzoruje my.name@corp.com na:

+ +firstname: My
+lastname: Name
+institution: CORP
+ +

Dozwolone modyfikatory: ucfirst, ucwords, strtoupper.

+EOT; diff --git a/settings.php b/settings.php new file mode 100644 index 0000000..9a9dc22 --- /dev/null +++ b/settings.php @@ -0,0 +1,60 @@ +. + +/** + * Admin settings and defaults + + * @package auth_emailotp + * @copyright 2020 Pawel Suwinski + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die; + +if ($ADMIN->fulltree) { + + $settings->add(new admin_setting_heading('auth_emailotp/pluginname', + new lang_string('fieldsmapping', 'auth_emailotp'), + new lang_string('fieldsmapping_help', 'auth_emailotp'))); + $settings->add(new class( + 'auth_emailotp/fieldsmapping_pattern', + get_string('fieldsmapping_pattern', 'auth_emailotp'), + get_string('fieldsmapping_pattern_help', 'auth_emailotp'), + '', PARAM_RAW_TRIMMED + ) extends admin_setting_configtext { + public function validate($data) { + if (true !== $result = parent::validate($data)) { + return $result; + } + try { + new \auth_emailotp\fields_mapper($data, ''); + } catch (\RuntimeException $e) { + return $e->getMessage(); + } + return true; + } + }); + + $settings->add(new admin_setting_configtextarea('auth_emailotp/fieldsmapping_mapping', + get_string('fieldsmapping_mapping', 'auth_emailotp'), + get_string('fieldsmapping_mapping_help', 'auth_emailotp'), '', PARAM_RAW_TRIMMED)); + + // Display locking / mapping of profile fields. + $authplugin = get_auth_plugin('emailotp'); + display_auth_lock_options($settings, $authplugin->authtype, $authplugin->userfields, + get_string('auth_fieldlocks_help', 'auth'), false, false, + $authplugin->get_custom_user_profile_fields()); +} diff --git a/version.php b/version.php new file mode 100644 index 0000000..3f1489e --- /dev/null +++ b/version.php @@ -0,0 +1,29 @@ +. + +/** + * Email OTP authentication plugin version information + * + * @package auth_emailotp + * @copyright 2020 Pawel Suwinski + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->version = 2020111002; // The current plugin version (Date: YYYYMMDDXX). +$plugin->requires = 2020060900; // Requires this Moodle version. +$plugin->component = 'auth_emailotp'; // Full name of the plugin (used for diagnostics).