<?php namespace ProcessWire;

/**
 * ProcessWire Login Process
 *
 * Provides Login capability for ProcessWire Admin 
 * 
 * For more details about how Process modules work, please see: 
 * /wire/core/Process.php 
 * 
 * ProcessWire 3.x, Copyright 2023 by Ryan Cramer
 * https://processwire.com
 * 
 * @property bool $allowForgot Whether the ProcessForgotPassword module is installed.
 * @property bool|int $allowEmail Whether or not email login is allowed (0|false=off, 1|true=Yes, 2=Yes or name also allowed)
 * @property string $emailField Field name used for email login (when enabled). 
 * @property array $tfaRecRoleIDs Role IDs where admin prompts/recommends them to enable TFA.
 * @property int $tfaRememberDays Allow user to remember their browser and bypass TFA for this many days (-1=no limit, 0=disabled)
 * @property array $tfaRememberFingerprints Means by which to fingerprint user’s browser
 * @property string $tfaAutoType Auto-enable type, aka module name (default='')
 * @property array $tfaAutoRoleIDs Auto-enable for these role IDs, or blank for all roles. Applies only if $tfaAutoType selected (default=[])
 * 
 * @method void beforeLogin() #pw-hooker
 * @method void afterLogin() #pw-hooker
 * @method string executeLogout() #pw-hooker
 * @method string executeLoggedOut() #pw-hooker
 * @method string afterLoginOutput() #pw-hooker
 * @method void afterLoginRedirect($url = '') #pw-hooker
 * @method string afterLoginURL($url = '') #pw-hooker
 * @method string renderLoginForm() #pw-hooker
 * @method InputfieldForm buildLoginForm() #pw-hooker
 * @method void login($name, $pass) #pw-hooker
 * @method void loginFailed($name, $message = '') #pw-hooker
 * @method void loginSuccess(User $user) #pw-hooker
 * @method void loginAttemptReady($name) #pw-hooker 3.0.223+
 * @method void loginAttempted($user, $name) #pw-hooker 3.0.223+
 * @method void loginFormProcessReady($form) #pw-hooker 3.0.223+
 * @method void loginFormProcessed($form, $name) #pw-hooker 3.0.223+
 * @method array getBeforeLoginVars() #pw-hooker
 * @method array getLoginLinks() #pw-hooker
 * @method string renderLoginLinks() #pw-hooker
 * 
 *
 */

class ProcessLogin extends Process implements ConfigurableModule {
	
	public static function getModuleInfo() {
		return array(
			'title' => 'Login',
			'summary' => 'Login to ProcessWire',
			'version' => 109,
			'permanent' => true,
			'permission' => 'page-view',
		);
	}

	/**
	 * @var InputfieldText|InputfieldEmail
	 * 
	 */
	protected $nameField;
	
	/**
	 * @var InputfieldText
	 *
	 */
	protected $passField;

	/**
	 * @var InputfieldSubmit
	 * 
	 */
	protected $submitField;

	/**
	 * @var InputfieldForm
	 * 
	 */
	protected $form;

	/**
	 * Requested page edit ID (no longer used, but kept in case anything else monitoring it)
	 * 
	 * @var int
	 * 
	 */
	protected $id; 

	/**
	 * Is this login form being used for admin login?
	 * 
	 * @var bool
	 * 
	 */
	protected $isAdmin = false;

	/**
	 * URL to redirect to after login
	 * 
	 * @var string
	 * 
	 */
	protected $loginURL = '';

	/**
	 * URL to redirect to after logout
	 * 
	 * @var string
	 * 
	 */
	protected $logoutURL = '';

	/**
	 * Did user login with two factor authentication?
	 * 
	 * @var bool
	 * 
	 */
	protected $tfaLoginSuccess = false;

	/**
	 * Cached value from useEmailLogin method
	 * 
	 * @var bool|null
	 * 
	 */
	protected $useEmailLogin = null;

	/**
	 * Custom labels that override defaults, indexed by label name
	 * 
	 * @var array
	 * 
	 */
	protected $customLabels = array();

	/**
	 * Login name as submitted (after sanitize)
	 * 
	 * @var string
	 * 
	 */
	protected $submitLoginName = '';

	/**
	 * Configurable markup for this module
	 * 
	 * @var array
	 * 
	 */
	protected $customMarkup = array(
		'error' => '<p class="ui-state-error-text">{out}</p>', 
		'login-link' => '<a href="{url}">{out}</a>',
		'login-links' => '<p class="pw-login-links">{out}</p>', 
		'login-links-split' => ' <br />',
		'forgot-icon' => '', // in constructor
		'home-icon' => '', // in constructor
	);

	/**
	 * Construct
	 * 
	 */
	public function __construct() {
		$this->set('tfaRecRoleIDs', array());
		$this->set('tfaRememberDays', 90);
		$this->set('tfaRememberFingerprints', array('agentVL', 'accept', 'scheme', 'host'));
		$this->set('tfaAutoType', ''); 
		$this->set('tfaAutoRoleIDs', array());
		$this->set('allowEmail', false);
		$this->set('emailField', 'email');
		$this->customMarkup['forgot-icon'] = wireIconMarkup('question-circle', 'fw');
		$this->customMarkup['home-icon'] = wireIconMarkup('home', 'fw'); 
		parent::__construct();
	}

