mirror of
https://github.com/processwire/processwire.git
synced 2025-08-12 01:34:31 +02:00
Add two-factor authentication module base class. Two modules that implmement it coming shortly.
This commit is contained in:
977
wire/core/Tfa.php
Normal file
977
wire/core/Tfa.php
Normal file
@@ -0,0 +1,977 @@
|
||||
<?php namespace ProcessWire;
|
||||
|
||||
/**
|
||||
* Tfa - Two Factor Authentication module base class
|
||||
*
|
||||
* USAGE
|
||||
* ~~~~~~
|
||||
* $tfa = new Tfa();
|
||||
*
|
||||
* if($tfa->success()) {
|
||||
* $session->redirect('after/login/url/');
|
||||
*
|
||||
* } else if($tfa->active()) {
|
||||
* echo $tfa->render();
|
||||
*
|
||||
* } else if($input->post('submit_login')) {
|
||||
* $name = $input->post('name');
|
||||
* $pass = $input->post('pass');
|
||||
* $tfa->start($name, $pass);
|
||||
*
|
||||
* // the above code performs a redirect if TFA is active for the user
|
||||
* // place your regular code to login user here, which will be used if TFA is not active for the user
|
||||
* }
|
||||
* ~~~~~~
|
||||
*
|
||||
* @method install()
|
||||
* @property int $codeLength Required length for authentication code (default=6)
|
||||
* @property int $codeExpire Codes expire after this many seconds (default=180)
|
||||
* @property int $codeType Type of TFA code to use, see codeType constants (default=0, which is Tfa::codeTypeDigits)
|
||||
*
|
||||
*/
|
||||
class Tfa extends WireData implements Module, ConfigurableModule {
|
||||
|
||||
/**
|
||||
* Code type: digits only
|
||||
*
|
||||
*/
|
||||
const codeTypeDigits = 0;
|
||||
|
||||
/**
|
||||
* Code type: alphabetical letters only
|
||||
*/
|
||||
const codeTypeAlpha = 1;
|
||||
|
||||
/**
|
||||
* Code type: alphanumeric (letters and digits)
|
||||
*
|
||||
*/
|
||||
const codeTypeAlnum = 2;
|
||||
|
||||
/**
|
||||
* Name used for GET variable when TFA is active
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
*/
|
||||
protected $keyName = 'tfa';
|
||||
|
||||
/**
|
||||
* User that authenticated login and pass, but not necessarily code yet
|
||||
*
|
||||
* @var User|null
|
||||
*
|
||||
*/
|
||||
protected $authUser = null;
|
||||
|
||||
/**
|
||||
* Form used for code input
|
||||
*
|
||||
* @var InputfieldForm|null
|
||||
*
|
||||
*/
|
||||
protected $authCodeForm = null;
|
||||
|
||||
/**
|
||||
* Prefix for field names on user template to store TFA data
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
*/
|
||||
protected $userFieldName = 'tfa_type';
|
||||
|
||||
/**
|
||||
* Cached result of getUserConfigInputfields()
|
||||
*
|
||||
* @var InputfieldFieldset|null
|
||||
*
|
||||
*/
|
||||
protected $userConfigInputfields = null;
|
||||
|
||||
/**
|
||||
* Construct
|
||||
*
|
||||
*/
|
||||
public function __construct() {
|
||||
|
||||
parent::__construct();
|
||||
|
||||
if(!$this->wire('fields')->get($this->userFieldName)) {
|
||||
$this->install();
|
||||
}
|
||||
|
||||
$this->initHooks();
|
||||
|
||||
$this->set('codeLength', 6);
|
||||
$this->set('codeExpire', 180);
|
||||
$this->set('codeType', self::codeTypeDigits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start 2-factor authentication
|
||||
*
|
||||
* On successful authentication of user, this method performs a redirect to the next step.
|
||||
* If user does not exist, they are not allowed to login, or fails to authenticate, this method
|
||||
* returns a boolean false. If user authenticates but simply does not have 2FA enabled,
|
||||
* then this method returns true.
|
||||
*
|
||||
* If preferred, you can ignore the return value, as this method will perform redirects whenever
|
||||
* it needs to move on to the next 2FA step.
|
||||
*
|
||||
* @param string $name
|
||||
* @param string $pass
|
||||
* @return bool
|
||||
*
|
||||
*/
|
||||
public function start($name, $pass) {
|
||||
|
||||
/** @var Sanitizer $sanitizer */
|
||||
$sanitizer = $this->wire('sanitizer');
|
||||
/** @var Session $session */
|
||||
$session = $this->wire('session');
|
||||
$name = $sanitizer->pageName($name);
|
||||
$this->sessionReset();
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->wire('users')->get("name=" . $sanitizer->selectorValue($name));
|
||||
|
||||
// unknown user
|
||||
if(!$user || !$user->id) return false;
|
||||
|
||||
// check if user is not allowed to login
|
||||
if(!$session->allowLogin($user->name, $user)) return false;
|
||||
|
||||
// check if user exists but does not have 2FA enabled
|
||||
$tfaModule = $user->get($this->userFieldName);
|
||||
if(!$tfaModule || !$tfaModule->enabled($user)) return true;
|
||||
|
||||
// check if user name and pass authenticate
|
||||
if(!$session->authenticate($user, $pass)) return false;
|
||||
|
||||
// at this point user has successfully authenticated with given name and pass
|
||||
$this->authUser = $user;
|
||||
|
||||
// generate new authentication code for user
|
||||
$code = $tfaModule->generateUserCode($user);
|
||||
|
||||
if(strlen($code) && $tfaModule->sendUserCode($user, $code)) {
|
||||
$key = $this->getSessionKey(true);
|
||||
$this->sessionSet(array(
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'type' => $tfaModule->className(),
|
||||
'time' => time(),
|
||||
));
|
||||
$session->redirect("./?$this->keyName=$key");
|
||||
} else {
|
||||
$this->error('Error creating or sending authentication code');
|
||||
$session->redirect('./');
|
||||
}
|
||||
|
||||
return false; // note: statement cannot be reached due to redirects above
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if TFA is active and process() should be called
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
*/
|
||||
public function active() {
|
||||
return $this->wire('input')->get($this->keyName) === $this->getSessionKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Is TFA enabled for given user?
|
||||
*
|
||||
* This method should be implemented by descending module to perform whatever
|
||||
* check is needed to verify that the user has enabled TFA.
|
||||
*
|
||||
* @param User $user
|
||||
* @return bool
|
||||
*
|
||||
*/
|
||||
public function enabled(User $user) {
|
||||
$settings = $this->getUserSettings($user);
|
||||
$enabled = empty($settings['enabled']) ? false : true;
|
||||
return $enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when TFA has successfully completed
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
*/
|
||||
public function success() {
|
||||
if(!$this->active()) return false;
|
||||
/** @var Tfa $module */
|
||||
$module = $this->getModule();
|
||||
if(!$module) return false;
|
||||
$result = $module->process(); // redirects may occur
|
||||
if($result && $result instanceof User && $result->id) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the TFA module for given user or current session
|
||||
*
|
||||
* @param User $user Optionally specify user
|
||||
* @return Tfa|null
|
||||
*
|
||||
*/
|
||||
public function getModule(User $user = null) {
|
||||
if($user) return $user->get($this->userFieldName);
|
||||
$moduleName = $this->sessionGet('type');
|
||||
if($moduleName) {
|
||||
if($moduleName === $this->className()) return $this;
|
||||
return $this->wire('modules')->getModule($moduleName);
|
||||
} else {
|
||||
$user = $this->getUser();
|
||||
if($user) return $user->get($this->userFieldName);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create unique two-factor authentication code for given $user
|
||||
*
|
||||
* @param User $user
|
||||
* @return string
|
||||
*
|
||||
*/
|
||||
public function generateUserCode(User $user) {
|
||||
$pass = new Password();
|
||||
|
||||
if($this->codeType == self::codeTypeAlpha) {
|
||||
$code = $pass->randomAlnum($this->codeLength, array('numeric' => false));
|
||||
} else if($this->codeType == self::codeTypeAlnum) {
|
||||
$code = $pass->randomAlnum($this->codeLength);
|
||||
} else {
|
||||
$code = $pass->randomDigits($this->codeLength);
|
||||
}
|
||||
|
||||
$expires = time() + $this->codeExpire;
|
||||
$this->saveUserCode($user, $code, $expires);
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send code to user, if applicable to the 2FA authentication method
|
||||
*
|
||||
* @param User $user User to send to
|
||||
* @param string $code Code to send
|
||||
* @return bool Return true on success, false on fail
|
||||
*
|
||||
*/
|
||||
public function sendUserCode(User $user, $code) {
|
||||
if($user && $code) {} // ignore
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save code to valid codes list in session
|
||||
*
|
||||
* @param User $user
|
||||
* @param string $code
|
||||
* @param int $expires Omit to use module configured expires time
|
||||
*
|
||||
*/
|
||||
public function saveUserCode(User $user, $code, $expires = 0) {
|
||||
if($user) {} // ignore
|
||||
if(empty($expires)) $expires = $this->codeExpire;
|
||||
$codes = $this->sessionGet('codes');
|
||||
if(!is_array($codes)) $codes = array();
|
||||
$codes[] = array(
|
||||
'code' => $code,
|
||||
'expires' => $expires,
|
||||
);
|
||||
$this->sessionSet('codes', $codes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get array of codes that are valid (and not yet expired) that can be used for TFA for user
|
||||
*
|
||||
* Note: if you implement your own isValidUserCode() method that does not need to call this method,
|
||||
* then this method will not be used and can be ignored.
|
||||
*
|
||||
* @param User $user
|
||||
* @return array
|
||||
*
|
||||
*/
|
||||
protected function getValidUserCodes(User $user) {
|
||||
if($user) {} // ignore
|
||||
$time = time();
|
||||
$codes = $this->sessionGet('codes', array());
|
||||
$valid = array();
|
||||
foreach($codes as $key => $info) {
|
||||
if($time >= $info['expires']) {
|
||||
unset($codes[$key]);
|
||||
} else if(!empty($info['code'])) {
|
||||
$valid[] = $info['code'];
|
||||
}
|
||||
}
|
||||
$this->sessionSet('codes', $codes);
|
||||
return $valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if code is valid or false if not
|
||||
*
|
||||
* @param User $user
|
||||
* @param string|int $code
|
||||
* @return bool
|
||||
*
|
||||
*/
|
||||
public function isValidUserCode(User $user, $code) {
|
||||
if($user) {} // ignore
|
||||
if(empty($code)) return false;
|
||||
$valid = false;
|
||||
foreach($this->getValidUserCodes($user) as $validCode) {
|
||||
if($validCode && $code === $validCode) {
|
||||
$valid = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a unique key that can be used in the “tfa” GET variable used by this module
|
||||
*
|
||||
* @param bool $reset Reset to new key?
|
||||
* @return string
|
||||
*
|
||||
*/
|
||||
protected function getSessionKey($reset = false) {
|
||||
$key = $this->sessionGet('key');
|
||||
if(empty($key) || $reset) {
|
||||
$pass = new Password();
|
||||
$key = $pass->randomAlnum(20);
|
||||
$this->sessionSet('key', $key);
|
||||
}
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the form used for two-factor authentication
|
||||
*
|
||||
* This form typically appears on the screen after the user has submitted their login info
|
||||
*
|
||||
* At minimum it must have an Inputfield with name “tfa_code”
|
||||
*
|
||||
* @return InputfieldForm
|
||||
*
|
||||
*/
|
||||
protected function buildAuthCodeForm() {
|
||||
|
||||
if($this->authCodeForm) return $this->authCodeForm;
|
||||
|
||||
/** @var Modules $modules */
|
||||
$modules = $this->wire('modules');
|
||||
|
||||
/** @var InputfieldForm $form */
|
||||
$form = $modules->get('InputfieldForm');
|
||||
|
||||
$form->attr('action', "./?$this->keyName=" . $this->getSessionKey(true));
|
||||
$form->attr('id', 'ProcessLoginForm');
|
||||
|
||||
/** @var InputfieldText $f */
|
||||
$f = $modules->get('InputfieldText');
|
||||
$f->attr('name', 'tfa_code');
|
||||
$f->attr('id', 'login_name');
|
||||
$f->label = $this->_('Authentication Code');
|
||||
$f->attr('required', 'required');
|
||||
$f->collapsed = Inputfield::collapsedNever;
|
||||
$form->add($f);
|
||||
|
||||
/** @var InputfieldSubmit $f */
|
||||
$f = $modules->get('InputfieldSubmit');
|
||||
$f->attr('name', 'tfa_submit');
|
||||
$f->attr('id', 'Inputfield_login_submit');
|
||||
$form->add($f);
|
||||
|
||||
$form->appendMarkup = "<p><a href='./'>" . $this->_('Cancel') . "</a></p>";
|
||||
$this->authCodeForm = $form;
|
||||
|
||||
return $form;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the code input form
|
||||
*
|
||||
* @return string
|
||||
*
|
||||
*/
|
||||
public function render() {
|
||||
$this->message($this->_('Please enter your authentication code to complete login.'));
|
||||
if($this->className() == 'Tfa') {
|
||||
// make sure we call the render from the module that implements TFA
|
||||
$module = $this->getModule();
|
||||
if($module) return $module->render();
|
||||
}
|
||||
$form = $this->buildAuthCodeForm();
|
||||
return $form->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process two-factor authentication
|
||||
*
|
||||
* This method processes the submission of the form containing “tfa_code”.
|
||||
* Note that this method will perform redirects as needed.
|
||||
*
|
||||
* @return User|bool Returns logged-in user object on successful code completion, or false on fail
|
||||
*
|
||||
*/
|
||||
public function process() {
|
||||
|
||||
/** @var WireInput $input */
|
||||
$input = $this->wire('input');
|
||||
|
||||
/** @var Session $session */
|
||||
$session = $this->wire('session');
|
||||
|
||||
/** @var string|null $key */
|
||||
$key = $input->get($this->keyName);
|
||||
|
||||
// invalid key, abort
|
||||
if(empty($key) || $key !== $this->getSessionKey() || $this->wire('user')->isLoggedin()) {
|
||||
return $this->sessionReset('./');
|
||||
}
|
||||
|
||||
unset($key);
|
||||
$form = $this->buildAuthCodeForm();
|
||||
$userID = (int) $this->sessionGet('id');
|
||||
$userName = $this->sessionGet('name');
|
||||
$user = $userID ? $this->wire('users')->get($userID) : null;
|
||||
$initTime = (int) $this->sessionGet('time');
|
||||
$numTries = (int) $this->sessionGet('tries');
|
||||
$maxTries = 3;
|
||||
$fail = false;
|
||||
|
||||
if(!$user || !$userID || !$user->id || $userID !== $user->id || $user->name !== $userName) {
|
||||
// unable to find user or name did not match (not likely to ever occur, but just in case)
|
||||
$fail = true;
|
||||
} else if($numTries > $maxTries) {
|
||||
// user has exceeded the max allowed attempts for this login
|
||||
$this->error($this->_('Max attempts reached'));
|
||||
$fail = true;
|
||||
} else if(!$initTime || (time() - $initTime > 180)) {
|
||||
// more than 3 minutes have passed since authentication, so make them start over
|
||||
$this->error($this->_('Time limit reached'));
|
||||
$fail = true;
|
||||
}
|
||||
|
||||
// if fail, exit and remove any 2FA related session variables that were set
|
||||
if($fail) return $this->sessionReset('./');
|
||||
|
||||
// no code submitted, caller should call $tfa->render()
|
||||
if(!$input->post('tfa_code')) return false;
|
||||
|
||||
// code submitted: validate code and set to blank if not valid format
|
||||
$form->processInput($input->post);
|
||||
$code = $form->getChildByName('tfa_code')->val();
|
||||
|
||||
// at this point, a code has been submitted
|
||||
$this->sessionSet('tries', ++$numTries);
|
||||
|
||||
// validate code
|
||||
if($this->isValidUserCode($user, $code) === true) {
|
||||
// code is validated, so do a forced login since user is already authenticated
|
||||
$this->sessionReset();
|
||||
$user = $session->forceLogin($user);
|
||||
if($user && $user->id && $user->id == $userID) {
|
||||
// code successfully validated and user is now logged in
|
||||
return $user;
|
||||
} else {
|
||||
// not likely for login to fail here, since they were already authenticated before
|
||||
$session->redirect('./');
|
||||
}
|
||||
} else {
|
||||
// failed validation
|
||||
$this->error($this->_('Invalid code'));
|
||||
// will ask them to try again
|
||||
$session->redirect("./?$this->keyName=" . $this->getSessionKey());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/*** CONFIG ********************************************************************************************/
|
||||
|
||||
/**
|
||||
* Get fields needed for a user to configure and confirm TFA from their user profile
|
||||
*
|
||||
* This method should be implemented by each TFA module
|
||||
*
|
||||
* @param User $user
|
||||
* @param InputfieldWrapper $fieldset
|
||||
* @param array $settings
|
||||
*
|
||||
*/
|
||||
public function getUserConfigInputfields(User $user, InputfieldWrapper $fieldset, $settings) {
|
||||
if($user || $fieldset || $settings) {} // ignore
|
||||
$fieldset->icon = 'user-secret';
|
||||
$fieldset->attr('id+name', '_tfa_settings');
|
||||
/*
|
||||
$f = $this->modules->get('InputfieldMarkup');
|
||||
$f->attr('name', 'test');
|
||||
$f->label = 'Hello world';
|
||||
$f->value = "<p>This is a test.</p>";
|
||||
$fieldset->add($f);
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the user config fieldset has been processed but before $settings have been saved
|
||||
*
|
||||
* @param User $user
|
||||
* @param InputfieldWrapper $fieldset
|
||||
* @param array $settings Associative array of new/current settings after processing
|
||||
* @param array $settingsPrev Associative array of previous settings
|
||||
* @return array Return $newSettings array (modified as needed)
|
||||
*
|
||||
*/
|
||||
public function processUserConfigInputfields(User $user, InputfieldWrapper $fieldset, $settings, $settingsPrev) {
|
||||
if($user || $fieldset || $settings || $settingsPrev) {} // ignore
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module configuration
|
||||
*
|
||||
* @param InputfieldWrapper $inputfields
|
||||
*
|
||||
*/
|
||||
public function ___getModuleConfigInputfields(InputfieldWrapper $inputfields) {
|
||||
$inputfields->new('integer', 'codeLength', $this->_('Authentication code length'))
|
||||
->val($this->codeLength)
|
||||
->columnWidth(50);
|
||||
$inputfields->new('integer', 'codeExpire', $this->_('Code expiration (seconds)'))
|
||||
->val($this->codeExpire)
|
||||
->columnWidth(50);
|
||||
$inputfields->new('radios', 'codeType', $this->_('Type of code to use'))
|
||||
->val($this->codeType)
|
||||
->addOption(self::codeTypeDigits, $this->_('Digits [0-9]'))
|
||||
->addOption(self::codeTypeAlpha, $this->_('Alpha [A-Z]'))
|
||||
->addOption(self::codeTypeAlnum, $this->_('Alphanumeric [A-Z 0-9]'));
|
||||
}
|
||||
|
||||
/*** SESSION *******************************************************************************************/
|
||||
|
||||
/**
|
||||
* Get a session variable for this module
|
||||
*
|
||||
* @param string $key
|
||||
* @param mixed $blankValue Optionally provide replacement blank value if session var does not exist.
|
||||
* @return mixed|null
|
||||
*
|
||||
*/
|
||||
protected function sessionGet($key, $blankValue = null) {
|
||||
$value = $this->wire('session')->getFor($this->keyName, $key);
|
||||
if($value === null) $value = $blankValue;
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a session variable only for this module
|
||||
*
|
||||
* Optionally set several variables at once by specifying just $key as an associative array.
|
||||
*
|
||||
* @param string|array $key
|
||||
* @param mixed $value
|
||||
*
|
||||
*/
|
||||
protected function sessionSet($key, $value = null) {
|
||||
/** @var Session $session */
|
||||
$session = $this->wire('session');
|
||||
$values = is_array($key) ? $key : array($key => $value);
|
||||
foreach($values as $k => $v) {
|
||||
$session->setFor($this->keyName, $k, $v);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all session variables set for this module
|
||||
*
|
||||
* @param string $redirectURL Optionally redirect to URL after reset
|
||||
* @return bool|string|int
|
||||
*
|
||||
*/
|
||||
protected function sessionReset($redirectURL = '') {
|
||||
$this->wire('session')->removeAllFor($this->keyName);
|
||||
if($redirectURL) $this->wire('session')->redirect($redirectURL);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/*** USER AND SETTINGS *******************************************************************************/
|
||||
|
||||
/**
|
||||
* Get default/blank user settings
|
||||
*
|
||||
* Descending modules should implement this method.
|
||||
*
|
||||
* @param User $user
|
||||
* @return array
|
||||
*
|
||||
*/
|
||||
protected function getDefaultUserSettings(User $user) {
|
||||
if($user) {}
|
||||
return array(
|
||||
'enabled' => false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get TFA data for given user from user_tfa field
|
||||
*
|
||||
* @param User $user
|
||||
* @return array
|
||||
*
|
||||
*/
|
||||
public function getUserSettings(User $user) {
|
||||
|
||||
$defaults = $this->getDefaultUserSettings($user);
|
||||
|
||||
$field = $this->wire('fields')->get($this->userFieldName);
|
||||
if(!$field) return $defaults;
|
||||
|
||||
$value = $user->get($field->name);
|
||||
if(empty($value)) return $defaults;
|
||||
|
||||
$table = $field->getTable();
|
||||
$sql = "SELECT `settings` FROM `$table` WHERE pages_id=:user_id";
|
||||
$query = $this->wire('database')->prepare($sql);
|
||||
$query->bindValue(':user_id', $user->id, \PDO::PARAM_INT);
|
||||
$query->execute();
|
||||
$data = $query->fetchColumn();
|
||||
$query->closeCursor();
|
||||
|
||||
if(empty($data)) {
|
||||
$settings = $defaults;
|
||||
} else {
|
||||
$settings = json_decode($data, true);
|
||||
if(!is_array($settings)) $settings = array();
|
||||
$settings = array_merge($defaults, $settings);
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save TFA data for given user to user_tfa field
|
||||
*
|
||||
* @param User $user
|
||||
* @param array $settings
|
||||
* @return bool
|
||||
*
|
||||
*/
|
||||
public function saveUserSettings(User $user, array $settings) {
|
||||
$field = $this->wire('fields')->get($this->userFieldName);
|
||||
if(!$field) return false;
|
||||
if(!$user->get($field->name)) return false; // no module selected
|
||||
$table = $field->getTable();
|
||||
$json = json_encode($settings);
|
||||
$sql = "UPDATE `$table` SET `settings`=:json WHERE pages_id=:user_id";
|
||||
$query = $this->wire('database')->prepare($sql);
|
||||
$query->bindValue(':user_id', $user->id, \PDO::PARAM_INT);
|
||||
$query->bindValue(':json', $json);
|
||||
return $query->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user for TFA
|
||||
*
|
||||
* @return User
|
||||
*
|
||||
*/
|
||||
protected function getUser() {
|
||||
|
||||
$user = null;
|
||||
|
||||
if($this->authUser) {
|
||||
// user that authenticated
|
||||
$user = $this->authUser;
|
||||
|
||||
} else if($this->wire('user')->isLoggedin()) {
|
||||
// if user is logged in, user can be current user or one being edited
|
||||
$process = $this->wire('process');
|
||||
|
||||
// if process API variable not adequate, attempt to get from current page
|
||||
if(!$process || $process == 'ProcessPageView') {
|
||||
$page = $this->wire('page');
|
||||
$process = $page->get('process');
|
||||
}
|
||||
|
||||
// check if we have a process
|
||||
if($process && $process instanceof Process) {
|
||||
// user being edited like in ProcessUser, ProcessProfile or ProcessPageEdit
|
||||
if($process instanceof WirePageEditor) {
|
||||
$user = $process->getPage();
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
// user is not yet logged in, get from session data if available
|
||||
$userID = $this->sessionGet('id');
|
||||
$userName = $this->sessionGet('name');
|
||||
if($userID && $userName) {
|
||||
$user = $this->wire('users')->get((int) $userID);
|
||||
if($user && (!$user->id || $user->name !== $userName)) $user = null;
|
||||
}
|
||||
}
|
||||
|
||||
// if not a user being edited, user can only be current user
|
||||
if(!$user || !$user instanceof User || !$user->id) {
|
||||
$user = $this->wire('user');
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
|
||||
/*** HOOKS **********************************************************************************************/
|
||||
|
||||
/**
|
||||
* Attach/initialize hooks used by this module
|
||||
*
|
||||
*/
|
||||
protected function initHooks() {
|
||||
$this->addHookBefore('InputfieldForm::render', $this, 'hookInputfieldFormRender');
|
||||
$this->addHookBefore('InputfieldForm::processInput', $this, 'hookBeforeInputfieldFormProcess');
|
||||
$this->addHookAfter('InputfieldForm::processInput', $this, 'hookAfterInputfieldFormProcess');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook before InputfieldForm::processInput()
|
||||
*
|
||||
* @param HookEvent $event
|
||||
*
|
||||
*/
|
||||
public function hookBeforeInputfieldFormProcess(HookEvent $event) {
|
||||
|
||||
/** @var InputfieldForm $form */
|
||||
$form = $event->object;
|
||||
|
||||
// if this is some other form that does not have a “tfa_type” field, then exit
|
||||
$inputfield = $form->getChildByName($this->userFieldName);
|
||||
if(!$inputfield) return;
|
||||
|
||||
$user = $this->getUser();
|
||||
|
||||
// fieldset for TFA settings
|
||||
if($this->userConfigInputfields) {
|
||||
$fieldset = $this->userConfigInputfields;
|
||||
} else {
|
||||
$fieldset = new InputfieldWrapper();
|
||||
$settings = $this->getUserSettings($user);
|
||||
$this->getUserConfigInputfields($user, $fieldset, $settings);
|
||||
}
|
||||
|
||||
foreach($fieldset->getAll() as $f) {
|
||||
$name = $f->attr('name');
|
||||
if(strpos($name, '_tfa_') === 0) list(,$name) = explode('_tfa_', $name);
|
||||
$f->attr('name', "_tfa_$name");
|
||||
}
|
||||
|
||||
$form->insertAfter($fieldset, $inputfield);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook after InputfieldForm::processInput()
|
||||
*
|
||||
* This method grabs data from the TFA related fields added by our render() hooks,
|
||||
* and saves them in the user’s “tfa_type” field “settings” column.
|
||||
*
|
||||
* @param HookEvent $event
|
||||
*
|
||||
*/
|
||||
public function hookAfterInputfieldFormProcess(HookEvent $event) {
|
||||
|
||||
/** @var InputfieldForm $form */
|
||||
$form = $event->object;
|
||||
|
||||
// if this is some other form that does not have a “tfa_type” field, then exit
|
||||
$inputfield = $form->getChildByName($this->userFieldName);
|
||||
if(!$inputfield) return;
|
||||
if(!$inputfield->val()) {
|
||||
// reset settinge
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var InputfieldFieldset $fieldset */
|
||||
$fieldset = $form->getChildByName('_tfa_settings');
|
||||
$user = $this->getUser();
|
||||
$settingsPrev = $this->getUserSettings($user);
|
||||
$settings = $settingsPrev;
|
||||
$changes = array();
|
||||
|
||||
foreach($fieldset->getAll() as $f) {
|
||||
$name = $f->attr('name');
|
||||
if(strpos($name, '_tfa_') === 0) list(,$name) = explode('_tfa_', $name);
|
||||
$settings[$name] = $f->val();
|
||||
}
|
||||
|
||||
$settings = $this->processUserConfigInputfields($user, $fieldset, $settings, $settingsPrev);
|
||||
|
||||
foreach($settings as $name => $value) {
|
||||
if(!isset($settingsPrev[$name]) || $settingsPrev[$name] !== $settings[$name]) {
|
||||
$changes[$name] = $name;
|
||||
}
|
||||
}
|
||||
foreach($settingsPrev as $name => $value) {
|
||||
if(!isset($settings[$name])) $changes[$name] = $name;
|
||||
}
|
||||
|
||||
if(count($changes)) {
|
||||
$this->message("TFA settings changed: " . implode(', ', $changes), Notice::debug);
|
||||
$this->saveUserSettings($user, $settings);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook before InputfieldForm::render()
|
||||
*
|
||||
* This method adds the fields configured in getUserConfigInputfields() and adds
|
||||
* them to the form being rendered, but only if the form already has a field
|
||||
* named “tfa_type”. It also pulls the settings stored in that field, and
|
||||
* populates the module-specific configuration fields.
|
||||
*
|
||||
* @param HookEvent $event
|
||||
*
|
||||
*/
|
||||
public function hookInputfieldFormRender(HookEvent $event) {
|
||||
|
||||
/** @var InputfieldWrapper $inputfields */
|
||||
$inputfields = $event->object;
|
||||
|
||||
// if form does not have a “tfa_type” field, then exit
|
||||
$inputfield = $inputfields->getChildByName($this->userFieldName);
|
||||
if(!$inputfield) return;
|
||||
if(!$inputfield->val()) return;
|
||||
|
||||
|
||||
/** @var Modules $modules */
|
||||
$modules = $event->wire('modules');
|
||||
$user = $this->getUser();
|
||||
$settings = $this->getUserSettings($user);
|
||||
|
||||
if($this->userConfigInputfields && false) {
|
||||
$fieldset = $this->userConfigInputfields;
|
||||
} else {
|
||||
/** @var InputfieldFieldset $fieldset */
|
||||
$fieldset = $modules->get('InputfieldFieldset');
|
||||
$fieldset->label = $modules->getModuleInfoProperty($this, 'title');
|
||||
$fieldset->showIf = "$this->userFieldName=" . $this->className();
|
||||
$this->getUserConfigInputfields($user, $fieldset, $settings);
|
||||
}
|
||||
|
||||
if(!$this->enabled($user) && !$this->wire('input')->requestMethod('POST')) {
|
||||
$this->warning($this->_('Please configure your two-factor authentication settings'));
|
||||
}
|
||||
|
||||
$inputfield->getParent()->insertAfter($fieldset, $inputfield);
|
||||
|
||||
foreach($fieldset->getAll() as $f) {
|
||||
$name = $f->attr('name');
|
||||
if(isset($settings[$name])) $f->val($settings[$name]);
|
||||
$f->attr('name', "_tfa_$name");
|
||||
}
|
||||
}
|
||||
|
||||
/*** INSTALL AND UNINSTALL ******************************************************************************/
|
||||
|
||||
/**
|
||||
* Module module and other assets required to execute it
|
||||
*
|
||||
*/
|
||||
public function ___install() {
|
||||
|
||||
$fieldName = $this->userFieldName;
|
||||
$field = $this->wire('fields')->get($fieldName);
|
||||
|
||||
if(!$field) {
|
||||
$field = new Field();
|
||||
$this->wire($field);
|
||||
$field->name = $fieldName;
|
||||
$field->label = $this->_('2-factor authentication type');
|
||||
$field->type = $this->wire('fieldtypes')->get('FieldtypeModule');
|
||||
$field->flags = Field::flagSystem;
|
||||
$field->icon = 'user-secret';
|
||||
$field->set('moduleTypes', array('Tfa'));
|
||||
$field->set('instantiateModule', 1);
|
||||
$field->set('showNoneOption', 1);
|
||||
$field->set('labelField', 'title');
|
||||
$field->set('inputfieldClass', 'InputfieldRadios');
|
||||
$field->set('blankType', 'zero');
|
||||
$this->wire('fields')->save($field);
|
||||
$this->message("Added field: $field->name", Notice::debug);
|
||||
// add a custom “settings” column to the field
|
||||
$table = $field->getTable();
|
||||
$this->wire('database')->exec("ALTER TABLE `$table` ADD `settings` MEDIUMTEXT");
|
||||
}
|
||||
|
||||
// add user_tfa field to all user template fieldgroups
|
||||
foreach($this->wire('config')->userTemplateIDs as $templateID) {
|
||||
$template = $this->wire('templates')->get($templateID);
|
||||
if(!$template) continue;
|
||||
if($template->fieldgroup->hasField($field)) continue;
|
||||
$template->fieldgroup->add($field);
|
||||
$template->fieldgroup->save();
|
||||
}
|
||||
|
||||
// add user_tfa as field editable in user profile
|
||||
$data = $this->wire('modules')->getConfig('ProcessProfile');
|
||||
if(!isset($data['profileFields'])) $data['profileFields'] = array();
|
||||
if(!in_array($fieldName, $data['profileFields'])) {
|
||||
$data['profileFields'][] = $fieldName;
|
||||
$this->wire('modules')->saveConfig('ProcessProfile', $data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall
|
||||
*
|
||||
*/
|
||||
public function ___uninstall() {
|
||||
|
||||
$tfaModules = $this->wire('modules')->findByPrefix('Tfa');
|
||||
unset($tfaModules[$this->className()]);
|
||||
if(count($tfaModules)) return;
|
||||
|
||||
// no more TFA modules installed, so assets can be removed
|
||||
$fieldName = $this->userFieldName;
|
||||
$field = $this->wire('fields')->get($fieldName);
|
||||
if(!$field) return;
|
||||
$field->addFlag(Field::flagSystemOverride);
|
||||
$field->removeFlag(Field::flagSystem);
|
||||
|
||||
// remove user_tfa field from all user template fieldgroups
|
||||
foreach($this->wire('config')->userTemplateIDs as $templateID) {
|
||||
$template = $this->wire('templates')->get($templateID);
|
||||
if(!$template) continue;
|
||||
if(!$template->fieldgroup->hasField($field)) continue;
|
||||
$template->fieldgroup->remove($field);
|
||||
$template->fieldgroup->save();
|
||||
$this->message("Removed $field from $template", Notice::debug);
|
||||
}
|
||||
|
||||
// completely delete the user_tfa field
|
||||
$this->wire('fields')->delete($field);
|
||||
|
||||
// remove user_tfa as field editable in user profile
|
||||
$data = $this->wire('modules')->getConfig('ProcessProfile');
|
||||
if(!empty($data) && is_array($data['profileFields'])) {
|
||||
$key = array_search($fieldName, $data['profileFields']);
|
||||
if($key !== false) {
|
||||
unset($data['profileFields'][$key]);
|
||||
$this->wire('modules')->saveConfig('ProcessProfile', $data);
|
||||
$this->message("Removed $fieldName from user profile editable fields", Notice::debug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Reference in New Issue
Block a user