diff --git a/wire/modules/Process/ProcessForgotPassword.module b/wire/modules/Process/ProcessForgotPassword.module index 87c17e93..db9a936e 100644 --- a/wire/modules/Process/ProcessForgotPassword.module +++ b/wire/modules/Process/ProcessForgotPassword.module @@ -9,8 +9,28 @@ * For more details about how Process modules work, please see: * /wire/core/Process.php * - * ProcessWire 3.x, Copyright 2017 by Ryan Cramer + * ProcessWire 3.x, Copyright 2018 by Ryan Cramer * https://processwire.com + * + * @property bool|int $allowReset Allow passwords to be reset? + * @property string $table DB table + * @property string $emailFrom Send emails from this address + * @property string $emailSubject Subject line for email that is sent (default='Password Reset Information') + * @property bool|int $askEmail Ask user for their email address rather than username? (default=false) + * @property int $maxPerIP Max concurrent reset requests per IP address per hour or session. (default=3) + * @property int $expireSecs Seconds after which requests expire. (default=3600) + * @property bool|int $beHonest Be honest about whether account exists or not? More helpful but less secure when true. (default=false) + * @property bool|int $useInlineNotices Render errors inline (rather than as notices)? (default=false) + * @property bool|int $useLog Log activity? (default=true) + * @property array $confirmFields Extra field values to confirm values before accepting password change, optional. (default=['email']) + * @property array $allowRoles Only allow password reset for these roles, optional. (default=[]) + * @property array $blockRoles Block these roles, optional. (default=[]) + * + * @method string renderEmailBody($url, $code, $html) Render the password reset email body, and $url should appear in that email body. + * @method string renderErrorEmailBody($error) Render error email body + * @method string renderContinue($url = './', $label = '') Render a continue link + * @method string renderError($str) Render an error (when useInlineNotices is true) + * @method string renderMessage($str) Render a message (when useInlineNotices is true) * * */ @@ -21,21 +41,63 @@ class ProcessForgotPassword extends Process implements ConfigurableModule { return array( 'title' => __('Forgot Password', __FILE__), // getModuleInfo title 'summary' => __('Provides password reset/email capability for the Login process.', __FILE__), // getModuleInfo summary - 'version' => 102, + 'version' => 103, 'permanent' => false, 'permission' => 'page-view', ); } + /** + * Table to store password reset requests + * + */ + const table = 'process_forgot_password'; + + /** + * Debug/development mode (NOT SAFE for production use) + * + */ + const debug = false; + + /** + * Errors for when useInlineNotices option active + * + * @var array + * + */ + protected $inlineErrors = array(); + + /** + * User to indicate in log entries + * + * @var User|null + * + */ + protected $logUser = null; + /** * Setup default values * */ public function __construct() { - // allow passwords to be reset? + parent::__construct(); + $this->set('allowReset', 1); $this->set('table', 'process_forgot_password'); - $this->set('emailFrom', ''); + $this->set('emailFrom', ''); + $this->set('emailSubject', $this->_("Password Reset Information")); // Email subject + $this->set('askEmail', false); + $this->set('maxPerIP', 3); + $this->set('expireSecs', 3600); + $this->set('beHonest', false); + $this->set('useInlineNotices', false); + $this->set('useLog', true); + $this->set('confirmFields', array()); + $this->set('allowRoles', array()); + $this->set('blockRoles', array()); + + $emailField = $this->wire('fields')->get('email'); + if($emailField) $this->set('confirmFields', array("email:$emailField->id")); } /** @@ -43,52 +105,72 @@ class ProcessForgotPassword extends Process implements ConfigurableModule { * */ public function ___execute() { + + /** @var WireInput $input */ + $input = $this->wire('input'); + $out = ''; + + $errors = array( + 'fail' => $this->_("Unable to complete password reset. Please make sure you are on the same computer and in the same web browser that you originally submitted your request from."), + 'off' => $this->_("Password reset temporarily not available. Please try again later or contact the admin."), + ); + $this->headline($this->_('Reset Password')); // Reset password page headline + + // password reset not applicable to logged-in users if($this->user->isLoggedin()) return ''; - - $this->wire('processHeadline', $this->_('Reset Password')); // Reset password page headline - - if(!$this->allowReset) { - $this->error($this->_("Password reset is not allowed")); - return ''; - } - + $this->setupResetTable(); - if( $this->input->post->username && - $this->input->post->submit_forgot && - $this->session->userResetStep === 1) { + if($this->allowResetRequest()) { + + $step = (int) $this->sessionGet('step'); - // step 2 - - return $this->step2_processForm(); - - } else if( - $this->input->get->token && - $this->input->get->user_id) { - - // steps 3 and 4 - - if($this->session->userResetStep >= 2 && $this->session->userResetID === (int) $this->input->get->user_id) { - return $this->step3_processEmailClick(); + if($step === 1 && $input->post('username') && $input->post('submit_forgot')) { + // process step 1 and prepare step 2 + // process form containing username/email of account to reset passwor for + $out = $this->step2_processForm(); + } else if($input->get('t') && $input->get('u')) { + // process step 2, prepare and proces steps 3 and 4 + // user has clicked link from email OR submitted form that follows + if($step >= 2) { + $out = $this->step3_processEmailClick(); + } else { + // expired or required session data isn't present + $this->error($errors['fail']); + } } else { - $this->error($this->_("Unable to complete password reset. Please make sure you are on the same computer and in the same web browser that you originally submitted your request from.")); - $this->session->redirect("./?forgot=1"); + // prepare and render form for step 1 + $out = $this->step1_renderForm(); } - + } else { - - // step 1 - - return $this->step1_renderForm(); + $this->error($errors['off']); } - return ''; + if(empty($out)) $this->deleteReset(); + + if(count($this->inlineErrors)) { + $errors = ''; + foreach($this->inlineErrors as $error) { + $errors .= $this->renderError($error); + } + if(empty($out)) $out = $this->renderContinue(); + $out = $errors . $out; + } else if(empty($out)) { + $this->wire('session')->redirect('./'); + } + + if($out) $out = "
$out
"; + + return $out; } /** * Render forgot password form + * + * @return string * */ protected function step1_renderForm() { @@ -99,28 +181,27 @@ class ProcessForgotPassword extends Process implements ConfigurableModule { $form->attr('method', 'post'); /** @var InputfieldText $field */ - $field = $this->modules->get("InputfieldText"); + if($this->askEmail) { + $field = $this->modules->get("InputfieldEmail"); + $field->label = $this->_("Enter your email address"); + $field->icon = 'envelope-o'; + } else { + $field = $this->modules->get("InputfieldText"); + $field->label = $this->_("Enter your user name"); + $field->icon = 'user-circle-o'; + } $field->attr('id+name', 'username'); - $field->required = true; - $field->label = $this->_("Enter your user name"); + $field->required = true; $field->description = $this->_("If you have an account in our system with a valid email address on file, an email will be sent to you after you submit this form. That email will contain a link that you may click on to reset your password."); + $field->collapsed = Inputfield::collapsedNever; $form->add($field); - /* - $field = $this->modules->get("InputfieldEmail"); - $field->attr('id+name', 'useremail'); - $field->label = $this->_('Forgot your username?'); - $field->collapsed = Inputfield::collapsedYes; - $field->description = $this->_('Enter your email address and we will send you your account name.'); - $form->add($field); - */ - /** @var InputfieldSubmit $submit */ $submit = $this->modules->get("InputfieldSubmit"); $submit->attr('id+name', 'submit_forgot'); $form->add($submit); - $this->session->userResetStep = 1; + $this->sessionSet('step', 1); return $form->render(); } @@ -129,111 +210,216 @@ class ProcessForgotPassword extends Process implements ConfigurableModule { * Process the form submitted from step1 with username or email * * If it matches up to an account in the system, then send them an email. + * + * @return string * */ protected function step2_processForm() { - + + /** @var Sanitizer $sanitizer */ + $sanitizer = $this->wire('sanitizer'); + /** @var WireInput $input */ + $input = $this->wire('input'); + /** @var Users $users */ + $users = $this->wire('users'); + /** @var User $user */ $user = null; - $name = $this->sanitizer->pageNameUTF8($this->input->post->username); - if(strlen($name)) { - /** @var User $user */ - $user = $this->users->get("name=" . $this->sanitizer->selectorValue($name)); - if($user && $user->id && $user->email && !$user->isUnpublished()) { - if($this->wire('session')->allowLogin($name, $user)) { - // user was found, send them an email with reset link - $this->step2_sendEmail($user); + $name = ''; + + // track quantity of submitted requests in session qty variable + $this->trackNewRequest(); + + if($this->askEmail) { + // user enters their email address + $email = $sanitizer->email($input->post('username')); + if(strlen($email)) { + $items = $users->find('email=' . $sanitizer->selectorValue($email) . ', include=all'); + if($items->count() > 1) { + if($this->beHonest) $this->error( + $this->_('There are multiple accounts with this email, so password reset is not possible.') . ' ' . + $this->_('Please contact administrator to reset your password.') + ); + $this->log("Fail for: $email - multiple accounts use this address"); + + } else if($items->count() == 1) { + $user = $items->first(); + $name = $user->name; + if(strtolower($user->email) !== strtolower($email)) $user = false; + } else { - $this->error($this->_('Specified account is not allowed to login so password may not be reset.')); - $this->wire('session')->redirect('./'); + // email not found + $this->log("Fail for '$email', no user matching this email"); + } + } + } else { + // user enters their username + $name = $sanitizer->pageNameUTF8($input->post('username')); + if(strlen($name)) { + $user = $users->get('name=' . $sanitizer->selectorValue($name)); + if(!$user || !$user->id || $user->name !== $name) { + $this->log("Fail for '$name', user not found"); + $user = false; } } } - - $out = - "

