artabro/wire/core/Tfa.php
2024-08-27 11:35:37 +02:00

2005 lines
60 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php namespace ProcessWire;
/**
* Tfa - ProcessWire Two Factor Authentication module base class
*
* This class is for “Tfa” modules to extend. See the TfaEmail and TfaTotp modules as examples.
*
* ProcessWire 3.x, Copyright 2022 by Ryan Cramer
* https://processwire.com
*
*
* 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 start() method 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
*
* } else {
* // render login form
* }
* ~~~~~~
*
* SETTINGS
* @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)
* @property string $startUrl URL we are operating from (default='./')
* @property int $rememberDays Number of days to "remember this browser", 0 to disable option, or -1 for no limit? (default=0)
* @property array $rememberFingerprints Fingerprints to remember: agent,agentVL,accept,scheme,host,ip,fwip (default=agentVL,accept,scheme,host)
* @property array $formAttrs Form <form> element attributes
* @property array $inputAttrs Code <input> element attributes
* @property array $submitAttrs Submit button attributes
* @property bool $showCancel Show a cancel link under authentication code form? (default=true)
* @property string $autoType Automatic/force TFA type (module name) to use when user doesnt already have TFA enabled (default='')
* @property array $autoRoleIDs Role IDs to enforce $autoType or blank for all roles, applies only if $autoType set (default=[])
* @property string $cancelMarkup Markup to use for the cancel link that appears under auth code form, must have {url} and {label} placeholders.
*
* TEXT LABELS
* @property string $cancelLabel Label to use for Cancel link (default='Cancel', translatable)
* @property string $configureLabel Indicates that TFA needs to be configured
* @property string $enabledLabel Indicates TFA enabled
* @property string $enabledDescLabel Describes enabled TFA and how to change settings
* @property string $expiredCodeLabel Expired code error
* @property string $fieldTfaTypeLabel Select 2-factor auth type
* @property string $fieldTfaTypeDescLabel Description of 2-factor auth type
* @property string $inputLabel Label for code <input> element
* @property string $invalidCodeLabel Invalid code error
* @property string $maxAttemptsLabel Max attempts error
* @property string $rememberLabel Label for "remember this browser" option
* @property string $rememberSuccessLabel Indicates that browser has been saved/remembered for n days.
* @property string $rememberSkipLabel Indicates that code entry was skipped because browser is remembered
* @property string $rememberClearLabel Clear remembered browsers
* @property string $rememberClearedLabel Message after remembered browsers cleared
* @property string $sendCodeErrorLabel Error creating or sending code
* @property string $submitLabel Label for submit button
* @property string $timeLimitLabel Time limit reached error
*
* HOOKABLE METHODS
* @method bool start($name, $pass)
* @method InputfieldForm buildAuthCodeForm()
* @method string render()
* @method User|bool process()
* @method void getUserSettingsInputfields(User $user, InputfieldWrapper $fieldset, $settings)
* @method void getUserEnabledInputfields(User $user, InputfieldWrapper $fieldset, $settings)
* @method array processUserSettingsInputfields(User $user, InputfieldWrapper $fieldset, $settings, $settingsPrev)
* @method array processUserEnabledInputfields(User $user, InputfieldWrapper $fieldset, $settings, $settingsPrev)
* @method install()
* @method uninstall()
*
*/
class Tfa extends WireData implements Module, ConfigurableModule {
const userFieldName = 'tfa_type';
/**
* Name used for GET variable when TFA is active
*
* @var string
*
*/
protected $keyName = 'tfa';
/**
* Form used for code input
*
* @var InputfieldForm|null
*
*/
protected $authCodeForm = null;
/**
* Name of field carrying Tfa type selection on user template
*
* @var string
*
*/
protected $userFieldName = self::userFieldName;
/**
* Default settings
*
* @var array
*
*/
protected $defaults = array(
// settings
'codeExpire' => 180,
'startUrl' => './',
'rememberDays' => 0,
'rememberFingerprints' => array('agentVL', 'accept', 'scheme', 'host'),
'autoType' => '',
'autoRoleIDs' => array(),
'formAttrs' => array('id' => 'ProcessLoginForm', 'class' => 'pw-tfa'),
'inputAttrs' => array('id' => 'login_name', 'autofocus' => 'autofocus'),
'submitAttrs' => array('id' => 'Inputfield_login_submit'),
'submitLabel' => '',
'showCancel' => true,
'cancelMarkup' => "<p><a href='{url}'>{label}</a></p>",
// labels
'cancelLabel' => 'Cancel',
'configureLabel' => 'Please configure',
'enabledLabel' => 'ENABLED',
'enabledDescLabel' => 'To disable or change settings, select the “None” option above and save.',
'expiredCodeLabel' => 'Expired code',
'fieldTfaTypeLabel' => '2-factor authentication type',
'fieldTfaTypeDescLabel' => 'After making or changing a selection, submit the form and return here to configure it.',
'inputLabel' => 'Authentication Code',
'invalidCodeLabel' => 'Invalid code',
'maxAttemptsLabel' => 'Max attempts reached',
'sendCodeErrorLabel' => 'Error creating or sending authentication code',
'timeLimitLabel' => 'Time limit reached',
'rememberClearLabel' => 'Clear remembered browsers that skip entering authentication code',
'rememberClearedLabel' => 'Cleared remembered browsers',
'rememberLabel' => 'Remember this computer?',
'rememberSuccessLabel' => 'This computer/browser is now remembered for up to %d days.',
'rememberSkipLabel' => 'Code was not required because the browser was recognized from a previous login.',
);
/**
* Construct
*
*/
public function __construct() {
$this->setArray($this->defaults);
parent::__construct();
}
/**
* Called when assigned to ProcessWire instance
*
*/
public function wired() {
// @todo convert to getLabel() switch and make defaults (above) blank
$this->setArray(array(
'cancelLabel' =>
$this->_('Cancel'),
'configureLabel' =>
$this->_('Please configure'),
'enabledLabel' =>
$this->_('ENABLED'),
'enabledDescLabel' =>
$this->_('Two factor authentication enabled!') . ' ' .
$this->_('To disable or change settings, select the “None” option above and save.'),
'expiredCodeLabel' =>
$this->_('Expired code'),
'fieldTfaTypeLabel' =>
$this->_('2-factor authentication type'),
'fieldTfaTypeDescLabel' =>
$this->_('After making or changing a selection, submit the form and return here to configure it.'),
'inputLabel' =>
$this->_('Authentication Code'),
'invalidCodeLabel' =>
$this->_('Invalid code'),
'maxAttemptsLabel' =>
$this->_('Max attempts reached'),
'rememberClearLabel' =>
$this->_('Clear remembered browsers that skip entering authentication code'),
'rememberClearedLabel' =>
$this->_('Cleared remembered browsers'),
'rememberLabel' =>
$this->_('Remember this computer?'),
'rememberSuccessLabel' =>
$this->_('This computer/browser is now remembered for up to %d days.') . ' ' .
$this->_('Changes to browser and/or location may require a new code.'),
'rememberSkipLabel' =>
$this->_('Code was not required because the browser was recognized from a previous login.'),
'sendCodeErrorLabel' =>
$this->_('Error creating or sending authentication code'),
'timeLimitLabel' =>
$this->_('Time limit reached'),
));
if(!$this->wire()->fields->get($this->userFieldName)) $this->install();
parent::wired();
}
/**
* Module init
*
*/
public function init() {
$this->initHooks();
}
/**
* Get translated Tfa type name (short name)
*
* When a module implements this, it should not make a a parent::getTfaTypeName() call.
*
* @return string
* @since 3.0.160
*
*/
public function getTfaTypeName() {
$className = $this->className();
return $className === 'Tfa' ? 'Tfa' : str_replace('Tfa', '', $className);
}
/**
* Get translated Tfa type title (longer name)
*
* This is generally the same or similar to the module title, though in a $this->_('…'); call
* so that it is translatable. When a module implements this, it should not make a
* parent::getTfaTypeLabel() call.
*
* @return string
* @since 3.0.160
*
*/
public function getTfaTypeTitle() {
$className = $this->className();
if($className === 'Tfa') return 'Tfa';
return $this->wire()->modules->getModuleInfoProperty($className, 'title');
}
/**
* Get translated Tfa type summary
*
* This is generally the same or similar to the module summary, though in a $this->_('…'); call
* so that it is translatable. When a module implements this, it should not make a
* parent::getTfaTypeLabel() call.
*
* @return string
* @since 3.0.160
*
*/
public function getTfaTypeSummary() {
$className = $this->className();
if($className === 'Tfa') return '';
return $this->wire()->modules->getModuleInfoProperty($className, 'summary');
}
/**
* Access the RememberTfa instance
*
* #pw-internal
*
* @param User $user
* @param array $settings
* @return RememberTfa
*
*/
public function remember(User $user, array $settings) {
/** @var RememberTfa $remember */
$remember = $this->wire(new RememberTfa($this, $user, $settings));
$remember->setDays($this->rememberDays);
$remember->setFingerprints($this->rememberFingerprints);
return $remember;
}
/**
* Get the start URL and optionally append query string
*
* @param string $queryString
* @return string
*
*/
protected function url($queryString = '') {
$url = $this->startUrl;
if(empty($queryString)) return $url;
$queryString = ltrim($queryString, '?&');
$url .= (strpos($url, '?') === false ? '?' : '&');
$url .= $queryString;
return $url;
}
/**
* Redirect to URL
*
* @param string $url
*
*/
protected function redirect($url) {
if(strpos($url, '/') === false) $url = $this->url($url);
$this->session->redirect($url, false);
}
/**
* 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 does not have 2FA enabled, or is remembered from a previous
* TFA login, then this method returns true, but user still needs to be authenticated.
*
* 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 = $this->getModule($user); /** @var Tfa $tfaModule */
if(!$tfaModule) return true;
$settings = $tfaModule->getUserSettings($user);
if(!$tfaModule->enabledForUser($user, $settings)) return true;
// check if user name and pass authenticate
if(!$session->authenticate($user, $pass)) return false;
if($tfaModule->rememberDays && $tfaModule->remember($user, $settings)->remembered()) {
if($this->wire()->config->debug) {
$this->message($this->rememberSkipLabel, Notice::noGroup);
}
return true;
}
// at this point user has successfully authenticated with given name and pass
if($tfaModule->startUser($user, $settings)) {
$key = $this->getSessionKey(true);
$this->redirect("$this->keyName=$key");
} else {
$this->error($this->sendCodeErrorLabel); // Error creating or sending authentication code'
$this->redirect($this->startUrl);
}
return false; // note: statement cannot be reached due to redirects above
}
/**
* Start two-factor authentication for User
*
* Modules must implement this method unless they do not need to generate or send
* authentication codes to the user. Below are details on how to implement this
* method:
*
* A. For modules that generate and validate their own authentication codes:
* 1. Generate an authentication code for user
* 2. Save the code to session
* 3. Send the code to the user via whatever TFA channel is used
* 4. Call parent::startUser($user)
* 5. Return true (if no errors)
*
* B. For modules that use an external service to generate, send and validate codes:
* 1. Call on the external service to generate and the code to user
* 2. Call parent::startUser($user)
* 3. Return true (if no errors)
*
* C. Modules that do not generate or send codes, but only validate them (i.e. TOTP):
* You can omit implementation, leaving just the built-in one below.
* But if you do implement it, make sure you call the parent::startUser($user).
*
* @param User $user
* @param array $settings Settings configured by user
* @return bool True on success, false on fail
*
*/
public function startUser(User $user, array $settings) {
if($settings) {} // ignore
$this->sessionSet(array(
'id' => $user->id,
'name' => $user->name,
'pash' => $this->getUserPash($user),
'type' => $this->className(),
'time' => time(),
));
return true;
}
/**
* Return true if code is valid or false if not
*
* Modules MUST implement this method.
*
* @param User $user
* @param string|int $code
* @param array $settings User configured TFA settings
* @return bool|int Returns true if valid, false if not, or optionally integer 0 if code was valid but is now expired
* @throws WireException
*
*/
public function isValidUserCode(User $user, $code, array $settings) {
if($code && $settings) throw new WireException('Modules should not call this method');
$value = false;
/** @var bool|int $value */
return $value;
}
/**
* Returns true if a TFA process is currently active
*
* - This method should be called if $tfa->success() returns false.
* - If this method returns true, you should `echo $tfa->render()` which will
* render the auth code form.
* - If this method returns false and login/pass submitted, then call `$tfa->start()`,
* or if login not submitted, then render login form.
*
* @return bool
*
*/
public function active() {
return $this->wire()->input->get($this->keyName) === $this->getSessionKey();
}
/**
* Is this Tfa module 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.
*
* #pw-internal
*
* @param User $user
* @param array $settings
* @return bool
*
*/
public function enabledForUser(User $user, array $settings) {
$enabled = empty($settings['enabled']) ? false : true;
return $enabled;
}
/**
* Returns true when TFA has successfully completed and user is now logged in
*
* Note that this method functions as part of the TFA flow control and will
* perform redirects during processing.
*
* @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 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) {
$module = null;
$moduleName = $this->sessionGet('type');
if($user) {
$module = $user->get($this->userFieldName);
} else if($moduleName && $moduleName === $this->className()) {
$module = $this;
} else if($moduleName) {
$module = $this->wire()->modules->getModule($moduleName);
} else {
$user = $this->getUser();
if($user) $module = $user->get($this->userFieldName);
}
if($module && is_string($module)) {
// if field returned module name rather than instance (field settings)
$module = $this->wire()->modules->getModule($module);
}
if($module && !$module instanceof Tfa) $module = null;
if(!$module && $user && $this->autoType) {
// automatic/forced TFA type
$autoType = '';
if(count($this->autoRoleIDs)) {
// verify that user has role
$autoRoleIDs = $this->autoRoleIDs;
foreach($user->roles as $role) {
if(in_array($role->id, $autoRoleIDs)) $autoType = $this->autoType;
if($autoType) break;
}
} else {
// enabled for all roles
$autoType = $this->autoType;
}
if($autoType) {
/** @var Tfa $module */
$module = $this->wire()->modules->getModule($autoType);
if($module) {
if($module->autoEnableSupported($user)) {
$module->autoEnableUser($user);
} else {
$module = null;
}
}
}
}
if($module) {
/** @var Tfa $module */
foreach(array_keys($this->defaults) as $key) {
$module->set($key, $this->get($key));
}
}
return $module;
}
/**
* 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) {
$rand = new WireRandom();
$this->wire($rand);
$key = $rand->alphanumeric(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
*
*/
public function ___buildAuthCodeForm() {
if($this->authCodeForm) return $this->authCodeForm;
$modules = $this->wire()->modules;
/** @var InputfieldForm $form */
$form = $modules->get('InputfieldForm');
foreach($this->formAttrs as $name => $value) {
$form->attr($name, $value);
}
$form->attr('action', $this->url($this->keyName . '=' . $this->getSessionKey(true)));
/** @var InputfieldText $f */
$f = $modules->get('InputfieldText');
foreach($this->inputAttrs as $name => $value) {
$f->attr($name, $value);
}
$f->attr('name', 'tfa_code');
$f->label = $this->inputLabel; // Authentication code
$f->attr('required', 'required');
$f->collapsed = Inputfield::collapsedNever;
$form->add($f);
if($this->rememberDays) {
/** @var InputfieldCheckbox $f */
$f = $modules->get('InputfieldCheckbox');
$f->attr('id+name', 'tfa_remember');
$f->attr('value', $this->rememberDays);
$f->label = $this->rememberLabel;
$f->collapsed = Inputfield::collapsedNever;
$form->add($f);
}
/** @var InputfieldSubmit $f */
$f = $modules->get('InputfieldSubmit');
foreach($this->submitAttrs as $name => $value) {
$f->attr($name, $value);
}
$f->attr('name', 'tfa_submit');
if($this->submitLabel) $f->val($this->submitLabel);
$form->add($f);
if($this->showCancel) {
$cancelUrl = $this->url();
$cancelLabel = $this->wire()->sanitizer->entities1($this->cancelLabel);
$form->appendMarkup .= str_replace(
array('{url}', '{label}'),
array($cancelUrl, $cancelLabel),
$this->cancelMarkup
);
}
$this->authCodeForm = $form;
return $form;
}
/**
* Render the code input form
*
* “Please enter your authentication code to complete login”
*
* @return string
*
*/
public function ___render() {
if($this->className() == 'Tfa') {
// ask the Tfa module to render rather than the Tfa base class
$module = $this->getModule();
if($module) return $module->render();
}
// we are in a Tfa module and can render from here
$form = $this->buildAuthCodeForm();
return $form->render();
}
/**
* Process two-factor authentication code input
*
* 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() {
$input = $this->wire()->input;
$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');
$userPash = $this->sessionGet('pash');
$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->maxAttemptsLabel); // Max attempts reached
$fail = true;
} else if(!$initTime || (time() - $initTime > $this->codeExpire)) {
// more than 3 minutes have passed since authentication, so make them start over
$this->error($this->timeLimitLabel); // 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();
$codeHash = $this->getUserCodeHash($user, $code);
// at this point, a code has been submitted
$this->sessionSet('tries', ++$numTries);
// validate code
$settings = $this->getUserSettings($user);
$valid = $this->isValidUserCode($user, $code, $settings);
if($valid === true && isset($settings['last_code']) && $codeHash === $settings['last_code']) {
// do not allow same code to be reused, just in case traffic is being intercepted
// and chosen Tfa module does not already handle this case
$valid = 0;
}
if($valid === true) {
// code is validated, so do a forced login since user is already authenticated
$this->sessionReset();
$user = $session->forceLogin($user);
if($user && "$user->id" === "$userID" && $this->getUserPash($user) === $userPash) {
// code successfully validated and user is now logged in
$settings = $this->getUserSettings($user); // get fresh copy of settings
$settings['last_code'] = $codeHash;
if($this->rememberDays && $input->post('tfa_remember') == $this->rememberDays) {
if($this->remember($user, $settings)->enable()) {
// This computer/browser is now remembered for up to %d days
$this->message(sprintf($this->rememberSuccessLabel, $this->rememberDays), Notice::noGroup);
}
} else {
$this->saveUserSettings($user, $settings);
}
return $user;
} else {
// not likely for login to fail here, since they were already authenticated before
// though if their password changed between start of login and now, then this could be reached
$session->logout();
$this->sessionReset('./');
}
} else {
// failed validation
if($valid === 0) {
$this->error($this->expiredCodeLabel); // Expired code
} else {
$this->error($this->invalidCodeLabel); // Invalid code
}
// will ask them to try again
$this->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. It is only used when the user has selected
* a TFA type and submitted form, but has not yet configured the TFA type.
*
* @param User $user
* @param InputfieldWrapper $fieldset
* @param array $settings
*
*/
public function ___getUserSettingsInputfields(User $user, InputfieldWrapper $fieldset, $settings) {
$script = 'script';
// jump to and highlight fieldset they need to complete (where supported)
$fieldset->appendMarkup .=
"<$script>if(typeof jQuery !== 'undefined') jQuery(document).ready(function() {" .
"if(typeof Inputfields !== 'undefined') Inputfields.find('#$fieldset->id');" .
"});</$script>";
}
/**
* Get fields for when user already has TFA enabled
*
* This method does not need to be implemented by TFA modules unless they want to add something to it.
*
* @param User $user
* @param InputfieldWrapper $fieldset
* @param array $settings
*
*/
public function ___getUserEnabledInputfields(User $user, InputfieldWrapper $fieldset, $settings) {
$fieldset->label .= ' - ' . $this->enabledLabel;
$fieldset->icon = 'user-secret';
$fieldset->description = $this->enabledDescLabel;
$fieldset->collapsed = Inputfield::collapsedYes;
if(!empty($settings['remember'])) {
/** @var InputfieldCheckbox $f */
$f = $this->wire()->modules->get('InputfieldCheckbox');
$f->attr('name', '_tfa_clear_remember');
$f->label = $this->rememberClearLabel; // Clear remembered browsers?
$f->skipLabel = Inputfield::skipLabelHeader;
$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 ___processUserSettingsInputfields(User $user, InputfieldWrapper $fieldset, $settings, $settingsPrev) {
return $settings;
}
/**
* Called when the user config fieldset has been processed (for enabled user) 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 ___processUserEnabledInputfields(User $user, InputfieldWrapper $fieldset, $settings, $settingsPrev) {
if($this->wire()->input->post('_tfa_clear_remember')) {
unset($settings['remember']);
$this->remember($user, $settings)->disableAll();
$this->message($this->rememberClearedLabel); // Cleared remembered browsers
}
return $settings;
}
/**
* Module configuration
*
* @param InputfieldWrapper $inputfields
*
*/
public function getModuleConfigInputfields(InputfieldWrapper $inputfields) {
}
/*** 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) {
$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 false
*
*/
protected function sessionReset($redirectURL = '') {
$this->wire()->session->removeAllFor($this->keyName);
if($redirectURL) $this->redirect($redirectURL);
return false;
}
/*** AUTOMATIC ENABLE ********************************************************************************/
/**
* Does this TFA module support automatic enable?
*
* Automatic enable means a TFA module can be enabled for a user without their input.
* This can be supported by a module like TfaEmail when/if we already have the users email,
* but cannot be supported by a module like TfaTotp which requires manual setup by user.
*
* Modules that support auto-enable must implement this method to return true. Modules
* that do not support it can ignore this method, as the default returns false.
*
* @param User $user Specify user to also confirm it is supported for given user.
* Omit to test if the module supports it in general.
* @return bool
* @since 3.0.160
*
*/
public function autoEnableSupported(User $user = null) {
if($user && $this->className() !== 'Tfa') {
// if it doesn't support it without user, then exit now
if(!$this->autoEnableSupported()) return false;
// if support is present and user already has it enabled, we can assume support
$userModule = $this->get($this->userFieldName);
$userModuleName = is_object($userModule) ? $userModule->className() : $userModule;
if($userModuleName === $this->className()) return true;
// if user has some other Tfa module present, then not supported right now
}
return false;
}
/**
* Auto-enable this TFA module for given $user
*
* Automatic enable means a TFA module can be enabled for a user without their input.
*
* This method throws WireException for all error conditions, so you may want to call the
* `autoEnableSupported($user)` method first.
*
* @param User $user User to auto-enable this Tfa module for.
* @param array $settings This argument can be omitted in public API usage, but should be specified
* by Tfa modules in parent::autoEnableForUser() call, containing any needed settings.
* @throws WireException on all error conditions
* @since 3.0.160
*
*/
public function autoEnableUser(User $user, array $settings = array()) {
$moduleName = $this->className();
$userModule = $user->get($this->userFieldName);
$userModuleName = is_object($userModule) ? $userModule->className() : $userModule;
if($userModuleName === $moduleName) {
return; // already enabled for user
}
if($moduleName === 'Tfa') {
throw new WireException("This method can only be called from a Tfa module");
}
if(!$this->autoEnableSupported($user)) {
throw new WireException("Module $moduleName does not support auto-enable");
}
if($userModuleName) {
throw new WireException("User $user->name already has another Tfa module enabled ($userModuleName)");
}
if(!$user->setAndSave($this->userFieldName, $moduleName)) {
throw new WireException("Unable to save user.$this->userFieldName = '$moduleName'");
}
if(!$this->saveUserSettings($user, array_merge($settings, array('enabled' => true)))) {
throw new WireException("Uneable to save user Tfa settings");
}
}
/*** USER AND SETTINGS *******************************************************************************/
/**
* Get default/blank user settings
*
* Descending modules should implement this method.
*
* @param User $user
* @return array
*
*/
protected function getDefaultUserSettings(User $user) {
return array(
'enabled' => false, // whether user has this auth method enabled
);
}
/**
* Get TFA data for given user from user_tfa field
*
* @param User $user
* @return array
* @throws WireException
*
*/
public function getUserSettings(User $user) {
$className = $this->className();
if($className === 'Tfa') {
throw new WireException('getUserSettings should only be called from Module instance');
}
$tfaSettings = $user->get('_tfa_settings');
if(!empty($tfaSettings[$className])) return $tfaSettings[$className];
$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; // no tfa_type is selected by user
$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)) {
$tfaSettings = array($className => $defaults);
} else {
$tfaSettings = json_decode($data, true);
if(!is_array($tfaSettings)) $tfaSettings = array();
if(isset($tfaSettings[$className])) {
$tfaSettings[$className] = array_merge($defaults, $tfaSettings[$className]);
} else {
$tfaSettings[$className] = $defaults;
}
}
if(empty($moduleName)) {
$user->setQuietly('_tfa_settings', $tfaSettings);
}
return $tfaSettings[$className];
}
/**
* Save TFA data for given user to user_tfa field
*
* @param User $user
* @param array $settings
* @return bool
* @throws WireException
*
*/
public function saveUserSettings(User $user, array $settings) {
unset($settings['clear_remember']); // not needed
$className = $this->className();
if($className === 'Tfa') throw new WireException('Method may only be called from module');
if(!empty($settings[$className])) $settings = $settings[$className]; // just in case it is $tfaSettings
$tfaSettings = array($className => $settings);
$user->setQuietly('_tfa_settings', $tfaSettings);
$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($tfaSettings);
$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
*
*/
public function getUser() {
$user = null;
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 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 instanceof User || !$user->id) {
$user = $this->wire()->user;
}
return $user;
}
/**
* Get the enabled Tfa type (module name) for given user, or blank string if not enabled
*
* This method is okay to call from a base Tfa class instance, and is useful for determining
* whether the user has Tfa enabled.
*
* In many cases it is preferable to accessing $user->tfa_type directly because is doesnt
* trigger Tfa module to load and attach hooks. In addition, it accounts for the case where
* user has selected a Tfa module but has not yet configured it.
*
* #pw-internal This method may move elsewhere, so call $user->hasTfa() instead.
*
* @param User $user
* @return string|Tfa Returns Tfa module name, module instance (if required), or boolean false if Tfa not enabled for user
* @param bool|Tfa|string $getInstance Return Tfa module instance rather than name?
* Note: returned instance may not be fully initialized. If you need fully initialized, use return value of
* $user->tfa_type after getting a non-empty return value from this method.
* @since 3.0.162
*
*/
public static function getUserTfaType(User $user, $getInstance = false) {
$moduleName = '';
$module = null;
$fieldName = self::userFieldName;
$field = $user->wire()->fields->get($fieldName);
if(!$field || !$field->type) {
// Tfa not yet enabled in system
return false;
}
if($user->isLoaded($fieldName)) {
$module = $user->get($fieldName);
if(empty($module)) return false;
if(is_object($module)) {
$moduleName = $module->className();
} else {
list($moduleName, $module) = array($module, null);
}
}
if(!$module && !$moduleName) {
/** @var FieldtypeModule $fieldtype */
$fieldtype = $field->type;
$moduleID = $fieldtype->___loadPageField($user, $field);
if(!$moduleID) return false;
$moduleName = $user->wire()->modules->getModuleClass($moduleID);
if(!$moduleName) return false;
}
if(!$module) {
$module = $user->wire()->modules->getModule($moduleName, array(
'noInit' => true,
'noCache' => true,
));
}
if(!$module instanceof Tfa) return false;
$settings = $module->getUserSettings($user);
if(!$module->enabledForUser($user, $settings)) return false;
return $getInstance ? $module : $moduleName;
}
/**
* Get internal user pass hash
*
* This is used to represent a user + pass hash in a temporary session value.
* It helps to identify if a users password changed between the time they
* authenticated and the time they submitted the authentication code. While
* it seems extremely unlikely, I think we have to cover this, just in case.
*
* @param User $user
* @return string
* @since 3.0.160
*
*/
protected function getUserPash(User $user) {
$config = $this->wire()->config;
$salt1 = substr($config->userAuthSalt, -10);
$salt2 = $config->installed;
return sha1($salt1 . $user->name . $user->pass->hash . $salt2);
}
/**
* Get internal hash of given code for user
*
* This is used to identify and invalidate a previously used authentication code,
*
* @param User $user
* @param string $code
* @return string
* @since 3.0.160
*
*/
protected function getUserCodeHash(User $user, $code) {
$config = $this->wire()->config;
$salt = substr($config->userAuthSalt, 0, 9);
return sha1($code . $user->id . $salt);
}
/*** HOOKS **********************************************************************************************/
/**
* Attach/initialize hooks used by this module
*
*/
protected function initHooks() {
if($this->className() === 'Tfa') {
$this->addHookBefore('InputfieldForm::render', $this, 'hookInputfieldFormRender');
} else {
$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();
if($user->isGuest()) {
$inputfield->val(0);
return;
}
$settings = $this->getUserSettings($user);
// fieldset for TFA settings
$fieldset = new InputfieldWrapper();
$this->wire($fieldset);
$fieldset->icon = 'user-secret';
$fieldset->attr('id+name', '_tfa_settings');
if($this->enabledForUser($user, $settings)) {
$this->getUserEnabledInputfields($user, $fieldset, $settings);
} else {
$this->getUserSettingsInputfields($user, $fieldset, $settings);
}
foreach($fieldset->getAll() as $f) {
/** @var Inputfield $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 users “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();
if($user->isGuest()) {
$inputfield->val(0);
return;
}
$settingsPrev = $this->getUserSettings($user);
$settings = $settingsPrev;
$changes = array();
foreach($fieldset->getAll() as $f) {
/** @var Inputfield $f */
$name = $f->attr('name');
if(strpos($name, '_tfa_') === 0) list(,$name) = explode('_tfa_', $name);
$settings[$name] = $f->val();
}
if($this->enabledForUser($user, $settings)) {
$settings = $this->processUserEnabledInputfields($user, $fieldset, $settings, $settingsPrev);
} else {
$settings = $this->processUserSettingsInputfields($user, $fieldset, $settings, $settingsPrev);
}
foreach($settings as $name => $value) {
if(!isset($settingsPrev[$name]) || $settingsPrev[$name] !== $value) {
$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 getUserSettingsInputfields() 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;
/** @var InputfieldRadios $inputfield */
// dynamically update tfa_type label and description to runtime (multi-language)
// value but only if site has not already changed the label and/or description
if($inputfield->label === $this->defaults['fieldTfaTypeLabel']) {
$inputfield->label = $this->fieldTfaTypeLabel;
}
if($inputfield->description === $this->defaults['fieldTfaTypeDescLabel']) {
$inputfield->description = $this->fieldTfaTypeDescLabel;
}
// update Tfa type labels of options
foreach($inputfield->getOptions() as $value => $label) {
if(empty($value)) continue;
$module = $this->wire()->modules->getModule($value, array(
'noInit' => true,
'noCache' => true,
));
if(!$module instanceof Tfa) continue;
if($inputfield instanceof InputfieldRadios) {
$label = $module->getTfaTypeName() . ' - ' . $module->getTfaTypeSummary();
} else {
$label = $module->getTfaTypeTitle();
}
$inputfield->optionLabel($value, $label);
}
// if no tfa_type selection is made then we do not need to do anything futher
if(!$inputfield->val()) return;
// localize API vars
$modules = $this->wire()->modules;
$sanitizer = $this->wire()->sanitizer;
$session = $this->wire()->session;
$input = $this->wire()->input;
$user = $this->getUser();
if($user->isGuest()) {
$inputfield->val(0);
return;
}
$tfaTitle = $this->getTfaTypeTitle();
$settings = $this->getUserSettings($user);
$enabled = $this->enabledForUser($user, $settings);
/** @var InputfieldFieldset $fieldset */
$fieldset = $modules->get('InputfieldFieldset');
$fieldset->label = $tfaTitle;
$fieldset->description = $this->getTfaTypeSummary();
$fieldset->showIf = "$this->userFieldName=" . $this->className();
if($enabled) {
$this->getUserEnabledInputfields($user, $fieldset, $settings);
$session->removeFor('_user', 'requireTfa'); // set by ProcessLogin
} else {
$this->getUserSettingsInputfields($user, $fieldset, $settings);
if(!$input->requestMethod('POST')) {
$this->warning(
'<strong>' . $sanitizer->entities1($this->configureLabel) . '</strong> ' .
wireIconMarkup('angle-right') . ' ' .
"<a href='#wrap_Inputfield_tfa_type'>" . $sanitizer->entities1($tfaTitle) . "</a>",
Notice::allowMarkup | Notice::noGroup
);
}
}
$inputfield->getParent()->insertAfter($fieldset, $inputfield);
foreach($fieldset->getAll() as $f) {
/** @var Inputfield $f */
$name = $f->attr('name');
if(isset($settings[$name])) $f->val($settings[$name]);
if(strpos($name, '_tfa_') !== 0) $f->attr('name', "_tfa_$name");
}
}
/*** INSTALL AND UNINSTALL ******************************************************************************/
/**
* Module module and other assets required to execute it
*
* Please note: Tfa modules with their own install method must also call parent::___install()
*
*/
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->defaults['fieldTfaTypeLabel'];
$field->type = $this->wire()->fieldtypes->get('FieldtypeModule');
$field->flags = Field::flagSystem;
$field->description = $this->defaults['fieldTfaTypeDescLabel'];
$field->icon = 'user-secret';
$field->set('moduleTypes', array('Tfa'));
$field->set('instantiateModule', 1);
$field->set('showNoneOption', 1);
$field->set('labelField', 'title-summary');
$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
*
* Please note: Tfa modules with their own uninstall method must also call parent::___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);
}
}
}
}
/**
* Manages the “remember me” feature for Tfa class
*
* Accessed from $tfaInstance->remember($user, $settings)->method().
* This class is kept in Tfa.php because it cannot be instantiated without
* a Tfa instance.
*
* @method array getFingerprintArray($getLabels = false)
*
* #pw-internal
*
*/
class RememberTfa extends Wire {
/**
* Shows debug info in warning messages, only for development
*
*/
const debug = false;
/**
* Max browsers to remember for any user
*
*/
const maxItems = 10;
/**
* @var Tfa
*
*/
protected $tfa;
/**
* @var User|null
*
*/
protected $user = null;
/**
* @var array
*
*/
protected $settings = array();
/**
* @var array
*
*/
protected $remember = array();
/**
* Days to remember
*
* @var int
*
*/
protected $days = 90;
/**
* Means by which to fingerprint user (extras on top of random remembered cookie)
*
* Options: agent, agentVL, accept, scheme, host, ip, fwip
*
* @var array
*
*/
protected $fingerprints = array(
'agentVL',
'accept',
'scheme',
'host',
);
/**
* Construct
*
* @param User $user
* @param Tfa $tfa
* @param array $settings
*
*/
public function __construct(Tfa $tfa, User $user, array $settings) {
$this->tfa = $tfa;
$tfa->wire($this);
$this->user = $user;
$this->settings = $settings;
if(isset($settings['remember'])) $this->remember = $settings['remember'];
parent::__construct();
}
/**
* Set days to remember between logins
*
* @param int $days
*
*/
public function setDays($days) {
$this->days = (int) $days;
}
/**
* Fingerprints to use for newly created "remember" items
*
* @param array $fingerprints
*
*/
public function setFingerprints(array $fingerprints) {
$this->fingerprints = $fingerprints;
}
/**
* Save Tfa 'remember' settings
*
* @return bool
*
*/
protected function saveRemember() {
if(count($this->remember)) {
$this->settings['remember'] = $this->remember;
} else {
unset($this->settings['remember']);
}
return $this->tfa->saveUserSettings($this->user, $this->settings);
}
/**
* Set combination of user/browser/host/page as remembered and allowed to skip TFA
*
* @return bool
*
*/
public function enable() {
if(!$this->days) return false;
$rand = new WireRandom();
$this->wire($rand);
$cookieValue = $rand->alphanumeric(0, array('minLength' => 40, 'maxLength' => 256));
$qty = count($this->remember);
if($qty > self::maxItems) {
$this->remember = array_slice($this->remember, $qty - self::maxItems);
}
do {
$name = $rand->alpha(0, array('minLength' => 3, 'maxLength' => 7));
} while(isset($this->remember[$name]) || $this->getCookie($name) !== null);
$this->remember[$name] = array(
'fingerprint' => $this->getFingerprintString(),
'created' => time(),
'expires' => strtotime("+$this->days DAYS"),
'value' => $this->serverValue($cookieValue),
'page' => $this->wire()->page->id,
);
$this->debugNote("Enabled new remember: $name");
$this->debugNote($this->remember[$name]);
$result = $this->saveRemember();
if($result) $this->setCookie($name, $cookieValue);
return $result;
}
/**
* Is current user/browser/host/URL one that is remembered and TFA can be skipped?
*
* @param bool $getName Return remembered cookie name rather than true? (default=false)
* @return bool|string
*
*/
public function remembered($getName = false) {
if(!$this->days) return false;
$page = $this->wire()->page;
$fingerprint = $this->getFingerprintString();
$valid = false;
$validName = '';
$disableNames = array();
foreach($this->remember as $name => $item) {
// skip any that do not match current page
if("$item[page]" !== "$page->id") {
$this->debugNote("Skipping $name because page: $item[page] != $page->id");
continue;
}
if(!empty($item['expires']) && time() >= $item['expires']) {
$this->debugNote("Skipping $name because it has expired (expires=$item[expires])");
$disableNames[] = $name;
continue;
}
$cookieValue = $this->getCookie($name);
// skip any where cookie value isn't present
if(empty($cookieValue)) {
// if cookie not present on this browser skip it because likely for another browser the user has
$this->debugNote("Skipping $name because cookie not present");
continue;
}
// skip and remove any that do not match current browser fingerprint
if(!$this->fingerprintStringMatches($item['fingerprint'])) {
$fingerprintTypes = $this->getFingerprintTypes($item['fingerprint']);
if(!isset($fingerprintTypes['ip']) && !isset($fingerprintTypes['fwip'])) {
// if IP isn't part of fingerprint then it is okay to remove this entry because browser can no longer match
$disableNames[] = $name;
}
$this->debugNote("Skipping $name because fingerprint: $item[fingerprint] != $fingerprint");
continue;
}
// cookie found, now validate it
$valid = $item['value'] === $this->serverValue($cookieValue);
if($valid) {
// cookie is valid, now refresh it, resetting its expiration
$this->debugNote("Valid remember: $name");
$this->setCookie($name, $cookieValue);
$validName = $name;
break;
} else {
// clear because cookie value populated but is not correct
$this->debugNote("Skipping $name because cookie does not authenticate with server value");
$disableNames[] = $name;
}
}
if(count($disableNames)) $this->disable($disableNames);
return ($getName && $valid ? $validName : $valid);
}
/**
* Disable one or more cookie/remembered client by name(s)
*
* @param array|string $names
* @return int
*
*/
public function disable($names) {
if(!is_array($names)) $names = array($names);
$qty = 0;
foreach($names as $name) {
$found = isset($this->remember[$name]);
if($found) unset($this->remember[$name]);
if($this->clearCookie($name)) $found = true;
if($found) $qty++;
if($found) $this->debugNote("Disabling: $name");
}
if($qty) $this->saveRemember();
return $qty;
}
/**
* Disable all stored "remember me" data for user
*
* @return bool
*
*/
public function disableAll() {
// remove cookies
foreach($this->remember as $name => $item) {
$this->clearCookie($name);
}
// remove from user settings
$this->remember = array();
$this->debugNote("Disabled all");
return $this->saveRemember();
}
/**
* Get a "remember me" cookie value
*
* @param string $name
* @return string|null
*
*/
protected function getCookie($name) {
$name = $this->cookieName($name);
return $this->wire()->input->cookie->get($name);
}
/**
* Set the "remember me" cookie
*
* @param string $cookieName
* @param string $cookieValue
* @return WireInputData
*
*/
protected function setCookie($cookieName, $cookieValue) {
$cookieOptions = array(
'age' => ($this->days > 0 ? $this->days * 86400 : 31536000),
'httponly' => true,
'domain' => '',
);
if($this->config->https) $cookieOptions['secure'] = true;
$cookieName = $this->cookieName($cookieName);
$this->debugNote("Setting cookie: $cookieName=$cookieValue");
return $this->wire()->input->cookie->set($cookieName, $cookieValue, $cookieOptions);
}
/**
* Get cookie prefix
*
* @return string
*
*/
protected function cookiePrefix() {
$config = $this->wire()->config;
$cookiePrefix = $config->https ? $config->sessionNameSecure : $config->sessionName;
if(empty($cookiePrefix)) $cookiePrefix = 'wire';
return $cookiePrefix . '_';
}
/**
* Given name return cookie name
*
* @param string $name
* @return string
*
*/
protected function cookieName($name) {
$prefix = $this->cookiePrefix();
if(strpos($name, $prefix) !== 0) $name = $prefix . $name;
return $name;
}
/**
* Clear cookie
*
* @param string $name
* @return bool
*
*/
protected function clearCookie($name) {
$name = $this->cookiePrefix() . $name;
$cookies = $this->wire()->input->cookie;
if($cookies->get($name) === null) return false;
$cookies->set($name, null, array()); // remove
$this->debugNote("Clearing cookie: $name");
return true;
}
/**
* Given a cookie value return equivalent expected server value
*
* @param string $cookieValue
* @param User|null $user
* @return string
*
*/
protected function serverValue($cookieValue, User $user = null) {
if($user === null) $user = $this->user;
return sha1(
$user->id . $user->name . $user->email .
substr(((string) $user->pass), 0, 15) .
substr($this->wire()->config->userAuthSalt, 0, 10) .
$cookieValue
);
}
/**
* Get fingerprint of current browser, host and URL
*
* Note that this is not guaranted unique, so is only a secondary security measure to
* ensure that remember-me record is married to an agent, scheme, and http host.
*
* @return array
*
*/
public function ___getFingerprintArray() {
$config = $this->wire()->config;
$agent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : 'noagent';
$fwip = '';
if(isset($_SERVER['HTTP_X_FORWARDED_FOR'])) $fwip .= $_SERVER['HTTP_X_FORWARDED_FOR'];
if(isset($_SERVER['HTTP_CLIENT_IP'])) $fwip .= ' ' . $_SERVER['HTTP_CLIENT_IP'];
if(empty($fwip)) $fwip = 'nofwip';
$fingerprints = array(
'agent' => $agent,
'agentVL' => preg_replace('![^a-zA-Z]!', '', $agent), // versionless agent
'accept' => (isset($_SERVER['HTTP_ACCEPT']) ? $_SERVER['HTTP_ACCEPT'] : 'noaccept'),
'scheme' => ($config->https ? 'HTTPS' : 'http'),
'host' => $config->httpHost,
'ip' => (isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : 'noip'),
'fwip' => $fwip,
);
$fingerprint = array();
foreach($this->fingerprints as $type) {
$fingerprint[$type] = $fingerprints[$type];
}
$this->debugNote($fingerprint);
return $fingerprint;
}
/**
* Get fingerprint string
*
* @param array $types Fingerprints to use, or omit when creating new
* @return string
*
*/
public function getFingerprintString(array $types = null) {
if($types === null) $types = $this->fingerprints;
return implode(',', $types) . ':' . sha1(implode("\n", $this->getFingerprintArray()));
}
/**
* Does given fingerprint match one determined from current request?
*
* @param string $fpstr Fingerprint to compare
* @return bool
*
*/
protected function fingerprintStringMatches($fpstr) {
$types = $this->getFingerprintTypes($fpstr);
$fpnow = $types ? $this->getFingerprintString($types) : '';
return ($fpstr && $fpnow && $fpstr === $fpnow);
}
/**
* Get the types used in given fingerprint string
*
* @param string $fpstr
* @return array|bool
*
*/
protected function getFingerprintTypes($fpstr) {
if(strpos($fpstr, ':') === false) return false;
list($types,) = explode(':', $fpstr, 2);
$a = explode(',', $types);
$types = array();
foreach($a as $type) $types[$type] = $type;
return $types;
}
/**
* Display debug note
*
* @param string|array $note
*
*/
protected function debugNote($note) {
if(self::debug) $this->warning($note);
}
}