	/**
	 * Build the login form 
	 *
	 */
	public function init() {

		$this->id = isset($_GET['id']) ? (int) $_GET['id'] : '';  // id no longer used as anything but a toggle (on/off)
		$this->set('allowForgot', $this->modules->isInstalled('ProcessForgotPassword')); 
		$this->isAdmin = $this->wire()->page->template == 'admin';
		$this->useEmailLogin = $this->useEmailLogin();

		parent::init();
	}

	/**
	 * Get or set named label text
	 * 
	 * @param string $name Label name
	 * @param null|string $value Specify value to replace label with custom value at runtime, otherwise omit
	 * @return string
	 * @since 3.0.154
	 * 
	 */
	public function labels($name, $value = null) {
		if($value !== null) $this->customLabels[$name] = $value; 
		if(isset($this->customLabels[$name])) return $this->customLabels[$name];
		switch($name) { // alpha order
			case 'continue': $label = $this->_('Continue'); break;
			case 'edit-profile': $label = $this->_('Edit Profile'); break;
			case 'email': $label = $this->_('Email'); break; // Email input label
			case 'email-not-supported': $label = $this->_('Login is not supported for that email address.'); break;
			case 'fail-cookie': $label = $this->_('Cookie check failed: please enable cookies to login.'); break;
			case 'fail-javascript': $label = $this->_('Javascript check failed: please enable Javascript to login.'); break;
			case 'forgot-password': $label = $this->_('Forgot your password?'); break;
			case 'invalid-name': $label = $this->_('Invalid login name'); break;
			case 'login': $label = $this->_('Login'); break; // Login submit button label
			case 'login-failed': $label = $this->_('Login failed'); break;
			case 'login-headline': $label = $this->_x('Login', 'headline'); break; // Login form headline
			case 'logged-in': $label = $this->_('You are logged in.'); break;
			case 'logged-out': $label = $this->_('You have logged out'); break;
			case 'password': $label = $this->_('Password'); break; // Password input label
			case 'username': $label = $this->_('Username'); break; // Username input label
			case 'username-or-email': $label = $this->_('Username or Email'); break; // Name/email input label
			default: $label = "Unknown label name: $name";
		}
		return $label;
	}

	/**
	 * Get or set custom markup
	 * 
	 * @param string $name
	 * @param null|string $value
	 * @return string
	 * @since 3.0.154
	 * 
	 */
	public function markup($name, $value = null) {
		if($value !== null) $this->customMarkup[$name] = $value;
		return isset($this->customMarkup[$name]) ? $this->customMarkup[$name] : "Unknown markup name: $name";
	}

	/**
	 * Use login by email?
	 * 
	 * Returns false if no, int 1 of yes, int 2 if either email or name allowed
	 * 
	 * @return bool|int 
	 * @since 3.0.151
	 * 
	 */
	public function useEmailLogin() {
		
		if($this->useEmailLogin !== null) return $this->useEmailLogin;
		
		if(!$this->allowEmail) return false;
		if(!$this->emailField) return false;
		
		/** @var Field $field */
		$field = $this->wire()->fields->get($this->emailField); 
		if(!$field) return false;
		if(!$field->type instanceof FieldtypeEmail) return false;
		if(!$field->hasFlag(Field::flagUnique)) return false;
	
		/** @var Template $template */
		$template = $this->wire()->templates->get($this->wire()->config->userTemplateID); 
		if(!$template || !$template->hasField($field)) return false;
		
		return (int) $this->allowEmail;
	}

	/**
	 * Set URL to redirect to after login success
	 * 
	 * If not set, redirect will be back to the current page with a "login=1" GET variable. 
	 * However, you should only check if the user is logged in with if($user->isLoggedin()).
	 * 
	 * @param $url
	 * @return $this
	 * @throws WireException if given invalid URL
	 * 
	 */
	public function setLoginURL($url) {
		$url = $this->wire()->sanitizer->url($url, array('throw' => true)); 
		$this->loginURL = $url;
		return $this; 
	}
	
	/**
	 * Set URL to redirect to after logout success
	 *
	 * If not set, redirect will be back to the current page with a "logout=2" GET variable. 
	 *
	 * @param $url
	 * @return $this
	 * @throws WireException if given invalid URL
	 *
	 */
	public function setLogoutURL($url) {
		$url = $this->wire()->sanitizer->url($url, array('throw' => true));
		$this->logoutURL = $url;
		return $this;
	}

	/**
	 * Set cache control headers to prevent caching
	 * 
	 * Note that PHP already does this, but if someone has overridden PHP’s default settings
	 * then these ones will apply. This is in order to prevent a cached copy of the login form
	 * from being used since the login form is rendered prior to login session. 
	 * 
	 */
	protected function setCacheHeaders() {
		header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
		header("Cache-Control: post-check=0, pre-check=0", false);
		header("Pragma: no-cache");
	}
	