" . $this->_("Assuming your account information was found and we have an email address on file, an email was dispatched with password reset information.") . "*

" . - "

" . $this->_("Please check your email for this message. If you do not receive an email within the next 15 minutes please contact the site administrator to reset your password. This password reset request will expire in 60 minutes. Do NOT close this window until you have completed your password reset request.") . "

" . - "

*" . $this->_('For security reasons, we do not reveal whether an account exists on this screen.') . "

"; - - /* - $email = $this->sanitizer->email($this->input->post->useremail); - if(strlen($email)) { - $users = $this->users->find("include=all, email=" . strtolower($this->sanitizer->selectorValue($email))); - $subject = $this->_('Account Information'); - $body = $this->_('You are receiving this email because you requested your account name.') . ' '; - if(count($users) > 1) { - $body .= $this->_('Your email address appears to be associated with multiple accounts and we cannot reveal those for security reasons. Please contact the administrator for assistance.'); + + if($name && $user) { + $reason = ''; + if($user->id) $this->logUser = $user; + if($this->allowUser($user, $reason)) { + // user was found, insert new reset request + $token = $this->insertNewResetRequest($user); } else { - $user = $users->first(); - if($user && strtolower($user->email) === $email) { - $body .= $this->_('Your account name is:') . ' ' . $user->name; + $this->log("Fail: $reason"); + if($this->beHonest) $this->error($this->_('User is not allowed to reset password.') . ' ' . $reason); + if($user->email) $this->step2_sendErrorEmail($user, $reason); + $token = false; + } + if(!empty($token)) { + // send them an email with reset link + if($this->step2_sendEmail($user, $token)) { + $this->log("Request email sent to: $user->email"); + } else { + $this->log("Fail email to: $user->email"); + $token = false; } } - - } - */ + } else { + // no user found, error has already been logged, fail silently + // quietly delete anything saved so far, as this is an invalid reset request + $token = false; + } + + if(empty($token)) { + // if no token, then this was a failed reset request + $this->deleteReset($user ? $user->id : 0); + // if we're being honest, don't show the message at the bottom of this function and just return blank + if($this->beHonest) return ''; + } - return $out; + $out = + "

