Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | Download
<?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 2020 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 doesn’t 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) {$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 userif(!$user || !$user->id) return false;// check if user is not allowed to loginif(!$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 authenticateif(!$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 passif($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($user && $code && $settings) throw new WireException('Modules should not call this method');return false;}/*** 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) {if($user) {} // ignore$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 occurif($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) {$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;/** @var Modules $modules */$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) {$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->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() {/** @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, abortif(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 setif($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 validationif($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) {if($user || $fieldset || $settings) {} // ignore$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) {if($user) {} // ignore$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) {if($user || $fieldset || $settings || $settingsPrev) {} // ignorereturn $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($user || $fieldset || $settings || $settingsPrev) {} // ignoreif($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) {if($inputfields) {} // ignore}/*** 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->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 user’s 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 nowif(!$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) {if($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 pageif(!$process || $process == 'ProcessPageView') {$page = $this->wire()->page;$process = $page->get('process');}// check if we have a processif($process && $process instanceof Process) {// user being edited like in ProcessUser, ProcessProfile or ProcessPageEditif($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 userif(!$user || !$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 doesn’t* 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 systemreturn 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 || !$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 user’s 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) {$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 settingereturn;}/** @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) {$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] !== $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 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 descriptionif($inputfield->label === $this->defaults['fieldTfaTypeLabel']) {$inputfield->label = $this->fieldTfaTypeLabel;}if($inputfield->description === $this->defaults['fieldTfaTypeDescLabel']) {$inputfield->description = $this->fieldTfaTypeDescLabel;}// update Tfa type labels of optionsforeach($inputfield->getOptions() as $value => $label) {if(empty($value)) continue;$module = $this->wire()->modules->getModule($value, array('noInit' => true,'noCache' => true,));if(!$module || !$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 futherif(!$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 {/** @var InputfieldFieldset $fieldset */$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) {$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 fieldgroupsforeach($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 fieldgroupsforeach($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 pageif("$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 presentif(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 fingerprintif(!$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 bool**/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 cookiesforeach($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) $options['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() {$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' => ($this->config->https ? 'HTTPS' : 'http'),'host' => $this->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);}}