	/**
	 * Check if login posted and attempt login, otherwise render the login form
	 * 
	 * @return string
	 *
	 */
	public function ___execute() {
	
		$session = $this->wire()->session;
		$input = $this->wire()->input;
		$user = $this->wire()->user;
		
		if($user->isLoggedin()) {
			
			if($this->loginURL && !$input->get('login')) {
				$this->afterLoginRedirect($this->loginURL);
			}
			if($input->get('layout')) return ''; // blank placeholder page option for admin themes
			$this->message($this->labels('logged-in')); 
			if($this->isAdmin && $user->hasPermission('page-edit') && !$input->get('login')) {
				$this->afterLoginRedirect();
			}
			// fallback if nothing set
			return $this->afterLoginOutput();
			
		} else if($input->urlSegmentStr() === 'logout') {
			$session->location('../');
		}
		
		$tfa = $this->getTfa();
		
		if($tfa && $tfa->active()) {
			// two factor authentication
			if($tfa->success()) {
				$this->tfaLoginSuccess = true;
				$this->loginSuccess($this->wire()->user);
				$this->afterLoginRedirect('./');
			} else {
				return $tfa->render();
			}
			
		} else if($input->get('forgot') && $this->allowForgot) {
			/** @var ProcessForgotPassword $process */
			$process = $this->modules->get('ProcessForgotPassword');
			if($this->useEmailLogin()) $process->askEmail = true;
			return $process->execute();
		}
		
		$this->buildLoginForm();
		$loginSubmit = $input->post('login_submit');

		if(!$loginSubmit) {
			$this->beforeLogin();
			return $this->renderLoginForm();
		}
		
		$this->loginFormProcessReady($this->form);
		$this->form->processInput($input->post);
	
		// at this point login form has been submitted
		$name = $this->getLoginName();
		$pass = substr($this->passField->attr('value'), 0, 128);
	
		$this->loginFormProcessed($this->form, $name);

		if(!$name || !$pass) return $this->renderLoginForm();

		// vars to copy from non-logged in session to logged-in session
		$session->setFor($this, 'copyVars', array(
			'hidpi' => $input->post('login_hidpi') ? true : false, 
			'touch' => $input->post('login_touch') ? true : false, 	
			'clientWidth' => (int) $input->post('login_width'),
		));
	
		if($tfa) $tfa->start($name, $pass); 
		
		$this->login($name, $pass);

		return $this->renderLoginForm();
	}

	/**
	 * Get Tfa instance or null if not applicable
	 * 
	 * @return null|Tfa
	 * @since 3.0.160
	 * 
	 */
	public function getTfa() {
		$tfas = $this->wire()->modules->findByPrefix('Tfa');
		if(!count($tfas)) return null;
		$tfa = new Tfa();
		$this->wire($tfa);
		$tfa->rememberDays = $this->tfaRememberDays;
		$tfa->rememberFingerprints = $this->tfaRememberFingerprints;
		$tfa->autoType = $this->tfaAutoType && $this->tfaAutoType !== '0' ? $this->tfaAutoType : '';
		$tfa->autoRoleIDs = $this->tfaAutoRoleIDs;
		return $tfa;
	}

	/**
	 * Get login username (whether email or name used)
	 * 
	 * @return string|bool
	 * @since 3.0.151
	 * 
	 */
	protected function getLoginName() {
		
		$sanitizer = $this->wire()->sanitizer;
		$config = $this->wire()->config;
		$users = $this->wire()->users;
		
		$value = $this->nameField->attr('value');
		if(!strlen($value)) return false;
		
		$originalValue = $value;
		
		if(!$this->useEmailLogin() || !strpos($value, '@')) {
			$value = $this->sanitizer->pageNameUTF8($value);
			$this->submitLoginName = $value;
			if($originalValue !== $value && strtolower($originalValue) !== $value) {
				// if sanitizer changed anything about the value (other than case) do not accept it
				$this->loginFailed($value, $this->labels('invalid-name'));
				$value = false;
			}
			return $value;
		}
		
		// at this point we are dealing with an email login
		$value = strtolower($sanitizer->email($value));
		$this->submitLoginName = $value;
		if(empty($value)) return false;
		
		if(strtolower($originalValue) !== $value) {
			// if sanitizer changed anything about the email (not likely) do not accept it
			$this->loginFailed($value, $this->labels('invalid-name'));
			return false;
		}
		
		$error = $this->labels('email-not-supported');
		$items = $users->find("include=all, $this->emailField=" . $sanitizer->selectorValue($value)); 
		
		if(!$items->count()) {
			// fail: no matches
			$this->loginFailed($value);
			return false;
			
		} else if($items->count() > 1) {
			// fail: more than one match
			if($config->debug) $error .= ' (not unique)';
			$this->loginFailed($value, $error);
			return false;
		} 
		
		// success: single match
		$user = $items->first();
		
		if($user->status > Page::statusHidden) {
			// hidden, unpublished, trash
			if($config->debug) $error .= ' (inactive)';
			$this->loginFailed($value, $error);
			return false;
		}
		
		return $user->name;
	}