" . + $this->_("Assuming your account information was found and we have an email address on file, an email was dispatched with password reset information.") . + ($this->beHonest ? '' : '*') . + "

" . + "

" . + $this->_("Please check your email for this message. If you do not receive an email within the next 15 minutes please contact the site administrator to reset your password. This password reset request will expire in 60 minutes. Do NOT close this window until you have completed your password reset request.") . + "

"; + + if(!$this->beHonest) { + $out .= + "

*" . + $this->_('For security reasons, we do not reveal whether an account exists on this screen.') . + "

"; + } + + return $out; } /** * Send an email with password reset link to the given User account * * @param User $user + * @param string $token + * @return bool|int * */ - protected function step2_sendEmail(User $user) { + protected function step2_sendEmail(User $user, $token) { - $subject = $this->_("Password Reset Information"); // Email subject - - // create the unique verification token that is stored on the server and sent in the email - $token = md5(mt_rand() . $user->name . $user->id . microtime() . mt_rand()); - - // set some session vars we'll use for comparison - $this->session->userResetStep = 2; - $this->session->userResetID = $user->id; - $this->session->userResetName = $user->name; - - $url = $this->page->httpUrl() . "?forgot=1&user_id={$user->id}&token=" . urlencode($token); - - $body = $this->_("To complete your password reset, click the URL below (or paste into your browser) and follow the instructions:") . "\n\n"; // Email body part 1 - $body .= $url . "\n\n"; - $body .= $this->_("This URL will expire 60 minutes from time it was sent. This URL must be opened from the same computer and browser that the request was initiated from."); // Email body part 2 + $verify = $this->sessionGet('verify'); + $url = $this->page->httpUrl() . + "?forgot=1" . + "&u={$user->id}" . + "&t=" . urlencode($token); - $emailFrom = $this->emailFrom; - if(!$emailFrom) $emailFrom = $this->wire('config')->adminEmail; - if(!$emailFrom) $emailFrom = 'processwire@' . $this->config->httpHost; + $body = $this->renderEmailBody($url, $verify, false); + $bodyHTML = $this->renderEmailBody($url, $verify, true); + + // if(self::debug) $this->message($bodyHTML, Notice::allowMarkup); + + $email = $this->wire('mail')->new(); + $email->to($user->email)->from($this->getEmailFrom()); + $email->subject($this->emailSubject)->body($body)->bodyHTML($bodyHTML); - if($this->wire('mail')->send($user->email, $emailFrom, $subject, $body)) { + return $email->send(); + } - // for informational/debugging purposes - $ip = preg_replace('/[^\d.]/', '', $_SERVER['REMOTE_ADDR']); + /** + * Send email to user notifying them why they cannot reset password + * + * @param User $user + * @param $error + * @return bool|int + * + */ + protected function step2_sendErrorEmail(User $user, $error) { + $body = $this->renderErrorEmailBody($error); + if(self::debug) $this->message("

" . nl2br($body) . "

