<?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 2017 by Ryan Cramer
 * https://processwire.com
 *
 *
 */

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' => 102, 
			'permanent' => false, 
			'permission' => 'page-view',
			);
	}

	/**
	 * Setup default values
	 *
	 */
	public function __construct() {
		// allow passwords to be reset?
		$this->set('allowReset', 1); 
		$this->set('table', 'process_forgot_password'); 
		$this->set('emailFrom', ''); 
	}

	/**
	 * Check if login posted and attempt login, otherwise render the login form
	 *
	 */
	public function ___execute() {

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

			// 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();

			} 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"); 
			}

		} else {

			// step 1

			return $this->step1_renderForm();
		}
		
		return '';
	}	

	/**
	 * Render forgot password form
	 *
	 */
	protected function step1_renderForm() {

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

		/** @var InputfieldText $field */
		$field = $this->modules->get("InputfieldText"); 	
		$field->attr('id+name', 'username');
		$field->required = true; 
		$field->label = $this->_("Enter your user name");
		$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.");
		$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; 

		return $form->render();
	}

	/**
	 * 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.
	 *
	 */
	protected function step2_processForm() {
		
		$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);
				} else {
					$this->error($this->_('Specified account is not allowed to login so password may not be reset.'));
					$this->wire('session')->redirect('./'); 
				}
			}
		}

		$out = 	
			"<h2>" . $this->_("Assuming your account information was found and we have an email address on file, an email was dispatched with password reset information.") . "*</h2>" .
			"<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>" .
			"<p class='detail'>*" . $this->_('For security reasons, we do not reveal whether an account exists on this screen.') . "</p>";

		/*
		$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.'); 
			} else {
				$user = $users->first();
				if($user && strtolower($user->email) === $email) {
					$body .= $this->_('Your account name is:') . ' ' . $user->name; 
				}
			}
			
		} 
		*/

		return $out; 
	}

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

		$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
		
		$emailFrom = $this->emailFrom; 	
		if(!$emailFrom) $emailFrom = $this->wire('config')->adminEmail;
		if(!$emailFrom) $emailFrom = 'processwire@' . $this->config->httpHost;

		if($this->wire('mail')->send($user->email, $emailFrom, $subject, $body)) {

			// for informational/debugging purposes
			$ip = preg_replace('/[^\d.]/', '', $_SERVER['REMOTE_ADDR']);

			// 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;
			}
		}
	}

	/**
	 * User clicked URL from their email
	 *
	 * If valid, display form with new password entries. 
	 *
	 * If form submitted, send to step 4. 
	 *
	 */
	protected function step3_processEmailClick() {

		$id = (int) $this->input->get->user_id;
		$token = $this->input->get->token; 
		$database = $this->wire('database');
		$table = $database->escapeTable($this->table);
		
		$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(	$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;
			}
		}
		
		$this->error($this->_("Invalid reset request. Your request may have expired.")); 

		return "<p><a href='./?forgot=1'>" . $this->_("Continue") . "</a></p>";
		
	}

	/**
	 * Build the form with the reset password field
	 * 
	 * @param string $id
	 * @param string $token
	 * @return InputfieldForm
	 *
	 */
	protected function step3_buildForm($id, $token) {

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

		/** @var Field $field */
		$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 = $this->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) {

		$form->processInput($this->input->post);
		
		/** @var User $user */
		$user = $this->users->get((int) $id); 
		$pass = $form->get('pass')->value; 

		if(count($form->getErrors()) || !$user->id || !$pass) return $form->render();

 		$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'); 

		/** @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 process_forgot_password table if it doesn't exist
	 *
	 * Delete any entries older than 60 minutes
	 *
	 */
	protected function setupResetTable() {

		// create the DB table if it's not there already
		$database = $this->wire('database');
		$table = $database->escapeTable($this->table);
		
		try {
			$query = $database->prepare("SHOW COLUMNS FROM `$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 `$table` WHERE ts<:ts"); 
		$query->bindValue(":ts", time()-3600, \PDO::PARAM_INT); 
		$query->execute();
			
	}

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

		$sql =	"CREATE TABLE `$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(15) NOT NULL, " . 
				"KEY token (token), " . 
				"KEY ts (ts), " . 
				"KEY ip (ip) " . 
				") ENGINE=$engine DEFAULT CHARSET=ascii;"; 

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

	public function ___uninstall() {
		$database = $this->wire('database');
		$table = $database->escapeTable($this->table);
		$database->exec("DROP TABLE `$table`"); 	
	}
	
	public function getModuleConfigInputfields(array $data) {
		/** @var InputfieldWrapper $form */
		$form = $this->wire(new InputfieldWrapper());
		/** @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']); 
		$form->add($f); 
		return $form; 
	}


}