	/**
	 * Determine the after login URL and page, then populate to session to pick up after login
	 * 
	 * @since 3.0.167
	 * 
	 */
	protected function determineAfterLoginUrl() {
		
		$input = $this->wire()->input;
		$session = $this->wire()->session;
		
		// if we’ve already done this then exit early
		if($session->getFor($this, 'afterLoginPageId') !== null) return;
	
		// ProcessPageView sets a loginRequestPageID variable to its session
		// this is the ID of the page that access control blocked and sent user to login
		$requestPageId = (int) $session->getFor('ProcessPageView', 'loginRequestPageID');
		if(!$requestPageId || $requestPageId === $this->wire()->page->id) return;
		
		// load the requested page
		$requestPage = $this->wire()->pages->get($requestPageId);
		if(!$requestPage->id) {
			$session->setFor($this, 'afterLoginPageId', 0);
			return;
		}
		
		if($requestPage->process) {
			// admin: this is likely an admin page with a Process module
			$process = (string) $requestPage->process;
			if(!$process || $process === "ProcessLogin") return;
			$className = $this->wire()->modules->getModuleClass($process, true);
			if(!$className || !class_exists($className)) return;
			$url = call_user_func_array("$className::getAfterLoginUrl", array($requestPage));
		} else {
			// front-end
			$url = '';
		}

		if(empty($url)) {
			$url = $session->getFor('ProcessPageView', 'loginRequestURL');
		} else {
			// common integer GET vars in admin to identify if present and not already in $url
			foreach(array('id', 'modal') as $name) {
				$value = $input->get($name);
				if($value === null || !ctype_digit("$value")) continue; // not an integer
				if(preg_match('/[&?]' . $name . '=/', $url)) continue; // already present
				$url .= (strpos($url, '?') ? '&' : '?') . "$name=" . ((int) $value);
			}
		}
		
		// set what we found to session
		$session->setFor($this, 'afterLoginUrl', $url);
		$session->setFor($this, 'afterLoginPageId', $requestPage->id);
	}

	/**
	 * Perform login and redirect on success
	 * 
	 * @param string $name
	 * @param string $pass
	 * @return bool Returns false on fail, performs redirect on success
	 * 
	 */
	public function ___login($name, $pass) {
		
		$session = $this->wire()->session;
		$input = $this->wire()->input;
		
		$this->loginAttemptReady($name);
		
		if($name && $pass) {
			$loginUser = $session->login($name, $pass);
		} else {
			$loginUser = false;
		}

		$this->loginAttempted($loginUser, $name);

		if($loginUser && $loginUser->id) {
			$this->loginSuccess($loginUser); 
			$url = $input->urlSegment1 === 'navJSON' ? '../' : './';
			$this->afterLoginRedirect($url); 
			
		} else {
			$this->loginFailed($this->submitLoginName ? $this->submitLoginName : $name); 
		}
		
		return false;
	}
	
	/**
	 * Log the user out
	 * 
	 * @return string
	 *
	 */
	public function ___executeLogout() {
		if($this->logoutURL) {
			$url = $this->logoutURL;
		} else if($this->isAdmin || $this->wire()->page->template->name === 'admin') {
			$url = $this->wire()->config->urls->admin . './?loggedout=1';
		} else {
			$url = "./?logout=2";
		}
		$session = $this->wire()->session;
		$session->logout();
		$session->redirect($url, false);
		return '';
	}


	/**
	 * Check that sessions can be initiated and attempt to rectify situation if not
	 * 
	 * Happens only on the admin login form. 
	 *
	 */
	protected function ___beforeLogin() {
		
		$session = $this->wire()->session;
		
		$beforeLoginVars = $this->getBeforeLoginVars();
		$session->setFor($this, 'beforeLoginVars', $beforeLoginVars);
		
		// check if Process module provides an after login URL that it wants us to use
		$this->determineAfterLoginUrl();
		
		// if checks already completed don't run them again
		if($session->getFor($this, 'beforeLoginChecks')) return;
		
		$modules = $this->wire()->modules;
		$config = $this->wire()->config;
		$input = $this->wire()->input;
		$files = $this->wire()->files;
		$log = $this->wire()->log;

		// any remaining checks only if currently in the admin
		if(!$this->isAdmin) return;
		
		if(	ini_get('session.save_handler') == 'files' 
			&& !$modules->isInstalled('SessionHandlerDB')
			&& !$input->get('db')
			) {
			
			$installSessionDB = false;
			$path = $config->paths->sessions;
			$error = '';
			
			if(!file_exists($path)) {
				$files->mkdir($path);
				clearstatcache();
				if(file_exists($path)) {
					$log->message("Created session path $path"); 
				} else {
					$installSessionDB = true;
					$error = "Session path $path does not exist and we are unable to create it.";
				}
			} 
			
			if(!is_writable($path)) {
				$files->chmod($path);
				clearstatcache();
				if(is_writable($path)) {
					$log->message("Updated session path to be writable $path"); 
				} else {
					$installSessionDB = true;
					$error = "Unable to write to session path $path, and unable to fix the permissions.";
				}
			}
			
			// if we can't get file-based sessions going, switch to database sessions to ensure admin can login
			if($installSessionDB) {
				if($error) $log->error($error); 
				if($modules->get('SessionHandlerDB')) {
					$log->error("Installed SessionHandlerDB as an alternate session handler. If you wish to uninstall this, do so after correcting the session path error."); 
					$session->redirect("./?db=1"); // db param to prevent potential infinite redirect
				} else {
					$log->error("Unable to install alternate session handler module SessionHandlerDB"); 	
					$this->error("Session write error. Login may not be possible."); 
				}
			}
		}
		
		$session->setFor($this, 'beforeLoginChecks', 1); 
	}

	/**
	 * Hook called after login
	 *
	 * Notify admin if there are any issues that need their attention.
	 * Happens only on the admin login form after superuser login. 
	 *
	 */
	protected function ___afterLogin() {
		if($this->wire()->user->isSuperuser()) {
			/** @var SystemUpdater $systemUpdater */
			$systemUpdater = $this->wire()->modules->get('SystemUpdater');
			if($systemUpdater) { 
				$updatesApplied = $systemUpdater->getUpdatesApplied();
				$checks = $systemUpdater->getChecks();
				$checks->setShowNotices(true);
				//$checks->setTestAll(true);
				
				if(count($updatesApplied)) {
					$checks->checkWelcome();
					$this->message(
						sprintf(
							$this->_('Skipping after-login system checks because updates were applied (%s)'), 
							implode(', ', $updatesApplied)
						),
						Notice::debug
					);
				} else {
					$checks->execute();
				}
			}
		}
	}