", Notice::allowMarkup); + $email = $this->wire('mail')->new(); + $email->to($user->email)->from($this->getEmailFrom()); + $email->subject($this->emailSubject)->body($body); + return $email->send(); + } - // clear space for this reset request, since there can only be one active for any given user - $database = $this->wire('database'); - $table = $database->escapeTable($this->table); - - try { - - $query = $database->prepare("DELETE FROM `{$table}` WHERE id=:id"); - $query->bindValue(":id", (int) $user->id, \PDO::PARAM_INT); - $query->execute(); - - $query = $database->prepare("INSERT INTO `{$table}` SET id=:id, name=:name, token=:token, ts=:ts, ip=:ip"); - $query->bindValue(":id", $user->id, \PDO::PARAM_INT); - $query->bindValue(":name", $user->name); - $query->bindValue(":token", $token); - $query->bindValue(":ts", time(), \PDO::PARAM_INT); - $query->bindValue(":ip", $ip); - $query->execute(); - - } catch(\Exception $e) { - // catch any errors, just to prevent anything from ever being reported to screen - $this->session->removeNotices(); - $this->errors('all clear'); - $this->error($this->_('Unable to complete this step')); - return; - } + /** + * Render the password reset email body + * + * This function is called twice, once for plain text and once for HTML. + * + * #pw-hooker + * + * @param string $url + * @param string $code + * @param bool $html Render in HTML? + * @return string + * + */ + protected function ___renderEmailBody($url, $code, $html = false) { + + if($html) { + $p = "

"; + $_p = "

\n\n"; + $code = "$code"; + $url = "$url"; + } else { + $p = ""; + $_p = "\n\n"; } + + $out = + $p . + sprintf($this->_('Your verification code is: %s'), $code) . + $_p . $p . + $this->_("To complete your password reset, click the URL below (or paste into your browser) and follow the instructions:") . // Email body part 1 + $_p . $p . $url . $_p . $p . + $this->_("This URL will expire 60 minutes from time it was sent. This URL must be opened from the same computer and browser that the request was initiated from.") . // Email body part 2 + $_p; + + if($html) { + $out = "$out"; + } + + return $out; + } + + /** + * Render the error email body + * + * This function is called twice, once for plain text and once for HTML. + * + * #pw-hooker + * + * @param string $error Error message + * @return string + * + */ + protected function ___renderErrorEmailBody($error) { + return + sprintf($this->_('You requested a password reset for your account at %s.'), $this->wire('config')->httpHost) . ' ' . + $this->_('The system is unable to complete this request for the reason listed below:') . + "\n\n$error\n\n" . + $this->_('Please contact the administrator for assistance with logging in to your account and/or changing your password.') . + "\n\n"; } /** @@ -242,61 +428,101 @@ class ProcessForgotPassword extends Process implements ConfigurableModule { * If valid, display form with new password entries. * * If form submitted, send to step 4. + * + * @return string * */ protected function step3_processEmailClick() { - - $id = (int) $this->input->get->user_id; - $token = $this->input->get->token; + + /** @var WireInput $input */ + $input = $this->wire('input'); + /** @var WireDatabasePDO $database */ $database = $this->wire('database'); - $table = $database->escapeTable($this->table); + + $id = (int) $input->get('u'); + $token = $input->get('t'); + $row = false; - $query = $database->prepare("SELECT name, token, ip FROM `$table` WHERE id=:id"); - $query->bindValue(":id", $id); - $query->execute(); - $row = $query->fetch(\PDO::FETCH_ASSOC); - - if($row && $id == $this->session->userResetID) { + if(strlen($token)) { + $query = $database->prepare("SELECT name, token, ip FROM `" . self::table . "` WHERE id=:id"); + $query->bindValue(":id", $id); + $query->execute(); - if( $row['token'] && ($row['token'] === $token) && - $row['name'] === $this->session->userResetName) { - - // all conditions good - user may reset their password - - $form = $this->step3_buildForm($id, $token); - - if($this->input->post->submit_reset && $this->session->userResetStep === 3) { - $out = $this->step4_completeReset($id, $form); - - } else { - $this->session->userResetStep = 3; - $out = $form->render(); - } - - return $out; - } + $row = $query->fetch(\PDO::FETCH_ASSOC); + $query->closeCursor(); } - $this->error($this->_("Invalid reset request. Your request may have expired.")); - - return "

" . $this->_("Continue") . "

