Subversion Repositories web.active

Rev

Rev 22 | Blame | Compare with Previous | Last modification | View Log | Download

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

}