	/**
	 * Build the login form
	 * 
	 * @return InputfieldForm
	 * 
	 */
	protected function ___buildLoginForm() {
		
		$modules = $this->wire()->modules;
		
		$useEmailLogin = $this->useEmailLogin();
		$nameInputType = 'InputfieldText';
		$nameInputLabel = $this->labels('username'); // Login form: username field label

		if($useEmailLogin === 1) {
			$nameInputType = 'InputfieldEmail';
			$nameInputLabel = $this->labels('email'); // Login form: email field label
		} else if($useEmailLogin === 2) {
			$nameInputLabel = $this->labels('username-or-email'); // Login form: username OR email field label
		}
	
		$this->nameField = $modules->get($nameInputType);
		$this->nameField->label = $nameInputLabel;
		$this->nameField->attr('id+name', 'login_name'); 
		$this->nameField->attr('class', $this->className() . 'Name');
		$this->nameField->addClass('InputfieldFocusFirst');
		$this->nameField->collapsed = Inputfield::collapsedNever;

		$this->passField = $modules->get('InputfieldText');
		$this->passField->set('label', $this->labels('password')); // Login form: password field label
		$this->passField->attr('id+name', 'login_pass'); 
		$this->passField->attr('type', 'password'); 
		$this->passField->attr('class', $this->className() . 'Pass');
		$this->passField->collapsed = Inputfield::collapsedNever;

		$this->submitField = $modules->get('InputfieldSubmit');
		$this->submitField->attr('name', 'login_submit'); 
		$this->submitField->attr('value', $this->labels('login')); // Login form: submit login button 
		
		$this->form = $modules->get('InputfieldForm');

		// we'll retain an ID field in the GET url, if it was there (note: no longer used as anything but a toggle on/off)
		$this->form->attr('action', "./" . ($this->id ? "?id=$this->id" : '')); 
		$this->form->addClass('InputfieldFormFocusFirst');

		$this->form->attr('id', $this->className() . 'Form'); 
		$this->form->add($this->nameField); 
		$this->form->add($this->passField); 
		$this->form->add($this->submitField);

		if($this->isAdmin) {
			// detect hidpi at login (populated from js)
			/** @var InputfieldHidden $f */
			$f = $modules->get('InputfieldHidden');
			$f->attr('id+name', 'login_hidpi');
			$f->attr('value', 0);
			$this->form->add($f);

			// detect touch device login (populated from js)
			/** @var InputfieldHidden $f */
			$f = $modules->get('InputfieldHidden');
			$f->attr('id+name', 'login_touch');
			$f->attr('value', 0);
			$this->form->add($f);
			
			// detect touch device login (populated from js)
			/** @var InputfieldHidden $f */
			$f = $modules->get('InputfieldHidden');
			$f->attr('id+name', 'login_width');
			$f->attr('value', 0);
			$this->form->add($f);
		}
	
		/** @var InputfieldHidden $f */
		$f = $modules->get('InputfieldHidden');
		$f->attr('id+name', 'login_start');
		$f->val(gmdate('U')); // GMT/UTC unix timestamp of when login form was rendered
		$this->form->add($f);

		$s = 'script';
		$jsError = str_replace('{out}', $this->labels('fail-javascript'), $this->markup('error'));
		$cookieError = str_replace(array('{out}', "'"), array($this->labels('fail-cookie'), '"'), $this->markup('error'));
		$this->form->prependMarkup .= "<$s>if(!navigator.cookieEnabled) document.write('$cookieError');</$s>";
		if($this->isAdmin) $this->form->prependMarkup .= "<no$s>$jsError</no$s>";

		return $this->form; 
	}

	/**
	 * Render the login form
	 * 
	 * @return string
	 *
	 */
	protected function ___renderLoginForm() {
		$loggedIn = $this->wire()->user->isLoggedin();
		$out = '';
		
		if($this->wire()->input->get('login') && $loggedIn) {
			// redirect to page after login
			$this->afterLoginRedirect();
		} else if($loggedIn) {
			// user is already logged in, do nothing
		} else {
			// render login form
			if($this->isAdmin) $this->setCacheHeaders();
			// note the space after 'Login ' is intentional to separate it from the Login button for translation purposes
			$this->headline($this->labels('login-headline')); // Headline for login form page
			$this->passField->attr('value', '');
			$out = $this->form->render();
			$links = $this->getLoginLinks();
			if(count($links)) {
				$out .= str_replace('{out}', implode($this->markup('login-links-split'), $links), $this->markup('login-links'));
			}
			if(!$this->wire()->modules->isInstalled('InputDetect')) {
				$config = $this->wire()->config;
				$config->scripts->prepend($config->urls('ProcessLogin') . 'what-input.min.js');
			}
		}
		
		return $out;
	}