"; + if($row + && ($id && $id === (int) $this->sessionGet('id')) + && (!empty($row['token']) && $row['token'] === $token) + && (!empty($row['name']) && $row['name'] === $this->sessionGet('name'))) { + + // all conditions good, user may reset their password + $form = $this->step3_buildForm($id, $token); + if($input->post('submit_reset') && $this->sessionGet('step') === 3) { + $this->sessionSet('step', 4); + $out = $this->step4_completeReset($id, $form); + } else { + $this->sessionSet('step', 3); + $out = $form->render(); + } + + } else { + $this->error($this->_("Invalid reset request. Your request may have expired.")); + $this->deleteReset($id, $token); + $out = ''; + } + return $out; } /** * Build the form with the reset password field * - * @param string $id + * @param int $id * @param string $token * @return InputfieldForm * */ protected function step3_buildForm($id, $token) { + + $id = (int) $id; + $token = urlencode($token); /** @var InputfieldForm $form */ $form = $this->modules->get("InputfieldForm"); $form->attr('method', 'post'); - $form->attr('action', "./?forgot=1&user_id=$id&token=$token"); + $form->attr('action', "./?forgot=1&u=$id&t=$token"); + + $f = $this->wire('modules')->get('InputfieldText'); + $f->attr('name', 'verify'); + $f->label = $this->_('Verification Code'); + $f->description = $this->_('Please type or paste in the code you received in your email.'); + $f->required = true; + $f->attr('required', 'required'); + $form->add($f); + + $confirmFields = array(); + foreach($this->confirmFields as $key => $fieldName) { + /** @var Fieldgroup $fieldgroup */ + $fieldgroup = $this->wire('templates')->get('user')->fieldgroup; + if(strpos($fieldName, ':') === false) { + $field = $fieldgroup->getFieldContext($fieldName); + } else { + list($fieldName, $fieldID) = explode(':', $fieldName); + $field = $fieldgroup->getFieldContext($fieldName); + if(!$field) $field = $fieldgroup->getFieldContext((int) $fieldID); + } + if(!$field) continue; + $f = $field->getInputfield(new NullPage(), $field); + $f->attr('name', $field->name); + $f->collapsed = Inputfield::collapsedNever; + $f->columnWidth = 100; + $f->notes = $this->_('Resetting password also requires that you confirm the correct value of this field.'); + $form->add($f); + $confirmFields[$key] = $field->name; + } + $this->confirmFields = $confirmFields; // normalized to only known field names /** @var Field $field */ $field = $this->wire('fields')->get('pass'); @@ -326,38 +552,165 @@ class ProcessForgotPassword extends Process implements ConfigurableModule { */ protected function step4_completeReset($id, $form) { - $form->processInput($this->input->post); + /** @var WireInput $input */ + $input = $this->wire('input'); + $form->processInput($input->post); + $attempts = (int) $this->sessionGet('attempts'); + $this->sessionSet('attempts', ++$attempts); + $numErrors = 0; + if($attempts > 3) { + $this->error($this->_('Exceeded max allowed attempts for this form.')); + return $this->deleteResetAndRedirect(); + } + + $f = $form->getChildByName('verify'); + $verify = $f->attr('value'); + if(empty($verify) || $verify !== $this->sessionGet('verify')) { + $f->error($this->_('Incorrect verification code')); + $numErrors++; + } + /** @var User $user */ - $user = $this->users->get((int) $id); - $pass = $form->get('pass')->value; + $user = $this->wire('users')->get((int) $id); + + foreach($this->confirmFields as $fieldName) { + $f = $form->getChildByName($fieldName); + if(!$f) continue; + $fv = $f->attr('value'); + $uv = $user->get($fieldName); + if(empty($fv) && empty($uv)) continue; + if(is_string($fv) && is_string($uv)) { + $fv = strtolower($fv); + $uv = strtolower($uv); + } + if($fv === $uv) continue; + $f->error($this->_('Entered value was not correct')); + $numErrors++; + } - if(count($form->getErrors()) || !$user->id || !$pass) return $form->render(); + $pass = $form->getChildByName('pass')->attr('value'); + + if($numErrors || count($form->getErrors()) || !$user->id || !strlen($pass)) { + $this->wire('session')->redirect($form->attr('action')); + return ''; + } $outputFormatting = $user->outputFormatting; $user->setOutputFormatting(false); $user->pass = $pass; $user->save(); $user->setOutputFormatting($outputFormatting); - - $this->session->message($this->_("Your password has been successfully reset. You may now login.")); - - $this->session->remove('userResetStep'); - $this->session->remove('userResetID'); - $this->session->remove('userResetName'); + + $this->log("Completed password reset for user $user->name ($user->email)"); + $message = $this->_("Your password has been successfully reset. You may now login."); + + if($this->useInlineNotices) { + $this->deleteReset($user->id); + return $this->renderMessage($message) . $this->renderContinue(); + } else { + $this->message($message); + return $this->deleteResetAndRedirect($user->id); + } + } + + /** + * Insert new password reset request for valid user and get token to identify request + * + * @param User $user + * @return array|bool Returns new token or boolean false on error + * + */ + protected function insertNewResetRequest($user) { /** @var WireDatabasePDO $database */ $database = $this->wire('database'); - $table = $database->escapeTable($this->table); - $query = $database->prepare("DELETE FROM `$table` WHERE id=:id"); - $query->bindValue(":id", $user->id, \PDO::PARAM_INT); - $query->execute(); - - $this->session->redirect("./"); - - return ''; + + // create the unique verification token that is stored on the server and sent in the email + $pass = new Password(); + $token = $pass->randomBase64String(32); + $ip = $this->wire('session')->getIP(); + + if(!strlen($token)) return false; + + // set some session vars we'll use for later comparison + $this->sessionSet('step', 2); + $this->sessionSet('id', $user->id); + $this->sessionSet('name', $user->name); + + try { + // clear space for this reset request, since there can only be one active for any given user + $query = $database->prepare("DELETE FROM `" . self::table . "` WHERE id=:id"); + $query->bindValue(":id", (int) $user->id, \PDO::PARAM_INT); + $query->execute(); + + // insert new password reset request + $query = $database->prepare("INSERT INTO `" . self::table . "` SET id=:id, name=:name, token=:token, ts=:ts, ip=:ip"); + $query->bindValue(":id", $user->id, \PDO::PARAM_INT); + $query->bindValue(":name", $user->name); + $query->bindValue(":token", $token); + $query->bindValue(":ts", time(), \PDO::PARAM_INT); + $query->bindValue(":ip", $ip); + $query->execute(); + + } catch(\Exception $e) { + // catch any errors, just to prevent anything from ever being reported to screen + $this->wire('session')->removeNotices(); + $this->errors('all clear'); + $this->error($this->_('Unable to complete this step')); + $this->deleteReset(); + $token = false; + } + + return $token; } + + /** + * Delete reset record from database for given user ID + * + * @param int $id + * @param string|null $token Optionally delete for token too (just in case) + * + */ + protected function deleteReset($id = 0, $token = null) { + + /** @var WireDatabasePDO $database */ + $database = $this->wire('database'); + + if(!$id) $id = (int) $this->sessionGet('id'); + + /** @var Session $session */ + $this->sessionRemove('step'); + $this->sessionRemove('name'); + $this->sessionRemove('id'); + $this->sessionRemove('verify'); + $this->sessionRemove('attempts'); + + if($id || $token !== null) { + $sql = "DELETE FROM `" . self::table . "` WHERE id=:id"; + if($token !== null) $sql .= ' OR token=:token'; + $query = $database->prepare($sql); + $query->bindValue(":id", $id, \PDO::PARAM_INT); + if($token !== null) $query->bindValue(':token', $token); + $query->execute(); + } + } + + /** + * Like deleteReset() but also redirects the request out of here + * + * @param int $id + * @param null|string $token + * @return string Blank string + * + */ + protected function deleteResetAndRedirect($id = 0, $token = null) { + $this->deleteReset($id, $token); + $this->wire('session')->redirect('./'); + return ''; + } + /** * Create the process_forgot_password table if it doesn't exist * @@ -366,12 +719,11 @@ class ProcessForgotPassword extends Process implements ConfigurableModule { */ protected function setupResetTable() { - // create the DB table if it's not there already + /** @var WireDatabasePDO $database */ $database = $this->wire('database'); - $table = $database->escapeTable($this->table); try { - $query = $database->prepare("SHOW COLUMNS FROM `$table`"); + $query = $database->prepare("SHOW COLUMNS FROM `" . self::table . "`"); $query->execute(); } catch(\Exception $e) { $query = false; @@ -380,10 +732,257 @@ class ProcessForgotPassword extends Process implements ConfigurableModule { if(!$query || !$query->rowCount()) $this->install(); // delete table entries that are older than one hour - $query = $database->prepare("DELETE FROM `$table` WHERE ts<:ts"); - $query->bindValue(":ts", time()-3600, \PDO::PARAM_INT); + $query = $database->prepare("DELETE FROM `" . self::table . "` WHERE ts<:ts"); + $query->bindValue(":ts", time() - $this->expireSecs, \PDO::PARAM_INT); $query->execute(); + } + + /** + * Set session value + * + * @param string $key + * @param string|int $value + * + */ + protected function sessionSet($key, $value) { + $this->wire('session')->setFor($this, $key, $value); + } + + /** + * Get session value + * + * @param string $key + * @return string|int|null + * + */ + protected function sessionGet($key) { + return $this->wire('session')->getFor($this, $key); + } + + /** + * Remove a session value + * + * @param string $key + * + */ + protected function sessionRemove($key) { + $this->wire('session')->removeFor($this, $key); + } + + /** + * Allow given user to reset password? + * + * @param User|Page|NullPage $user + * @param string $reason Reason why not allowed (if false is returned) + * @return bool + * + */ + protected function allowUser(Page $user, &$reason) { + + $reason = ''; + + if(!$user->id) { + $reason = $this->_('User does not exist'); + return false; + } else if(!$user->email) { + $reason = $this->_('User has no email address defined'); + return false; + } else if($user->isUnpublished()) { + $reason = $this->_('User is unpublished'); + return false; + } else if(!$this->wire('session')->allowLogin($user->name, $user)) { + $reason = $this->_('User is not allowed to login per site configuration'); + return false; + } + + $allow = true; + + foreach(array('allowRoles', 'blockRoles') as $type) { + $roles = array(); + + foreach($this->get($type) as $roleName) { + $roleID = 0; + if(strpos($roleName, ':')) list($roleName, $roleID) = explode(':', $roleName, 2); + $role = $this->wire('roles')->get($roleName); + if(!$role || !$role->id) $role = $this->wire('roles')->get((int) $roleID); + if(!$role || !$role->id) continue; + $roles[] = $role; + } + + if(!count($roles)) continue; + + $hasRole = false; + + foreach($roles as $role) { + if($user->hasRole($role)) { + $hasRole = $role; + break; + } + } + + if($type === 'allowRoles' && !$hasRole) { + $reason = $this->_('User does not have a role supported for password reset.'); + $allow = false; + } + + if($type === 'blockRoles' && $hasRole) { + $reason = $this->_('User has a role that does not support password reset.'); + $allow = false; + } + + if(!$allow) break; + } + + return $allow; + } + + /** + * Allow current IP address and session to perform a password reset? + * + * @param string|null $ip + * @return bool + * + */ + protected function allowResetRequest($ip = null) { + + $maxError = $this->_('Max attempt quantity reached, please try again later.'); + if(!$this->allowReset) return false; + + // check that expected session vars are present when steps have begun + $step = (int) $this->sessionGet('step'); + if($step > 1) { + $verify = $this->sessionGet('verify'); + if(empty($verify)) { + if(self::debug) $this->error('Missing session verify'); + return false; + } + } + + // if there are no max restrictions on attempts, allow the request + $maxPerIP = $this->maxPerIP; + if($maxPerIP <= 0) return true; + if($step > 1) $maxPerIP++; + + // check the quantity of *any* attempts recorded in 'qty' session variable + $qty = (int) $this->sessionGet('qty'); + if($qty > $maxPerIP) { + if($this->beHonest || self::debug) $this->error($maxError); + return false; + } + + // check the quantity of *successful* attempts recorded in the database for this IP + if($ip === null) $ip = $this->wire('session')->getIP(); + $query = $this->wire('database')->prepare('SELECT COUNT(*) FROM ' . self::table . ' WHERE ip=:ip'); + $query->bindValue(':ip', $ip); + $query->execute(); + $qty = $query->fetchColumn(); + $query->closeCursor(); + if($qty >= $maxPerIP) { + if(self::debug) { + $this->error("Max multi-user requests ($qty) IP limit reached (via table)"); + } else if($this->beHonest) { + $this->error($maxError); + } + return false; + } + + // check the quantity of *any) requests recorded in our hourly cache for this IP address + $ip = ip2long($ip); + $qty = (int) $this->wire('cache')->get("forgotpass$ip", $this->expireSecs); + if($qty >= $maxPerIP) { + if(self::debug) { + $this->error("Max per IP limit ($qty) reached (via cache)"); + } else if($this->beHonest) { + $this->error($maxError); + } + return false; + } + + return true; + } + + /** + * Track a new password reset request in session and in cache + * + */ + protected function trackNewRequest() { + $pass = new Password(); + $verify = $pass->randomBase64String(22); + $this->sessionSet('verify', $verify); + $qty = (int) $this->sessionGet('qty'); + $this->sessionSet('qty', $qty + 1); + $ip = $this->wire('session')->getIP(true); // int + $qty = (int) $this->wire('cache')->get("forgotpass$ip", $this->expireSecs); + $this->wire('cache')->save("forgotpass$ip", $qty + 1, $this->expireSecs); + } + + /** + * Clear all password reset requests + * + */ + public function clearRequests() { + $this->wire('database')->exec("DELETE FROM " . self::table); + $this->wire('cache')->delete("forgotpass*"); + $this->wire('session')->removeAllFor($this); + } + + /** + * Get address to send emails from + * + * @return string + * + */ + protected function getEmailFrom() { + $emailFrom = $this->emailFrom; + if(empty($emailFrom)) $emailFrom = $this->wire('config')->adminEmail; + if(empty($emailFrom)) $emailFrom = 'noreply@' . $this->wire('config')->httpHost; + return $emailFrom; + } + + /** + * Log a message for this class + * + * @param string $str + * @param array $options + * @return WireLog + * + */ + public function ___log($str = '', array $options = array()) { + if(!$this->useLog) return $this->wire('log'); + if(empty($options['name'])) $options['name'] = 'forgot-password'; + if($this->logUser) $options['user'] = $this->logUser; + return parent::___log($str, $options); + } + + /** + * Record error + * + * @param array|Wire|string $text + * @param int $flags + * @return Wire + * + */ + public function error($text, $flags = 0) { + if($this->useInlineNotices) { + $this->inlineErrors[] = $text; + return $this; + } else { + return parent::error($text, $flags); + } + } + + protected function ___renderContinue($url = './', $label = '') { + if(empty($label)) $label = $this->_('Continue'); + return "

