22 Commits
1.0.0 ... 1.1.0

Author SHA1 Message Date
Paweł Suwiński
6b67b5940d fix lang string typo 2020-11-19 11:52:23 +01:00
Paweł Suwiński
a556e3bf4c Update README.md 2020-11-19 11:25:06 +01:00
Paweł Suwiński
e71f991d6e README update 2020-11-19 11:19:21 +01:00
Paweł Suwiński
fddba75efc apply local_codechecker recomendations 2020-11-19 10:02:12 +01:00
Paweł Suwiński
bb4d2927fa fix lang string name typo 2020-11-19 09:35:30 +01:00
Paweł Suwiński
79ca27cad8 added CHANGELOG, TODO and version update 2020-11-19 09:25:59 +01:00
Paweł Suwiński
7b6e11a288 phpdoc corrections 2020-11-19 09:07:59 +01:00
Paweł Suwiński
1ef35c31bd support standard not email login of existing user, phpdoc corrections 2020-11-19 09:07:07 +01:00
Paweł Suwiński
23c5697841 undo not working standard login support lang strings 2020-11-18 19:49:28 +01:00
Paweł Suwiński
d4c2f0130a version update 2020-11-18 19:40:21 +01:00
Paweł Suwiński
a660541540 gen_otp refactoring 2020-11-18 19:38:58 +01:00
Paweł Suwiński
c019d32cf8 undo not working standard login support 2020-11-18 19:31:51 +01:00
Paweł Suwiński
792a9e1c7e support standard not email login and username mapping 2020-11-18 19:03:30 +01:00
Paweł Suwiński
15450e8eec apply local_codechecker recomendations 2020-11-18 15:04:19 +01:00
Paweł Suwiński
03680da8f0 setting form: logstore disabled warning, lang: updates and corrections 2020-11-18 14:36:38 +01:00
Paweł Suwiński
fc56948099 event classes refactoring 2020-11-18 14:34:38 +01:00
Paweł Suwiński
2db88bb417 user_login: otpperiod logchecker 2020-11-18 14:33:36 +01:00
Paweł Suwiński
b19202e705 otp_generated and otp_revoked events 2020-11-17 20:49:24 +01:00
Paweł Suwiński
4b0e41dbf3 otp failure limit revoke threshold 2020-11-17 12:17:53 +01:00
Paweł Suwiński
a06447c270 tagging new release 2020-11-13 18:13:22 +01:00
Paweł Suwiński
27ac93e3a8 removed ending trailing comma on fields_mapper arguments list (allowed since php 7.4) 2020-11-13 18:10:00 +01:00
Paweł Suwiński
e4f9f63ff9 lowering requirements to moodle 3.6.4 2020-11-13 17:38:01 +01:00
10 changed files with 301 additions and 70 deletions

12
CHANGELOG.md Normal file
View File

@@ -0,0 +1,12 @@
# Changelog
## [1.1.0] - 2020-11-19
### Added
- security settings revokethreshold and minrequestperiod
- support for standard not email based login of existing users
### Fixed
- settings form help typos

View File

@@ -1,11 +1,20 @@
# 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.
Validates credentials and password if exists in current session or generates
ones for session time on empty password which is treated as one-time password
request and sends it to an email. Matches only valid email from allowed domains
using global `allowemailaddresses` and `denyemailaddresses` settings if set.
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.
Additional security can be set:
- *revoke threshold*: login failures limit causing revoke of the generated password
- *minimum request period*: a time in seconds after which another password can be generated
See [setting form help](settings.php) for mapping usage example.
Signup and user creation on first login takes place only in case of using email
as username (not to be confused with the `authloginviaemail` global setting) if
not prevented (global setting `authpreventaccountcreation`) and parts of email
address may be mapped to profile fields using PCRE expressions.
Auth instruction setting (global `auth_instructions`) is recommended depending
on the adopted user account policy and plugin settings.
See also: `fieldsmapping_help` setting form for [mapping usage example](lang/en/auth_emailotp.php).

View File

@@ -1,5 +1,7 @@
TODO:
- unit tests
- configurable takeover of user authtype after self singup in other auth module
in case of standard not email based logins
- js snippet to change login form (ex. to add "Generate OTP" button) on valid
email as username
- unit tests

159
auth.php
View File

