<?php namespace ProcessWire;

/**
 * ProcessWire Forgot Password
 *
 * Provides password reset capability for when users forget their password. 
 * This accompanies the ProcessLogin module. 
 * 
 * For more details about how Process modules work, please see: 
 * /wire/core/Process.php 
 * 
 * ProcessWire 3.x, Copyright 2022 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=[])
 * @property string $wireMailer WireMail module name to use or omit for system default. Since 3.0.148. 
 * 
 * @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)
 * @method string renderForm(InputfieldForm $form, $formName)
 *
 *
 */

class ProcessForgotPassword extends Process implements ConfigurableModule {

	public static function getModuleInfo() {
		return array(
			'title' => __('Forgot Password', __FILE__), // getModuleInfo title
			'summary' => __('Provides password reset/email capability for the Login process.', __FILE__), // getModuleInfo summary
			'version' => 104, 
			'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() {
		parent::__construct();
		
		$this->set('allowReset', 1); 
		$this->set('table', 'process_forgot_password'); 
		$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());	
		$this->set('wireMailer', '');
	}

	/**
	 * Wired to API
	 * 
	 */
	public function wired() {
		$emailField = $this->wire()->fields->get('email');
		if($emailField) $this->set('confirmFields', array("email:$emailField->id"));
		parent::wired();
	}

	/**
	 * Check if login posted and attempt login, otherwise render the login form
	 *
	 */
	public function ___execute() {
	
		$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->wire()->user->isLoggedin()) return '';
		
		$this->setupResetTable();