$label

"; + } + + protected function ___renderError($str) { + return "

" . $this->wire('sanitizer')->entities1($str) . "

"; + } + + protected function ___renderMessage($str) { + return "

" . $this->wire('sanitizer')->entities1($str) . "

"; } /** @@ -391,12 +990,12 @@ class ProcessForgotPassword extends Process implements ConfigurableModule { * */ public function ___install() { - + + /** @var WireDatabasePDO $database */ $database = $this->wire('database'); - $table = $database->escapeTable($this->table); $engine = $this->wire('config')->dbEngine; - $sql = "CREATE TABLE `$table` ( " . + $sql = "CREATE TABLE `" . self::table . "` ( " . "id INT unsigned NOT NULL PRIMARY KEY, " . "name varchar(128) NOT NULL, " . "token char(32) NOT NULL, " . @@ -408,7 +1007,7 @@ class ProcessForgotPassword extends Process implements ConfigurableModule { ") ENGINE=$engine DEFAULT CHARSET=ascii;"; try { - $this->message("Creating table: $table", Notice::log); + $this->message("Creating table: " . self::table, Notice::log); $database->exec($sql); } catch(\Exception $e) { $this->error($e->getMessage(), Notice::log); @@ -416,21 +1015,115 @@ class ProcessForgotPassword extends Process implements ConfigurableModule { } public function ___uninstall() { + /** @var WireDatabasePDO $database */ $database = $this->wire('database'); - $table = $database->escapeTable($this->table); - $database->exec("DROP TABLE `$table`"); + $database->exec("DROP TABLE `" . self::table ."`"); } - - public function getModuleConfigInputfields(array $data) { - /** @var InputfieldWrapper $form */ - $form = $this->wire(new InputfieldWrapper()); + + /** + * Module configuration + * + * @param InputfieldWrapper $inputfields + * + */ + public function getModuleConfigInputfields(InputfieldWrapper $inputfields) { + + $form = $inputfields; + $optional = ' ' . $this->_('(optional)'); + /** @var InputfieldEmail $f */ $f = $this->wire('modules')->get('InputfieldEmail'); $f->attr('name', 'emailFrom'); $f->label = $this->_('Email address to send messages from'); - if(isset($data['emailFrom'])) $f->attr('value', $data['emailFrom']); + $f->attr('value', $this->emailFrom); + $form->add($f); + + /** @var InputfieldCheckbox $f */ + $f = $this->wire('modules')->get('InputfieldCheckbox'); + $f->attr('name', 'askEmail'); + $f->label = $this->_('Use email rather than user name'); + $f->description = + $this->_('When checked, user will be asked for their email address to reset their password, rather than their username.') . ' ' . + $this->_('If the email address is used by more than one account, resetting passwords is not possible for those accounts.'); + $f->columnWidth = 50; + if($this->askEmail) $f->attr('checked', 'checked'); $form->add($f); - return $form; + + /** @var InputfieldInteger $f */ + $f = $this->wire('modules')->get('InputfieldInteger'); + $f->attr('name', 'maxPerIP'); + $f->label = $this->_('Max password reset requests per IP address or session'); + $f->description = $this->_('Use this option to prevent the same IP address or session from flooding reset requests.'); + $f->notes = $this->_('Specify 0 to disable.'); + $f->columnWidth = 50; + $f->attr('value', $this->maxPerIP); + $form->add($f); + + $f = $this->wire('modules')->get('InputfieldCheckbox'); + $f->attr('name', 'useLog'); + $f->label = $this->_('Log activity?'); + $f->description = $this->_('When enabled, password reset requests will be logged.'); + if(is_file($this->wire('config')->paths->logs . 'forgot-password.txt')) { + $f->description .= ' [' . $this->_('View the log') . ']' . + '(' . $this->wire('config')->urls->admin . 'setup/logs/view/forgot-password/' . ')'; + } + if($this->useLog) $f->attr('checked', 'checked'); + $form->add($f); + + /** @var InputfieldAsmSelect $f */ + $f = $this->wire('modules')->get('InputfieldAsmSelect'); + $f->attr('name', 'confirmFields'); + $f->label = $this->_('Confirm field values') . $optional; + $f->description = $this->_('As an extra verification in the last step, ask user to confirm values of these fields before accepting new password.'); + $f->notes = $this->_('Please only use single-language text or number based fields that are always populated for this.'); + foreach($this->wire('templates')->get('user')->fieldgroup as $field) { + if($field->name == 'roles' || $field->name == 'pass') continue; + if($field->type instanceof InputfieldHasArrayValue) continue; + if(strpos($field->type->className(), 'Language')) continue; + $f->addOption("$field->name:$field->id", $field->getLabel() . " ($field->name)"); + } + $f->attr('value', $this->confirmFields); + $form->add($f); + + /** @var InputfieldCheckboxes $f */ + $f = $this->wire('modules')->get('InputfieldCheckboxes'); + $f->attr('name', 'allowRoles'); + $f->label = $this->_('Allowed roles') . $optional; + $f->description = $this->_('To only allow certain roles to reset password, select them here.'); + $f->notes = $this->_('If none are selected then password reset is available to all roles.'); + foreach($this->wire('roles') as $role) { + if($role->name == 'guest') continue; + $f->addOption("$role->name:$role->id", $role->name); + } + $f->attr('value', $this->allowRoles); + $f->columnWidth = 50; + $form->add($f); + + /** @var InputfieldCheckboxes $f */ + $f = $this->wire('modules')->get('InputfieldCheckboxes'); + $f->attr('name', 'blockRoles'); + $f->label = $this->_('Blocked roles') . $optional; + $f->description = $this->_('To block certain roles from resetting password, select them here.'); + foreach($this->wire('roles') as $role) { + if($role->name == 'guest') continue; + $f->addOption("$role->name:$role->id", $role->name); + } + $f->attr('value', $this->blockRoles); + $f->columnWidth = 50; + $form->add($f); + + /** @var InputfieldCheckbox $f */ + $f = $this->wire('modules')->get('InputfieldCheckbox'); + $f->attr('name', '_clearCache'); + $f->label = $this->_('Clear password reset request caches and tables'); + $f->description = $this->_('This happens automatically over time but can be cleared manually if desired.'); + $f->collapsed = Inputfield::collapsedYes; + $form->add($f); + + if($this->wire('input')->post('_clearCache')) { + $this->clearRequests(); + $this->message($this->_('Cleared password reset requests')); + } }