	/**
	 * Get array of links to display under login form
	 * 
	 * Each item in returned array must be entire `<a>` tag for link
	 * 
	 * #pw-hooker
	 * 
	 * @return array
	 * @since 3.0.154
	 * 
	 */
	protected function ___getLoginLinks() {
		$links = array();
		$markup = $this->markup('login-link');
		if($this->allowForgot) {
			$icon = $this->markup('forgot-icon');
			$label = $this->labels('forgot-password');
			$links['forgot'] = str_replace(
				array('{url}', '{out}'), 
				array('./?forgot=1', "$icon $label"), 
				$markup
			);
		}
		$home = $this->pages->get('/');
		$icon = $this->markup('home-icon');
		$label = $this->wire()->sanitizer->entities($home->getUnformatted('title'));
		$links['home'] = str_replace(
			array('{url}', '{out}'), 
			array($home->url, "$icon $label"), 
			$markup
		); 
		return $links;
	}

	/**
	 * Output that appears if there is nowhere to redirect to after login
	 * 
	 * Called only if login originated from the actual login page, OR if user does not have page-edit permission
	 * and thus can’t browse around in the admin. 
	 * 
	 * This method is not often used since it’s more common and recommended to redirect after login. 
	 * 
	 * @return string
	 * 
	 */
	protected function ___afterLoginOutput() {
		$config = $this->wire()->config;
		/** @var InputfieldButton $btn */
		$btn = $this->wire()->modules->get('InputfieldButton');
		if($this->wire()->user->hasPermission('profile-edit')) {
			$btn->value = $this->labels('edit-profile');
			$btn->href = $config->urls->admin . 'profile/';
		} else {
			$btn->value = $this->labels('continue');
			$btn->href = $config->urls->root;
		}
		return "<p>" . $btn->render() . "</p>";
	}

	/**
	 * Redirect to admin root after login
	 *
	 * @param string $url
	 *
	 */
	protected function ___afterLoginRedirect($url = '') {
		$url = $this->afterLoginURL($url);
		$session = $this->wire()->session;
		$session->removeFor($this, 'beforeLoginVars');
		$session->removeFor($this, 'beforeLoginChecks');
		$session->removeFor($this, 'afterLoginUrl');
		$session->removeFor($this, 'afterLoginPageId');
		$session->removeFor('ProcessPageView', 'loginRequestPageID');
		$session->removeFor('ProcessPageView', 'loginRequestURL');
		if(strpos($url, '/navJSON') !== false) $url = str_replace(array('/navJSON/', '/navJSON'), '/', $url);
		$session->redirect($url, false);
	}

	/**
	 * Hooks can modify the redirect URL with this hook
	 * 
	 * #pw-hooker
	 * #pw-internal
	 * 
	 * @param string $url
	 * @return string
	 * 
	 */
	public function ___afterLoginURL($url = '') {
		
		$session = $this->wire()->session;
		$afterLoginUrl = $session->getFor($this, 'afterLoginUrl');
		$id = (int) $session->getFor($this, 'afterLoginPageId'); 
		
		if($afterLoginUrl) {
			$page = $id ? $this->wire()->pages->get($id) : new NullPage();
			if(!$page->id || $page->viewable()) return $afterLoginUrl;
		}
		
		if(empty($url)) {
			$user = $this->wire()->user;
			if($this->loginURL) {
				$url = $this->loginURL;
			} else if($this->isAdmin && $user->isLoggedin() && $user->hasPermission('page-edit')) {
				if($this->id || ((string) $this->wire()->process) !== $this->className()) {
					$url = './';	
				} else {
					$url = $this->wire()->config->urls->admin . 'page/';
				}
			} else {
				$url = './';
			}
		}
		
		$beforeLoginVars = $session->getFor($this, 'beforeLoginVars'); 
		if(!is_array($beforeLoginVars)) $beforeLoginVars = array();
		if(!isset($beforeLoginVars['login'])) $beforeLoginVars['login'] = 1;
		$a = array();
		foreach($beforeLoginVars as $name => $value) {
			if(strpos($url, "?$name=") !== false || strpos($url, "&$name=") !== false) continue; // skip if overridden
			$a[$name] = $value;
		}
		if(count($a)) {
			$url .= strpos($url, '?') !== false ? '&' : '?';
			$url .= http_build_query($a);
		}
		
		return $url;
	}

	/**
	 * Get validated/sanitized variables in the query string for not logged-in user to retain after login
	 * 
	 * Hook this if you need to add more than 'id' but make sure anything populated
	 * to the return value is fully validated and sanitized. 
	 * 
	 * @return array Associative array of variables
	 * 
	 */
	public function ___getBeforeLoginVars() {
		$session = $this->wire()->session;
		$vars = $session->getFor($this, 'beforeLoginVars'); 
		if(!is_array($vars)) $vars = array();
		$id = $this->wire()->input->get('id');
		if($id !== null) $vars['id'] = $this->wire()->sanitizer->intUnsigned($id);
		return $vars;
	}

	/**
	 * Hook called right before a login is attempted
	 * 
	 * #pw-hooker
	 * 
	 * @param string $name
	 * @since 3.0.223
	 * 
	 */
	protected function ___loginAttemptReady($name) {}

	/**
	 * Hook called immediately after a login was attempted
	 * 
	 * #pw-hooker
	 * 
	 * @param User|false $user This will be User object on success, or false on fail
	 * @param string $name Attempted login name
	 * @since 3.0.223
	 * 
	 */
	protected function ___loginAttempted($user, $name) {}