@@ -18,6 +18,7 @@
* Email OTP authentication plugin.
*
* @see self::user_login()
* @see self::get_user_field()
* @package auth_emailotp
* @copyright 2020 Pawel Suwinski <psuw@wp.pl>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
@@ -33,6 +34,7 @@ use core\output\notification;
* Email OTP authentication plugin.
*
* @see self::user_login()
* @see self::get_user_field()
* @package auth_emailotp
* @copyright 2020 Pawel Suwinski <psuw@wp.pl>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
@@ -63,9 +65,9 @@ class auth_plugin_emailotp extends auth_plugin_base {
}
/**
* 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.
* Matches only valid email from allowed domains. 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
@@ -73,56 +75,64 @@ class auth_plugin_emailotp extends auth_plugin_base {
*/
public function user_login($username, $password) {
global $CFG, $DB;
if (!validate_email($username) || email_is_not_allowed($username)) {
$email = validate_email($username)
? $username // Email as username or signup on first login.
: $this->get_user_field($username, 'email'); // Standard login, existing user.
if (empty($email) || email_is_not_allowed($email)) {
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']);
if (empty($password)) {
return (bool) $this->redirect($username, 'otpsent', notification::NOTIFY_INFO);
} else if (password_verify($password, $_SESSION[self::COMPONENT_NAME]['password'])) {
return true;
}
}
// 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
);
empty($CFG->authpreventaccountcreation) || $this->get_user_field($username, 'id'))) {
if (!$this->min_request_period_fulfilled($email)) {
$this->redirect($username, 'otpperiod', notification::NOTIFY_WARNING);
} else if ($this->gen_otp($username, $email)) {
\auth_emailotp\event\otp_generated::create(array(
'other' => array('email' => $email),
))->trigger();
$this->redirect($username, 'otpsent', notification::NOTIFY_SUCCESS);
} else {
$this->redirect($username, 'otpsent', notification::NOTIFY_ERROR);
}
}
// OTP exits but validation failed - reset if revoke threshold is set.
if (isset($_SESSION[self::COMPONENT_NAME])) {
$_SESSION[self::COMPONENT_NAME]['login_failed_count']++;
if (!empty($this->config->revokethreshold) &&
$_SESSION[self::COMPONENT_NAME]['login_failed_count'] >= $this->config->revokethreshold) {
unset($_SESSION[self::COMPONENT_NAME]);
\core\notification::add(get_string('otprevoked', self::COMPONENT_NAME),
notification::NOTIFY_WARNING
);
\auth_emailotp\event\otp_revoked::create(array(
'other' => array('email' => $email),
))->trigger();
}
}
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])) {
@@ -131,7 +141,13 @@ class auth_plugin_emailotp extends auth_plugin_base {
}
/**
* {@inheritdoc}
* get_userinfo
*
* Signup and user creation on first login takes place only in case of
* using email address as username.
*
* @param string $username
* @return array
*/
public function get_userinfo($username) {
$this->get_custom_user_profile_fields();
@@ -141,7 +157,7 @@ class auth_plugin_emailotp extends auth_plugin_base {
$fields += array_filter(
(new \auth_emailotp\fields_mapper(
$this->config->fieldsmapping_pattern,
strtolower($username),
strtolower($username)
))->map(array_map(function($mapping) {
return trim($mapping);
}, explode(PHP_EOL, $this->config->fieldsmapping_mapping))),
@@ -173,33 +189,28 @@ class auth_plugin_emailotp extends auth_plugin_base {
* gen_otp
*
* @param string $username
* @param string $email
* @return bool
*/
protected function gen_otp(string $username) {
protected function gen_otp(string $username, string $email) {
global $CFG;
$newpassword = generate_password();
$_SESSION[self::COMPONENT_NAME] = array(
'credentials' => static::get_credentials($username),
'password' => password_hash($newpassword, PASSWORD_DEFAULT),
'login_failed_count' => 0,
);
$a = (object)array(
$user = (object)array(
'id' => -1, // Fake due email_to_user() requirements.
'auth' => $this->authtype,
'username' => $username,
'email' => $email,
'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)
return email_to_user($user, core_user::get_support_user(),
format_string(get_site()->fullname).': '.
get_string('otpgeneratedsubj', self::COMPONENT_NAME, $user),
get_string('otpgeneratedtext', self::COMPONENT_NAME, $user)
);
}
@@ -210,13 +221,53 @@ class auth_plugin_emailotp extends auth_plugin_base {
* @param string $msg
* @return void
*/
protected function redirect(string $username, string $msg) {
protected function redirect(string $username, string $msg, string $level) {
global $CFG;
redirect(
get_login_url().'?username='.urlencode($username),
(string)new lang_string('otpsent'.$msg, self::COMPONENT_NAME, null, $CFG->lang),
null,
$msg
);
redirect(get_login_url().'?username='.urlencode($username),
get_string($msg.$level, self::COMPONENT_NAME), null, $level);
}
/**
* min_request_period_fulfilled
*
* @param string $email
* @return bool
*/
protected function min_request_period_fulfilled(string $email) {
// Min request period security disabled.
if (empty($this->config->minrequestperiod)) {
return true;
}
// Log reader required - silently return failure on absence.
if (!$reader = reset(get_log_manager()->get_readers('\core\log\sql_reader'))) {
return false;
}
return $reader->get_events_select_count(
'component = ? AND action = ? AND timecreated >= ? AND other = ?',
array(
self::COMPONENT_NAME,
'generated',
time() - $this->config->minrequestperiod,
json_encode(['email' => $email]),
)
) === 0;
}
/**
* get_user_field
*
* @see moodle_database::get_field()
* @param string $username
* @param string $field
* @return mixed
*/
protected function get_user_field(string $username, string $field) {
global $CFG, $DB;
return $DB->get_field('user', $field, array(
'username' => $username,
'mnethostid' => $CFG->mnet_localhost_id,
'auth' => $this->authtype,
'deleted' => 0,
));
}
}

View File

@@ -0,0 +1,76 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
/**
* Event when one-time password is generated.
*
* @package auth_emailotp
* @copyright 2020 Pawel Suwinski <psuw@wp.pl>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace auth_emailotp\event;
defined('MOODLE_INTERNAL') || die();
/**
* Event when one-time password is generated.
*
* @package auth_emailotp
* @copyright 2020 Pawel Suwinski <psuw@wp.pl>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class otp_generated extends \core\event\base {
protected const CRUD = 'c';
protected function init() {
$this->data['crud'] = static::CRUD;
$this->data['edulevel'] = self::LEVEL_OTHER;
$this->context = \context_system::instance();
}
public static function get_name() {
return get_string('eventotp'.substr(static::class, strrpos(static::class, '_') + 1),
'auth_emailotp');
}
public function get_description() {
return sprintf('Password %s for \'%s\'', $this->action,
$this->other['email']);
}
protected function get_legacy_logdata() {
return array(SITEID, 'auth_emailotp', $this->action, '',
$this->other['email']);
}
/**
* Custom validation.
*
* @throws \coding_exception
*/
protected function validate_data() {
parent::validate_data();
if (!isset($this->other['email'])) {
throw new \coding_exception('The \'email\' value must be set in other.');
}
}
public static function get_other_mapping() {
return false;
}
}

View File

@@ -0,0 +1,38 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
/**
* Event when one-time password is revoked.
*
* @package auth_emailotp
* @copyright 2020 Pawel Suwinski <psuw@wp.pl>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace auth_emailotp\event;
defined('MOODLE_INTERNAL') || die();
/**
* Event when one-time password is revoked.
*
* @package auth_emailotp
* @copyright 2020 Pawel Suwinski <psuw@wp.pl>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class otp_revoked extends otp_generated {
protected const CRUD = 'd';
}

View File

@@ -23,14 +23,23 @@
*/
$string['pluginname'] = 'Email OTP';
$string['eventotpgenerated'] = 'Password generated';
$string['eventotprevoked'] = 'Password revoked';
$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['otprevoked'] = 'Previously generated password has been revoked due to exceeding the login failure threshold.';
$string['otpperiodwarning'] = 'Minimum period after which another password can be generated not preserved. Try again later.';
$string['revokethreshold'] = 'Revoke threshold';
$string['revokethreshold_help'] = 'Login failures limit causing revoke of the generated password (0 - unlimited).';
$string['minrequestperiod'] = 'Minium period';
$string['minrequestperiod_help'] = 'A time in seconds after which another password can be generated (0 - unrestricted). Enabled logstore required.';
$string['logstorerequired'] = '<b>Notice: no working logstore! <a href="{$a}">Enable logstore</a> or set time to 0.</b>';
$string['fieldsmapping'] = 'User profile fields mapping on signup';
$string['fieldsmapping_pattern'] = 'Pattern';
$string['fieldsmapping_pattern_help'] = 'Capturing groups PCRE patttern.';
$string['fieldsmapping_pattern_help'] = 'Capturing groups PCRE pattern.';
$string['fieldsmapping_mapping'] = 'Mapping';
$string['fieldsmapping_mapping_help'] = 'Mapping expressions.';
$string['fieldsmapping_help'] = <<<'EOT'
@@ -38,7 +47,7 @@ $string['fieldsmapping_help'] = <<<'EOT'
Pattern:<br />
<pre>
'#/?P<FIRST>[^\.]+)\.(?P<LAST>[^@]+)@(?P<COMPANY>[^\.]+).*#',
'#/?P&lt;FIRST&gt;[^\.]+)\.(?P&lt;LAST&gt;[^@]+)@(?P&lt;COMPANY&gt;[^\.]+).*#',
</pre>
Mapping:<br />

View File

@@ -23,12 +23,21 @@
*/
$string['pluginname'] = 'Email OTP';
$string['eventotpgenerated'] = 'Hasło wynegerowano';
$string['eventotprevoked'] = 'Hasło unieważniono';
$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['otpsentinfo'] = 'Hasło jednorazowe dla bieżącej sesji już zostało wygenerowane i wysłane.';
$string['otprevoked'] = 'Poprzednio wygenerowane hasło zostało unieważnione z powodu przekroczenia limitu niepoprawnych logowań.';
$string['otpperiodwarning'] = 'Nie zachowany minimalny odstęp, po którym kolejne hasło może być wygenerowane. Spróbuj ponownie później.';
$string['revokethreshold'] = 'Próg nieważnienia';
$string['revokethreshold_help'] = 'Limit nieudanych logowań unieważniających wygenerowane hasło (0 - bez limitu).';
$string['minrequestperiod'] = 'Minimalny odstęp';
$string['minrequestperiod_help'] = 'Czas w sekundach, po którym kolejne hasło może być wygenerowane (0 - nieograniczony). Wymaga działającego loggera.';
$string['logstorerequired'] = '<b>Uwaga: logger nieaktywny! <a href="{$a}">Aktywuj logger</a> albo ustaw czas na 0.</b>';
$string['fieldsmapping'] = 'Mapowanie pól profilu użytkownika podczas rejestracji';
$string['fieldsmapping_pattern'] = 'Wzorzec';
$string['fieldsmapping_pattern_help'] = 'Grupujące wyrażenie regularne PCRE.';
$string['fieldsmapping_mapping'] = 'Mapowanie';
@@ -38,7 +47,7 @@ $string['fieldsmapping_help'] = <<<'EOT'
Wzorzec:<br />
<pre>
'#/?P<FIRST>[^\.]+)\.(?P<LAST>[^@]+)@(?P<COMPANY>[^\.]+).*#',
'#/?P&lt;FIRST&gt;[^\.]+)\.(?P&lt;LAST&gt;[^@]+)@(?P&lt;COMPANY&gt;[^\.]+).*#',
</pre>
Mapowanie:<br />

View File

@@ -52,6 +52,29 @@ if ($ADMIN->fulltree) {
get_string('fieldsmapping_mapping', 'auth_emailotp'),
get_string('fieldsmapping_mapping_help', 'auth_emailotp'), '', PARAM_RAW_TRIMMED));
$settings->add(new admin_setting_heading('auth_emailotp/security',
new lang_string('security', 'admin'), ''));
$settings->add(new admin_setting_configtext('auth_emailotp/revokethreshold',
get_string('revokethreshold', 'auth_emailotp'),
get_string('revokethreshold_help', 'auth_emailotp'), 3, PARAM_INT));
$settings->add(new class(
'auth_emailotp/minrequestperiod',
get_string('minrequestperiod', 'auth_emailotp'),
get_string('minrequestperiod_help', 'auth_emailotp')
) extends admin_setting_configtext {
public function __construct($name, $visiblename, $description) {
$logreader = reset(get_log_manager()->get_readers('\core\log\sql_reader'));
parent::__construct($name, $visiblename, $description, $logreader ? 120 : 0, PARAM_INT);
if (!$logreader && !empty($this->get_setting())) {
$this->description .= ' '.get_string('logstorerequired', 'auth_emailotp',
(string)new moodle_url('/admin/settings.php', ['section' => 'managelogging'])
);
}
}
});
// Display locking / mapping of profile fields.
$authplugin = get_auth_plugin('emailotp');
display_auth_lock_options($settings, $authplugin->authtype, $authplugin->userfields,

View File

@@ -24,6 +24,8 @@
defined('MOODLE_INTERNAL') || die();
$plugin->version = 2020111002; // The current plugin version (Date: YYYYMMDDXX).
$plugin->requires = 2020060900; // Requires this Moodle version.
$plugin->version = 2020111903; // The current plugin version (Date: YYYYMMDDXX).
$plugin->requires = 2018120304; // Requires this Moodle version.
$plugin->component = 'auth_emailotp'; // Full name of the plugin (used for diagnostics).
$plugin->maturity = MATURITY_STABLE;
$plugin->release = '1.1.0';