		if($this->allowResetRequest()) {
			
			$step = (int) $this->sessionGet('step');

			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 {
				// prepare and render form for step 1
				$out = $this->step1_renderForm();
			}
			
		} else {
			$this->error($errors['off']);
		}
		
		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->location('./');
		}
		
		if($out) $out = "<div id='ProcessForgotPassword' style='text-align:left;'>$out</div>";
		
		return $out;
	}	

	/**
	 * Render forgot password form
	 * 
	 * @return string
	 *
	 */
	protected function step1_renderForm() {
		
		$modules = $this->wire()->modules;

		/** @var InputfieldForm $form */
		$form = $modules->get("InputfieldForm"); 
		$form->attr('action', './?forgot=1'); 
		$form->attr('method', 'post');

		/** @var InputfieldText $field */
		if($this->askEmail) {
			$field = $modules->get("InputfieldEmail");
			$field->label = $this->_("Enter your email address");
			$field->icon = 'envelope-o';
		} else {
			$field = $modules->get("InputfieldText");
			$field->label = $this->_("Enter your user name");
			$field->icon = 'user-circle-o';
		}
		$field->attr('id+name', 'username');
		$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);
	
		/** @var InputfieldSubmit $submit */
		$submit = $modules->get("InputfieldSubmit"); 
		$submit->attr('id+name', 'submit_forgot'); 
		$form->add($submit);

		$this->sessionSet('step', 1); 
		
		return $this->renderForm($form, 'step1');
	}

	/**
	 * 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() {

		$sanitizer = $this->wire()->sanitizer;
		$input = $this->wire()->input;
		$users = $this->wire()->users;
		$user = null; /** @var User|null $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 {
					// 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;
				}
			}
		}
		
		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 {
				$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 '';
		}

		$out = 
			"<p><strong>" . 
			$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 ? '' : '*') . 
			"</strong></p>" .
			"<p>" . 
			$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.") . 
			"</p>";
	
		if(!$this->beHonest) {
			$out .= 
			"<p><small class='detail'>*" . 
				$this->_('For security reasons, we do not reveal whether an account exists on this screen.') . 
			"</small></p>";
		}
		
		return $out;
	}

	/**
	 * Send an email with password reset link to the given User account
	 * 
	 * @param User $user
	 * @param string $token
	 * @return int
	 *
	 */
	protected function step2_sendEmail(User $user, $token) {

		$verify = $this->sessionGet('verify');
		$url = $this->wire()->page->httpUrl() . 
			"?forgot=1" . 
			"&u={$user->id}" . 
			"&t=" . urlencode($token);
		
		$body = $this->renderEmailBody($url, $verify, false);
		$bodyHTML = $this->renderEmailBody($url, $verify, true);
		
		$email = null;
		if($this->wireMailer) $email = $this->wire()->mail->new(array('module' => $this->wireMailer));
		if(!$email) $email = $this->wire()->mail->new();
		
		$email->to($user->email)->from($this->getEmailFrom()); 
		$email->subject($this->emailSubject)->body($body)->bodyHTML($bodyHTML); 

		return $email->send();
	}

	/**
	 * Send email to user notifying them why they cannot reset password
	 * 
	 * @param User $user
	 * @param $error
	 * @return int
	 * 
	 */
	protected function step2_sendErrorEmail(User $user, $error) {
		$body = $this->renderErrorEmailBody($error); 
		if(self::debug) $this->message("<p>" . nl2br($body) . "</p>", Notice::allowMarkup);
		$email = $this->wire()->mail->new();
		$email->to($user->email)->from($this->getEmailFrom());
		$email->subject($this->emailSubject)->body($body);
		return $email->send();
	}

	/**
	 * 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>";
			$_p = "</p>\n\n";
			$code = "<code>$code</code>";
			$url = "<a href='$url'>$url</a>";
		} 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 = "<html><head></head><body>$out</body></html>";
		}
		
		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";
	}

	/**
	 * User clicked URL from their email
	 *
	 * If valid, display form with new password entries. 
	 *
	 * If form submitted, send to step 4. 
	 * 
	 * @return string
	 *
	 */
	protected function step3_processEmailClick() {
	
		$input = $this->wire()->input;
		$database = $this->wire()->database;

		$id = (int) $input->get('u');
		$token = $input->get('t'); 
		$row = false;
		
		if(strlen($token)) {
			$query = $database->prepare("SELECT name, token, ip FROM `" . self::table . "` WHERE id=:id");
			$query->bindValue(":id", $id);
			$query->execute();

			$row = $query->fetch(\PDO::FETCH_ASSOC);
			$query->closeCursor();
		}
		
		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 = $this->renderForm($form, 'step3');	
			}
			
		} 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 int $id
	 * @param string $token
	 * @return InputfieldForm
	 *
	 */
	protected function step3_buildForm($id, $token) {
		
		$modules = $this->wire()->modules;
		$templates = $this->wire()->templates;
		
		$id = (int) $id;
		$token = urlencode($token); 

		/** @var InputfieldForm $form */
		$form = $modules->get("InputfieldForm"); 
		$form->attr('method', 'post');
		$form->attr('action', "./?forgot=1&u=$id&t=$token"); 
	
		/** @var InputfieldText $f */
		$f = $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);

		$template = $templates->get('user');
		$fieldgroup = $template->fieldgroup;
		$confirmFields = array();
		
		foreach($this->confirmFields as $key => $fieldName) {
			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->description = $this->_('Resetting password also requires that you confirm the correct value of this field.');
			$f->showIf = '';
			$f->requiredIf = '';
			$form->add($f);
			$confirmFields[$key] = $field->name;
		}
		$this->confirmFields = $confirmFields; // normalized to only known field names

		$field = $this->wire()->fields->get('pass'); 
		
		/** @var InputfieldPassword $inputfield */
		$inputfield = $field->getInputfield(new NullPage(), $field); 
		$inputfield->required = true; 
		$inputfield->collapsed = Inputfield::collapsedNever;
		$inputfield->attr('id+name', 'pass'); 
		$inputfield->label = $this->_("Reset Password"); // New password field label
		$form->add($inputfield);

		/** @var InputfieldSubmit $submit */
		$submit = $modules->get("InputfieldSubmit"); 
		$submit->attr('id+name', 'submit_reset'); 
		$form->add($submit); 

		return $form; 
	}

	/**
	 * Process the submitted password reset form and reset password
	 * 
	 * @param string $id
	 * @param InputfieldForm $form
	 * @return string
	 *
	 */
	protected function step4_completeReset($id, $form) {
		
		$input = $this->wire()->input;
	
		$confirmInputfields = array();
		foreach($this->confirmFields as $fieldName) {
			$field = $this->wire()->fields->get($fieldName);
			$inputfield = $form->getChildByName($fieldName);
			if(!$inputfield) continue;
			if($field && $field->hasFlag(Field::flagUnique)) {
				// unique flag does not apply to this input scenario
				$inputfield->hasField = null;
			}
			$confirmInputfields[$fieldName] = $inputfield;
		}

		$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++;
		}
	
		$user = $this->wire()->users->get((int) $id);
		
		foreach($confirmInputfields as $fieldName => $f) {
			$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++;
		}

		$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->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) {

		$database = $this->wire()->database;
		$session = $this->wire()->session;

		// create the unique verification token that is stored on the server and sent in the email
		$pass = new Password();
		$token = $pass->randomBase64String(32);
		$ip = $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
			$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) {
		
		$database = $this->wire()->database;
		
		if(!$id) $id = (int) $this->sessionGet('id');
		
		$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
	 *
	 * Delete any entries older than 60 minutes
	 *
	 */
	protected function setupResetTable() {

		$database = $this->wire()->database;
		
		try {
			$query = $database->prepare("SHOW COLUMNS FROM `" . self::table . "`");
			$query->execute();
		} catch(\Exception $e) {
			$query = false;
		}

		if(!$query || !$query->rowCount()) $this->install();

		// delete table entries that are older than one hour
		$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;
		$roles = $this->wire()->roles;
		
		foreach(array('allowRoles', 'blockRoles') as $type) {
			
			$testRoles = array();
			
			foreach($this->get($type) as $roleName) {
				$roleID = 0;
				if(strpos($roleName, ':')) list($roleName, $roleID) = explode(':', $roleName, 2);
				$role = $roles->get($roleName);
				if(!$role || !$role->id) $role = $roles->get((int) $roleID);
				if(!$role || !$role->id) continue;
				$testRoles[] = $role;
			}
			
			if(!count($testRoles)) continue;
			
			$hasRole = false;
			
			foreach($testRoles 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() {
		$cache = $this->wire()->cache;
		$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) $cache->get("forgotpass$ip", $this->expireSecs);
		$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() {
		$config = $this->wire()->config;
		$emailFrom = $this->emailFrom;
		if(empty($emailFrom)) {
			$settings = $config->wireMail;
			if(!empty($settings['from'])) $emailFrom = $settings['from'];
		}
		if(empty($emailFrom)) $emailFrom = $config->adminEmail;
		if(empty($emailFrom)) $emailFrom = 'noreply@' . $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 "<p class='pwfp-continue'><a href='$url'>$label</a></p>";
	}
	
	protected function ___renderError($str) {
		$str = $this->wire()->sanitizer->entities1($str); 
		return "<p class='pwfp-error'><strong>$str</strong></p>";
	}
	
	protected function ___renderMessage($str) {
		$str = $this->wire()->sanitizer->entities1($str);
		return "<p class='pwfp-message'>$str</p>";
	}

	/**
	 * For hooks that want to modify form before it is rendered
	 * 
	 * @param InputfieldForm $form
	 * @param string $formName
	 * @return string
	 * @since 3.0.149
	 * 
	 */
	protected function ___renderForm(InputfieldForm $form, $formName) {
		if(!$form->attr('name')) $form->attr('name', $formName);
		return $form->render();
	}

	/**
	 * Install this module by creating it's table
	 *
	 */
	public function ___install() {
	
		$database = $this->wire()->database;
		$engine = $this->wire()->config->dbEngine;

		$sql =	"CREATE TABLE `" . self::table . "` ( " . 
				"id INT unsigned NOT NULL PRIMARY KEY, " . 
				"name varchar(128) NOT NULL, " . 
				"token char(32) NOT NULL, " . 
				"ts INT unsigned NOT NULL, " . 
				"ip varchar(45) NOT NULL, " . 
				"KEY token (token), " . 
				"KEY ts (ts), " . 
				"KEY ip (ip) " . 
				") ENGINE=$engine DEFAULT CHARSET=ascii;"; 

		try { 
			$this->message("Creating table: " . self::table, Notice::log);
			$database->exec($sql);
		} catch(\Exception $e) {
			$this->error($e->getMessage(), Notice::log);
		}
	}

	public function ___uninstall() {
		$database = $this->wire()->database;
		$database->exec("DROP TABLE `" . self::table  ."`"); 	
	}
	
	public function ___upgrade($fromVersion, $toVersion) {
		$database = $this->wire()->database;
		$table = self::table;
		if($fromVersion <= 103) {
			$database->exec("ALTER TABLE `$table` MODIFY ip VARCHAR(45) NOT NULL"); 
			$this->message("Added IPv6 address support"); 
		}
	}

	/**
	 * Module configuration
	 * 
	 * @param InputfieldWrapper $inputfields
	 * 
	 */
	public function getModuleConfigInputfields(InputfieldWrapper $inputfields) {
		
		$modules = $this->wire()->modules;
		
		$form = $inputfields;
		$optional = ' ' . $this->_('(optional)');
		
		/** @var InputfieldEmail $f */
		$f = $modules->get('InputfieldEmail'); 
		$f->attr('name', 'emailFrom');
		$f->label = $this->_('Email address to send messages from'); 
		$f->attr('value', $this->emailFrom); 
		$form->add($f);
		
		/** @var InputfieldCheckbox $f */
		$f = $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->notes = 
			$this->_('Note: the ProcessLogin module will set this automatically at runtime when configured to use email login.'); 
		$f->columnWidth = 50;
		if($this->askEmail) $f->attr('checked', 'checked');
		$form->add($f); 
	
		/** @var InputfieldInteger $f */
		$f = $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);
	
		/** @var InputfieldCheckbox $f */
		$f = $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 = $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 = $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 = $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 = $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'));
		}
	}


}