	/**  
	 * Hook called immediately before the login form in processed
	 * 
	 * #pw-hooker
	 * 
	 * @param InputfieldForm $form
	 * @since 3.0.223
	 * 
	 */
	protected function ___loginFormProcessReady($form) {}

	/**
	 * Hook called immediately after login form processed and user name/pass identified as present
	 * 
	 * #pw-hooker
	 * 
	 * @param InputfieldForm $form
	 * @param string $name Attempted user name
	 * @since 3.0.223
	 * 
	 */	
	protected function ___loginFormProcessed($form, $name) {}

	/**
	 * Hook called on login fail
	 * 
	 * @param string $name
	 * @param string $message Specify only to override default error message (since 3.0.151)
	 * 
	 */
	protected function ___loginFailed($name, $message = '') {
		if(empty($message)) $message = "$name - " . $this->labels('login-failed'); 
		$this->error($message);
	}

	/**
	 * Hook called on login success
	 * 
	 * @param User $user
	 * 
	 */
	protected function ___loginSuccess(User $user) {
		
		$session = $this->wire()->session;
		
		if($this->isAdmin) {
			$copyVars = $session->getFor($this, 'copyVars');
			if(!is_array($copyVars)) $copyVars = array();
			foreach($copyVars as $key => $value) {
				$session->set($key, $value);
			}

			$session->remove('error');
			$session->removeFor($this, 'copyVars');
		}
			
		if(!$user->hasTfa() && count($this->tfaRecRoleIDs) && !$this->tfaLoginSuccess) {
			// determine if Tfa module is installed and user has role requiring Tfa
			$requireTfa = false;
			$roles = $this->wire()->roles;
			if(count($this->wire()->modules->findByPrefix('Tfa'))) {
				foreach($this->tfaRecRoleIDs as $roleID) {
					$role = $roles->get((int) $roleID);
					if($role && $user->hasRole($role)) {
						$requireTfa = true;
						break;
					}
				}
			}
			if($requireTfa) {
				$url = $this->wire()->config->urls('admin') . 'profile/#wrap_Inputfield_tfa_type';
				$session->setFor('_user', 'requireTfa', $url);
			}
		}

		if($this->isAdmin) $this->afterLogin();
	}

	/**
	 * Configure module settings
	 * 
	 * @param InputfieldWrapper $inputfields
	 * 
	 */
	public function getModuleConfigInputfields(InputfieldWrapper $inputfields) {
	
		$modules = $this->wire()->modules;

		/** @var InputfieldRadios $f */
		$f = $modules->get('InputfieldRadios'); 
		
		$emailAllow = true;
		$emailField = $this->wire()->fields->get($this->emailField); /** @var Field $field */
		$emailAttrs = array();
		$emailLabel = $this->_('Email address');
		$emailNotes = array();
		
		if($emailField && ((int) $this->allowEmail) !== 1) {
			if(!$emailField->hasFlag(Field::flagUnique)) {
				$emailAllow = false;
				$emailNotes[] = sprintf(
					$this->_('You must [enable the “unique” setting](%s) for your email field'),
					$emailField->editUrl('flagUnique')
				);
			}
			$role = $this->wire()->roles->get($this->wire()->config->superUserRolePageID);
			if($this->wire()->users->count("roles=$role, email=''") > 0) {
				$emailAllow = false;
				$emailNotes[] = $this->_('All superusers must have an email address defined');
			}
			if(!$emailAllow) {
				$emailAttrs['disabled'] = 'disabled';
				$emailLabel .= ' - ' . $this->_('See notes below to enable');
			}
		}
		
		$f->attr('name', 'allowEmail'); 
		$f->label = $this->_('Login type'); 
		$f->addOption(0, $this->_('User name'));
		$f->addOption(1, $emailLabel, $emailAttrs);
		$f->addOption(2, $this->_('Either'), $emailAttrs);
		$f->icon = 'sign-in';
		$f->val((int) $this->allowEmail);
		if(count($emailNotes)) $f->notes = $this->_('To use login-by-email: ') . implode(', ', $emailNotes);
		$inputfields->add($f);
		$inputfields->add($this->getTfaConfigInputfields());
	}

