diff --git a/wire/core/ProcessWire.php b/wire/core/ProcessWire.php index cf78c9ae..5ef38a4b 100644 --- a/wire/core/ProcessWire.php +++ b/wire/core/ProcessWire.php @@ -19,8 +19,39 @@ require_once(__DIR__ . '/boot.php'); * * ProcessWire 3.x, Copyright 2020 by Ryan Cramer * https://processwire.com - * + * + * Default API vars (A-Z) + * ====================== + * @property AdminTheme|AdminThemeFramework|null $adminTheme + * @property WireCache $cache + * @property Config $config + * @property WireDatabasePDO $database + * @property WireDateTime $datetime + * @property Fieldgroups $fieldgroups + * @property Fields $fields + * @property Fieldtypes $fieldtypes + * @property WireFileTools $files * @property Fuel $fuel + * @property WireHooks $hooks + * @property WireInput $input + * @property Languages $languages (present only if LanguageSupport installed) + * @property WireLog $log + * @property WireMailTools $mail + * @property Modules $modules + * @property Notices $notices + * @property Page $page + * @property Pages $pages + * @property Permissions $permissions + * @property Process|ProcessPageView $process + * @property WireProfilerInterface $profiler + * @property Roles $roles + * @property Sanitizer $sanitizer + * @property Session $session + * @property Templates $templates + * @property Paths $urls + * @property User $user + * @property Users $users + * @property ProcessWire $wire * * @method init() * @method ready() @@ -164,9 +195,6 @@ class ProcessWire extends Wire { /** * Fuel manages ProcessWire API variables * - * This will replace the static $fuel from the Wire class in PW 3.0. - * Currently it is just here as a placeholder. - * * @var Fuel|null * */ @@ -446,9 +474,9 @@ class ProcessWire extends Wire { } $notices = new Notices(); + $this->wire('notices', $notices, true); // first so any API var can send notices $this->wire('urls', $config->urls); // shortcut API var - $this->wire('log', new WireLog(), true); - $this->wire('notices', $notices, true); + $this->wire('log', new WireLog(), true); $this->wire('sanitizer', new Sanitizer()); $this->wire('datetime', new WireDateTime()); $this->wire('files', new WireFileTools()); @@ -760,11 +788,20 @@ class ProcessWire extends Wire { $this->wire($key, $value, $lock); return $this; } - + + /** + * Get API var directly + * + * @param string $key + * @return mixed + * + */ public function __get($key) { if($key === 'fuel') return $this->fuel; if($key === 'shutdown') return $this->shutdown; if($key === 'instanceID') return $this->instanceID; + $value = $this->fuel->get($key); + if($value !== null) return $value; return parent::__get($key); } diff --git a/wire/core/Tfa.php b/wire/core/Tfa.php index 45794b9d..7fd7261f 100644 --- a/wire/core/Tfa.php +++ b/wire/core/Tfa.php @@ -5,7 +5,7 @@ * * This class is for “Tfa” modules to extend. See the TfaEmail and TfaTotp modules as examples. * - * ProcessWire 3.x, Copyright 2018 by Ryan Cramer + * ProcessWire 3.x, Copyright 2020 by Ryan Cramer * https://processwire.com * * @@ -36,6 +36,8 @@ * @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
element attributes * @property array $inputAttrs Code element attributes * @property string $inputLabel Label for code element @@ -43,6 +45,7 @@ * @property string $submitLabel Label for submit button * @property bool $showCancel Show a cancel link under authentication code form? (default=true) * @property string $cancelLabel Label to use for Cancel link (default='Cancel', translatable) + * @property string $rememberLabel Label for "remember this browser" option * * * @method bool start($name, $pass) @@ -86,11 +89,12 @@ class Tfa extends WireData implements Module, ConfigurableModule { * */ public function __construct() { - if(!$this->wire('fields')->get($this->userFieldName)) $this->install(); - if($this->className() != 'Tfa') $this->initHooks(); $this->set('codeExpire', 180); $this->set('startUrl', './'); - $this->set('formAttrs', array('id' => 'ProcessLoginForm')); + $this->set('rememberDays', 0); + $this->set('rememberLabel', $this->_('Remember this computer?')); + $this->set('rememberFingerprints', array('agentVL', 'accept', 'scheme', 'host')); + $this->set('formAttrs', array('id' => 'ProcessLoginForm', 'class' => 'pw-tfa')); $this->set('inputAttrs', array('id' => 'login_name', 'autofocus' => 'autofocus')); $this->set('inputLabel', $this->_('Authentication Code')); $this->set('submitAttrs', array('id' => 'Inputfield_login_submit')); @@ -100,6 +104,33 @@ class Tfa extends WireData implements Module, ConfigurableModule { parent::__construct(); } + /** + * Called when assigned to ProcessWire instance + * + */ + public function wired() { + if(!$this->wire()->fields->get($this->userFieldName)) $this->install(); + if($this->className() != 'Tfa') $this->initHooks(); + parent::wired(); + } + + /** + * 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 * @@ -132,8 +163,8 @@ class Tfa extends WireData implements Module, ConfigurableModule { * * On successful authentication of user, this method performs a redirect to the next step. * If user does not exist, they are not allowed to login, or fails to authenticate, this method - * returns a boolean false. If user authenticates but simply does not have 2FA enabled, - * then this method returns true. + * 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. @@ -153,7 +184,7 @@ class Tfa extends WireData implements Module, ConfigurableModule { $this->sessionReset(); /** @var User $user */ - $user = $this->wire('users')->get("name=" . $sanitizer->selectorValue($name)); + $user = $this->wire()->users->get('name=' . $sanitizer->selectorValue($name)); // unknown user if(!$user || !$user->id) return false; @@ -162,14 +193,24 @@ class Tfa extends WireData implements Module, ConfigurableModule { if(!$session->allowLogin($user->name, $user)) return false; // check if user exists but does not have 2FA enabled - $tfaModule = $user->get($this->userFieldName); + $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->_('Code was not required because the browser was recognized from a previous login.'), + Notice::noGroup + ); + } + return true; + } // at this point user has successfully authenticated with given name and pass if($tfaModule->startUser($user, $settings)) { @@ -255,6 +296,48 @@ class Tfa extends WireData implements Module, ConfigurableModule { return $this->wire('input')->get($this->keyName) === $this->getSessionKey(); } + /** + * Programmatically enable this TFA module for given user + * + * This can only be used if the Tfa module supports it (i.e. TfaEMail does but TfaTotp cannot). + * Returns false when not supported by the module. When a module supports it, it should include + * a parent::enableForUser() call that includes the $settings argument. + * + * @param User $user + * @param array $settings For internal use, should only be specified on a parent::setEnabledForUser() call. + * @return bool + * @since 3.0.159 + * + */ + public function enableForUser(User $user, array $settings = array()) { + $moduleName = $this->className(); + if($moduleName === 'Tfa') throw new WireException('This method may only be called on a Tfa module'); + if(empty($settings)) return false; // module lacks auto-configure support for this + $user->setAndSave($this->userFieldName, $moduleName); + $settings['enabled'] = true; + $this->saveUserSettings($user, $settings); + return true; + } + + /** + * Programmatically disable this TFA module for given user + * + * @param User $user + * @return bool + * @throws WireException + * @since 3.0.159 + * + */ + public function disableForUser(User $user) { + $moduleName = $this->className(); + if($moduleName === 'Tfa') throw new WireException('This method may only be called on a Tfa module'); + if($user->get($this->userFieldName) != $moduleName) return false; + $user->setAndSave($this->userFieldName, ''); + $settings = array('enabled' => false); + $this->saveUserSettings($user, $settings); + return true; + } + /** * Is TFA enabled for given user? * @@ -271,7 +354,7 @@ class Tfa extends WireData implements Module, ConfigurableModule { $enabled = empty($settings['enabled']) ? false : true; return $enabled; } - + /** * Returns true when TFA has successfully completed and user is now logged in * @@ -299,16 +382,30 @@ class Tfa extends WireData implements Module, ConfigurableModule { * */ public function getModule(User $user = null) { - if($user) return $user->get($this->userFieldName); + + $module = null; $moduleName = $this->sessionGet('type'); - if($moduleName) { - if($moduleName === $this->className()) return $this; - return $this->wire('modules')->getModule($moduleName); + + 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) return $user->get($this->userFieldName); + if($user) $module = $user->get($this->userFieldName); } - return null; + + if($module && !$module instanceof Tfa) $module = null; + + if($module) { + /** @var Tfa $module */ + $module->rememberDays = $this->rememberDays; + $module->rememberFingerprints = $this->rememberFingerprints; + } + + return $module; } @@ -364,6 +461,15 @@ class Tfa extends WireData implements Module, ConfigurableModule { $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'); @@ -424,7 +530,10 @@ class Tfa extends WireData implements Module, ConfigurableModule { /** @var Session $session */ $session = $this->wire('session'); - + + /** @var Config $config */ + $config = $this->wire('config'); + /** @var string|null $key */ $key = $input->get($this->keyName); @@ -465,21 +574,41 @@ class Tfa extends WireData implements Module, ConfigurableModule { // code submitted: validate code and set to blank if not valid format $form->processInput($input->post); $code = $form->getChildByName('tfa_code')->val(); + $codeHash = sha1($code . $user->id . substr($config->userAuthSalt, 0, 9)); // 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'])) { + if($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 && $user->id == $userID) { + if($user && $user->id && "$user->id" === "$userID") { // code successfully validated and user is now logged in + $settings = $this->getUserSettings($user); // get fresh + $settings['last_code'] = $codeHash; + if($this->rememberDays && $input->post('tfa_remember') == $this->rememberDays) { + if($this->remember($user, $settings)->enable()) { + $this->message( + sprintf($this->_('This computer/browser is now remembered for up to %d days.'), $this->rememberDays) . ' ' . + $this->_('Changes to browser and/or location may require a new code.'), + Notice::noGroup); + } + } else { + $this->saveUserSettings($user, $settings); + } return $user; } else { // not likely for login to fail here, since they were already authenticated before @@ -611,7 +740,7 @@ class Tfa extends WireData implements Module, ConfigurableModule { protected function getDefaultUserSettings(User $user) { if($user) {} return array( - 'enabled' => false // whether user has this auth method enabled + 'enabled' => false, // whether user has this auth method enabled ); } @@ -780,6 +909,13 @@ class Tfa extends WireData implements Module, ConfigurableModule { if(!$this->enabledForUser($user, $settings)) { $this->getUserSettingsInputfields($user, $fieldset, $settings); } + + if($this->wire()->input->post('_tfa_clear_remember')) { + unset($settings['remember']); + $changes['remember'] = 'remember'; + $this->remember($user, $settings)->disableAll(); + $this->message($this->_('Cleared remembered browsers')); + } foreach($fieldset->getAll() as $f) { $name = $f->attr('name'); @@ -896,6 +1032,15 @@ class Tfa extends WireData implements Module, ConfigurableModule { $this->_('Two factor authentication enabled!') . ' ' . $this->_('To disable or change settings, select the “None” option above and save.'); $fieldset->collapsed = Inputfield::collapsedYes; + + if(!empty($settings['remember'])) { + /** @var InputfieldCheckbox $f */ + $f = $modules->get('InputfieldCheckbox'); + $f->attr('name', '_tfa_clear_remember'); + $f->label = $this->_('Clear remembered browsers that skip entering authentication code'); + $fieldset->add($f); + } + $this->wire('session')->removeFor('_user', 'requireTfa'); // set by ProcessLogin } else { /** @var InputfieldFieldset $fieldset */ @@ -915,7 +1060,7 @@ class Tfa extends WireData implements Module, ConfigurableModule { foreach($fieldset->getAll() as $f) { $name = $f->attr('name'); if(isset($settings[$name])) $f->val($settings[$name]); - $f->attr('name', "_tfa_$name"); + if(strpos($name, '_tfa_') !== 0) $f->attr('name', "_tfa_$name"); } } @@ -1012,4 +1157,466 @@ class Tfa extends WireData implements Module, ConfigurableModule { } } +} + +/** + * 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 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 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) $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'; + + if(isset($_SERVER['HTTP_X_FORWARDED_FOR'])) { + $fwip = $_SERVER['HTTP_X_FORWARDED_FOR']; + } else if(isset($_SERVER['HTTP_CLIENT_IP'])) { + $fwip = $_SERVER['HTTP_CLIENT_IP']; + } else { + $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); + } + } \ No newline at end of file diff --git a/wire/core/WireRandom.php b/wire/core/WireRandom.php index 28457947..97402dfd 100644 --- a/wire/core/WireRandom.php +++ b/wire/core/WireRandom.php @@ -818,17 +818,7 @@ class WireRandom extends Wire { } else { // for password use, slower $rawLength = (int) ($requiredLength * 3 / 4 + 1); - - // mcrypt_create_iv - if((!$valid || $test) && function_exists('mcrypt_create_iv') && !defined('PHALANGER')) { - // @operator added for PHP 7.1 which throws deprecated notice on this function call - $buffer = @mcrypt_create_iv($rawLength, MCRYPT_DEV_URANDOM); - if($buffer) $valid = true; - if($test) $tests['mcrypt_create_iv'] = $buffer; - } else if($test) { - $tests['mcrypt_create_iv'] = ''; - } - + // PHP7 random_bytes if((!$valid || $test) && function_exists('random_bytes')) { try { @@ -842,6 +832,16 @@ class WireRandom extends Wire { $tests['random_bytes'] = ''; } + // mcrypt_create_iv + if((!$valid || $test) && function_exists('mcrypt_create_iv') && !defined('PHALANGER')) { + // @operator added for PHP 7.1 which throws deprecated notice on this function call + $buffer = @mcrypt_create_iv($rawLength, MCRYPT_DEV_URANDOM); + if($buffer) $valid = true; + if($test) $tests['mcrypt_create_iv'] = $buffer; + } else if($test) { + $tests['mcrypt_create_iv'] = ''; + } + // openssl_random_pseudo_bytes if((!$valid || $test) && function_exists('openssl_random_pseudo_bytes')) { $good = false; diff --git a/wire/modules/Process/ProcessLogin/ProcessLogin.css b/wire/modules/Process/ProcessLogin/ProcessLogin.css index bd013c34..a6327855 100644 --- a/wire/modules/Process/ProcessLogin/ProcessLogin.css +++ b/wire/modules/Process/ProcessLogin/ProcessLogin.css @@ -16,3 +16,19 @@ ul.Inputfields .InputfieldSubmit { clear: both; } +#wrap_tfa_remember { + border: none; + outline: none; + padding: 0; +} +#wrap_tfa_remember .InputfieldHeader { + display: none; +} +#wrap_tfa_remember .InputfieldContent { + padding: 0 0 20px 0; +} + +.ProcessLogin #ProcessLoginForm.tfa { + width: 300px; +} + diff --git a/wire/modules/Process/ProcessLogin/ProcessLogin.module b/wire/modules/Process/ProcessLogin/ProcessLogin.module index 8c068f5a..686248f9 100644 --- a/wire/modules/Process/ProcessLogin/ProcessLogin.module +++ b/wire/modules/Process/ProcessLogin/ProcessLogin.module @@ -15,6 +15,8 @@ * @property bool|int $allowEmail Whether or not email login is allowed (0|false=off, 1|true=Yes, 2=Yes or name also allowed) * @property string $emailField Field name used for email login (when enabled). * @property array $tfaRecRoleIDs Role IDs where admin prompts/recommends them to enable TFA. + * @property int $tfaRememberDays Allow user to remember their browser and bypass TFA for this many days (-1=no limit, 0=disabled) + * @property array $tfaRememberFingerprints Means by which to fingerprint user’s browser * * @method void beforeLogin() #pw-hooker * @method void afterLogin() #pw-hooker @@ -154,6 +156,8 @@ class ProcessLogin extends Process implements ConfigurableModule { */ public function __construct() { $this->set('tfaRecRoleIDs', array()); + $this->set('tfaRememberDays', 90); + $this->set('tfaRememberFingerprints', array('agentVL', 'accept', 'scheme', 'host')); $this->set('allowEmail', false); $this->set('emailField', 'email'); $this->customMarkup['forgot-icon'] = wireIconMarkup('question-circle', 'fw'); @@ -336,6 +340,8 @@ class ProcessLogin extends Process implements ConfigurableModule { if(count($tfas)) { $tfa = new Tfa(); $this->wire($tfa); + $tfa->rememberDays = $this->tfaRememberDays; + $tfa->rememberFingerprints = $this->tfaRememberFingerprints; } if($tfa && $tfa->active()) { @@ -735,7 +741,7 @@ class ProcessLogin extends Process implements ConfigurableModule { } $home = $this->pages->get('/'); $icon = $this->markup('home-icon'); - $label = $home->getFormatted('title'); + $label = $this->wire()->sanitizer->entities($home->getUnformatted('title')); $links['home'] = str_replace( array('{url}', '{out}'), array($home->url, "$icon $label"), @@ -953,6 +959,40 @@ class ProcessLogin extends Process implements ConfigurableModule { } $f->attr('value', $this->get('tfaRecRoleIDs')); $fieldset->add($f); + + /** @var InputfieldInteger $f */ + $f = $modules->get('InputfieldInteger'); + $f->attr('name', 'tfaRememberDays'); + $f->label = $this->_('Allow users the option to skip code entry when their browser/location is remembered?'); + $f->description = $this->_('Enter the number of days that browser/location will be remembered or 0 to disable.'); + $f->attr('value', (int) $this->tfaRememberDays); + $fieldset->add($f); + + $fingerprints = array( + 'agent' => $this->_('User agent (browser, platform, and versions of each)'), + 'agentVL' => $this->_('Non-versioned user agent (browser and platform, but no versions—less likely to change often)'), + 'accept' => $this->_('Accept header (content types user’s browser accepts)'), + 'scheme' => $this->_('Current request scheme whether HTTP or HTTPS'), + 'host' => $this->_('Server hostname (value of $config->httpHost)'), + 'ip' => $this->_('User’s IP address (REMOTE_ADDR)'), + 'fwip' => $this->_('User’s forwarded or client IP address (HTTP_X_FORWARDED_FOR or HTTP_CLIENT_IP)'), + ); + + /** @var InputfieldCheckboxes $f */ + $f = $modules->get('InputfieldCheckboxes'); + $f->attr('name', 'tfaRememberFingerprints'); + $f->label = $this->_('Do not allow user to skip code entry when any of these properties change'); + $f->description = + $this->_('Changes to password, name, email, or a random cookie in the user’s browser, will always require code entry at login.') . ' ' . + $this->_('In addition, changes to any checked items below will also require code entry at login.') . ' ' . + $this->_('These properties form a fingerprint of the user’s browser beyond the random cookie that we set.'); + $f->notes = $this->_('This setting only applies when the option to remember browser/location is enabled.'); + foreach($fingerprints as $name => $label) { + $f->addOption($name, $label); + } + $f->showIf = 'tfaRememberDays!=0'; + $f->attr('value', $this->tfaRememberFingerprints); + $fieldset->add($f); } else { $fieldset->description = $this->_('To configure this you must first install one or more Tfa modules and then return here.');