	/**
	 * Get Inputfields to configure Tfa settings
	 * 
	 * @param array $data
	 * @return InputfieldFieldset
	 * @since 3.0.163
	 * 
	 */
	public function getTfaConfigInputfields(array $data = array()) {
		
		$defaults = array(
			'tfaAutoType' => $this->tfaAutoType,
			'tfaAutoRoleIDs' => $this->tfaAutoRoleIDs, 
			'tfaRecRoleIDs' => $this->tfaRecRoleIDs,
			'tfaRememberDays' => $this->tfaRememberDays,
			'tfaRememberFingerprints' => $this->tfaRememberFingerprints,
		);
	
		$data = array_merge($defaults, $data);
		$modules = $this->wire()->modules;
		$items = array();
		$autos = array();
		
		/** @var InputfieldFieldset $fieldset */
		$fieldset = $modules->get('InputfieldFieldset');
		$fieldset->attr('id', 'tfaConfigFieldset');
		$fieldset->label = $this->_('Two-factor authentication');
		$fieldset->icon = 'user-secret';
		$fieldset->appendMarkup =
			"<p><a target='_blank' href='https://modules.processwire.com/categories/tfa/'>" .
			$this->_('Tfa modules in the ProcessWire modules directory') . ' ' .
			wireIconMarkup('external-link') . "</a></p>";

		$tfaModules = $modules->findByPrefix('Tfa');

		if(!count($tfaModules)) {
			$fieldset->description = $this->_('To configure this you must first install one or more Tfa modules and then return here.');
			$fieldset->collapsed = Inputfield::collapsedYes;
			return $fieldset;
		}
		
		foreach($tfaModules as $name) {
			$items[] = "[$name](" . $modules->getModuleEditUrl($name) . ")";
			/** @var Tfa $tfaModule */
			$tfaModule = $modules->getModule($name, array('noCache' => true, 'noInit' => true));
			if($tfaModule && $tfaModule->autoEnableSupported()) {
				$autos[$name] = $modules->getModuleInfoProperty($name, 'title');
			}
		}
		
		$fieldset->description = 
			$this->_('Found the following Tfa modules:') . ' ' . implode(', ', $items) . ' ' . 
			$this->_('(click to configure)');

		if(count($autos)) {
			$forceLabel = $this->_('Force two-factor authentication');
			/** @var InputfieldRadios $f */
			$f = $modules->get('InputfieldRadios');
			$f->attr('name', 'tfaAutoType');
			$f->label = $forceLabel . ' - ' . $this->_x('Type', 'Module name/type');
			$f->description = $this->_('When a Tfa module is selected here, it will be enabled automatically (at login) for users that are not using two-factor authentication.');
			$f->addOption('0', $this->_('Disabled'));
			foreach($autos as $name => $title) {
				$f->addOption($name, "$title ($name)");
			}
			$f->icon = 'gavel';
			$f->val(!empty($data['tfaAutoType']) ? $data['tfaAutoType'] : '0');
			$fieldset->add($f);

			/** @var InputfieldCheckboxes $f */
			$f = $modules->get('InputfieldCheckboxes');
			$f->attr('name', 'tfaAutoRoleIDs');
			$f->label = $forceLabel . ' - ' . $this->_x('Roles', 'Roles selection');
			$f->description = $this->_('Check roles to force two-factor authentication for, or leave all unchecked to force for ALL roles (when/where possible).');
			foreach($this->wire('roles') as $role) {
				if($role->name == 'guest') continue;
				$f->addOption($role->id, $role->name);
			}
			$f->icon = 'gavel';
			$f->attr('value', $data['tfaAutoRoleIDs']);
			$f->showIf = 'tfaAutoType!=0';
			$f->collapsed = Inputfield::collapsedBlank;
			$fieldset->add($f);
		}

		/** @var InputfieldCheckboxes $f */
		$f = $modules->get('InputfieldCheckboxes');
		$f->attr('name', 'tfaRecRoleIDs');
		$f->icon = 'gears';
		$f->label = $this->_('Strongly suggest two-factor authentication for these roles');
		$f->description =
			$this->_('After logging in to the admin, ProcessWire will prompt users in the roles you select here to use two-factor authentication for their accounts.');
		foreach($this->wire('roles') as $role) {
			if($role->name == 'guest') continue;
			$f->addOption($role->id, $role->name);
		}
		$f->attr('value', $data['tfaRecRoleIDs']);
		$f->collapsed = Inputfield::collapsedBlank;
		$fieldset->add($f);

		/** @var InputfieldInteger $f */
		$f = $modules->get('InputfieldInteger');
		$f->attr('name', 'tfaRememberDays');
		$f->label = $this->_('Allow users the option to skip code entry when their browser/location is remembered?');
		$f->description =
			$this->_('This presents users with a “Remember this computer?” option on the code entry screen at login.') . ' ' .
			$this->_('Enter the number of days that a user’s browser/location can be remembered for, or 0 to disable.');
		$f->attr('value', (int) $data['tfaRememberDays']);
		$f->icon = 'unlock-alt';
		$fieldset->add($f);

		$fingerprints = array(
			'agent' => $this->_('User agent (browser, platform, and versions of each)'),
			'agentVL' => $this->_('Non-versioned user agent (browser and platform, but no versions—less likely to change often)'),
			'accept' => $this->_('Accept header (content types user’s browser accepts)'),
			'scheme' => $this->_('Current request scheme whether HTTP or HTTPS'),
			'host' => $this->_('Server hostname (value of $config->httpHost)'),
			'ip' => $this->_('User’s IP address (REMOTE_ADDR)'),
			'fwip' => $this->_('User’s forwarded or client IP address (HTTP_X_FORWARDED_FOR or HTTP_CLIENT_IP)'),
		);

		/** @var InputfieldCheckboxes $f */
		$f = $modules->get('InputfieldCheckboxes');
		$f->attr('name', 'tfaRememberFingerprints');
		$f->label = $this->_('Do not allow user to skip code entry when any of these properties change');
		$f->description =
			$this->_('Changes to password, name, email, or a random cookie in the user’s browser, will always require code entry at login.') . ' ' .
			$this->_('In addition, changes to any checked items below will also require code entry at login.') . ' ' .
			$this->_('These properties form a fingerprint of the user’s browser beyond the random cookie that we set.');
		$f->notes = $this->_('This setting only applies when the option to remember browser/location is enabled.');
		foreach($fingerprints as $name => $label) {
			$f->addOption($name, $label);
		}
		$f->showIf = 'tfaRememberDays!=0';
		$f->attr('value', $data['tfaRememberFingerprints']);
		$f->icon = 'lock';
		$fieldset->add($f);
		
		return $fieldset;
	}

}
