Subversion Repositories web.creative

Rev

Blame | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * An Inputfield for handling a password
 * 
 * ProcessWire 3.x, Copyright 2020 by Ryan Cramer
 * https://processwire.com
 * 
 * @property array $requirements Array of requirements (See require* constants)
 * @property array $requirementsLabels Text labels used for requirements
 * @property string $complexifyBanMode Complexify ban mode, 'loose' or 'strict' (default='loose')
 * @property float $complexifyFactor Complexify factor, lower numbers enable simpler passwords (default=0.7)
 * @property int $requireOld Require previous password? 0=Auto, 1=Yes, -1=No, Default=0 (Auto)
 * @property bool $showPass Allow password to be rendered in renderValue and/or re-populated in form?
 * @property string $defaultLabel Default label for field (default='Set Password'). Used when no 'label' has been set.
 * @property string $oldPassLabel Label for old/current password placeholder. 
 * @property string $newPassLabel Label for new password placeholder. 
 * @property string $confirmLabel Label for password confirm placeholder. 
 *
 */
class InputfieldPassword extends InputfieldText {

  public static function getModuleInfo() {
    return array(
      'title' => __('Password', __FILE__), // Module Title
      'summary' => __("Password input with confirmation field that doesn't ever echo the input back.", __FILE__), // Module Summary
      'version' => 102,
      'permanent' => true, 
      );
  }

  /**
   * Requirements: letter required
   * 
   */
  const requireLetter = 'letter';
  
  /**
   * Requirements: lowercase letter required
   *
   */
  const requireLowerLetter = 'lower';
  
  /**
   * Requirements: uppercase letter required
   *
   */
  const requireUpperLetter = 'upper';
  
  /**
   * Requirements: digit required
   *
   */
  const requireDigit = 'digit';
  
  /**
   * Requirements: other character (symbol) required
   *
   */
  const requireOther = 'other';

  /**
   * Requirements: disable all above
   * 
   */
  const requireNone = 'none';

  /**
   * Require old password before changes? Auto
   * 
   */
  const requireOldAuto = 0;
  
  /**
   * Require old password before changes? Yes
   *
   */
  const requireOldYes = 1;
  
  /**
   * Require old password before changes? No
   *
   */
  const requireOldNo = -1;

  /**
   * Page being edited, when applicable
   * 
   * @var User|Page|null
   * 
   */
  protected $_page = null;

  /**
   * Construct and establish default settings
   * 
   */
  public function __construct() {
    parent::__construct();
    $this->attr('type', 'password'); 
    $this->attr('size', 30); 
    $this->attr('maxlength', 256); 
    $this->attr('minlength', 6); 
    $this->set('requireOld', false);
    $this->set('requirements', array(self::requireLetter, self::requireDigit));
    $this->set('complexifyFactor', 0.7);
    $this->set('complexifyBanMode', 'loose'); 
    $this->set('requirementsLabels', array(
      self::requireLetter => $this->_('letter'),
      self::requireLowerLetter => $this->_('lowercase letter'),
      self::requireUpperLetter => $this->_('uppercase letter'),
      self::requireDigit => $this->_('digit'),
      self::requireOther => $this->_('symbol/punctuation'), 
      self::requireNone => $this->_('none (disable all above)'), 
    ));
    $this->set('showPass', false); // allow password to be rendered in renderValue and/or re-populated in form?
    $this->set('oldPassLabel', $this->_('Current password'));
    $this->set('newPassLabel', $this->_('New password'));
    $this->set('confirmLabel', $this->_('Confirm'));
  }

  /**
   * Init Inputfield, establishing the label if none has been set
   * 
   */
  public function init() {
    parent::init();
    $this->set('defaultLabel', $this->_('Set Password'));
    $this->label = $this->defaultLabel;
  }

  /**
   * Sets the page being edited, not always applicable
   * 
   * @param Page $page
   * 
   */
  public function setPage(Page $page) { 
    $this->_page = $page; 
    if($page->hasStatus(Page::statusUnpublished)) $this->required = true; 
  }

  /**
   * Called before render
   * 
   * @param Inputfield $parent
   * @param bool $renderValueMode
   * @return bool
   * @throws WireException
   * 
   */
  public function renderReady(Inputfield $parent = null, $renderValueMode = false) {
    if($this->label == 'Set Password') $this->label = $this->defaultLabel;
    $config = $this->wire('config');
    $url = $config->urls->InputfieldPassword . 'complexify/';
    $config->scripts->add($url . 'jquery.complexify.min.js');
    $config->scripts->add($url . 'jquery.complexify.banlist.js');
    $this->wire('modules')->get('JqueryCore')->use('xregexp');
    $page = $this->wire('page');
    if(($page && $page->template == 'admin') || $this->wire('user')->isLoggedin()) {
      $this->attr('autocomplete', 'new-password'); // ProcessProfile and ProcessUser
    }
    return parent::renderReady($parent, $renderValueMode);
  }

  /**
   * Render Password input(s)
   * 
   * @return string
   * 
   */
  public function ___render() {

    /** @var Sanitizer $sanitizer */
    $sanitizer = $this->wire('sanitizer');
    
    $minlength = (int) $this->attr('minlength');
    $requirements = array();
    if($minlength) {
      $requirements['minlength'] = "[span.pass-require.pass-require-minlength]" . 
        sprintf($this->_('at least %d characters long'), $minlength) . "[/span]";
    }
    $labels = $this->getSetting('requirementsLabels');
    if(!in_array(self::requireNone, $this->getSetting('requirements'))) {
      foreach($this->getSetting('requirements') as $name) {
        $requirements[$name] = "[span.pass-require.pass-require-$name]" . $labels[$name] . "[/span]";
      }
    }
    if(isset($requirements['upper']) || isset($requirements['lower'])) {
      unset($requirements['letter']); 
    }
    if(count($requirements)) {
      $description = implode(", ", $requirements);
      $description = sprintf($this->_('Minimum requirements: %s.'), $description);
      $description = trim("$description  " . $this->getSetting('description'));
      $this->set('description', $description);
    }
  
    $value = $this->attr('value'); 
    $confirmValue = '';
    
    $trackChanges = $this->trackChanges();
    if($trackChanges) $this->setTrackChanges(false);
    
    if(!$this->getSetting('showPass')) {
      $this->attr('value', '');
    } else {
      $confirmValue = $this->wire('sanitizer')->entities($value);
    }
    
    $this->attr('data-banMode', $this->complexifyBanMode ? $this->complexifyBanMode : 'loose');
    $this->attr('data-factor', (float) $this->complexifyFactor >= 0 ? $this->complexifyFactor : 0);
    
    $inputClass = $this->wire('sanitizer')->entities($this->attr('class'));
    $this->addClass('InputfieldPasswordComplexify');
    
    $failIcon = "<i class='fa fa-fw fa-frown-o'></i>";
    $okIcon = "<i class='fa fa-fw fa-meh-o'></i>";
    $goodIcon = "<i class='fa fa-fw fa-smile-o'></i>";
    $oldPassLabel = $sanitizer->entities1($this->oldPassLabel);
    $newPassLabel = $sanitizer->entities1($this->newPassLabel);
    $confirmLabel = $sanitizer->entities1($this->confirmLabel);
    $name = $this->attr('name');
    $id = $this->attr('id'); 
    $size = $this->attr('size');
    $out = '';
    
    if((int) $this->requireOld > 0 && $this->wire('user')->isLoggedin()) {
      $out .= 
        "<p class='InputfieldPasswordRow'>" .
          "<label for='_old_$name'>$oldPassLabel</label>" . 
          "<input placeholder='$oldPassLabel' class='InputfieldPasswordOld $inputClass' type='password' " . 
            "size='$size' id='_old_$name' name='_old_$name' value='' autocomplete='new-password' /> " .
        "</p>";
    }
    
    $out .=
      "<p class='InputfieldPasswordRow'>" .
        "<label for='$id'>$newPassLabel</label>" . 
        "<input placeholder='$newPassLabel' " . $this->getAttributesString() . " /> " . 
        "<span class='detail pass-scores' data-requirements='" . implode(' ', array_keys($requirements)) . "'>" .
          //"<span class='on'>$angleIcon$newPassLabel</span>" . 
          "<span class='pass-fail'>$failIcon" . $this->_('Not yet valid') . "</span>" .
          "<span class='pass-invalid'>$failIcon" . $this->_('Invalid') . "</span>" .
          "<span class='pass-short'>$failIcon" . $this->_('Too short') . "</span>" .
          "<span class='pass-common'>$failIcon" . $this->_('Too common') . "</span>" .
          "<span class='pass-same'>$failIcon" . $this->_('Same as old') . "</span>" . 
          "<span class='pass-weak'>$okIcon" . $this->_('Weak') . "</span>" .
          "<span class='pass-medium'>$okIcon" . $this->_('Ok') . "</span>" .
          "<span class='pass-good'>$goodIcon" . $this->_('Good') . "</span>" .
          "<span class='pass-excellent'>$goodIcon" . $this->_('Excellent') . "</span>" . 
        "</span>" . 
      "</p>" . 
      "<p class='InputfieldPasswordRow'>" .
        "<label for='_$id'>$confirmLabel</label>" . 
        "<input placeholder='$confirmLabel' class='InputfieldPasswordConfirm $inputClass' type='password' " . 
          "size='$size' id='_$id' name='_$name' value='$confirmValue' autocomplete='new-password' /> " . 
        "<span class='pass-confirm detail'>" . 
          //"<span class='confirm-pending on'>$angleIcon$newPassLabel ($confirmLabel)</span>" . 
          "<span class='confirm-yes'>$goodIcon" . $this->_('Matches') . "</span>" .
          "<span class='confirm-no'>$failIcon" . $this->_('Does not match') . "</span>" . 
          "<span class='confirm-qty'>$okIcon<span></span></span>" . 
        "</span>" . 
      "</p>";
    
    $this->attr('value', $value);
    if($trackChanges) $this->setTrackChanges(true);
    
    return $out; 
  }

  /**
   * Set Inputfield setting
   * 
   * @param string $key
   * @param mixed $value
   * @return Inputfield|InputfieldPassword
   * 
   */
  public function set($key, $value) {
    if($key == 'collapsed' && $this->_page && $this->_page->hasStatus(Page::statusUnpublished)) {
      // prevent collapse of field when pass is for unpublished user
      $value = Inputfield::collapsedNo; 
    }
    return parent::set($key, $value);
  }

  /**
   * Render non-editable Inputfield
   * 
   * @return string
   * 
   */
  public function ___renderValue() {
    if(!$this->getSetting('showPass')) {
      $value = strlen($this->attr('value')) ? '******' : '';
    } else {
      $value = $this->wire('sanitizer')->entities($this->attr('value'));
    }
    $value = strlen($value) ? "<p>$value</p>" : "";
    return $value; 
  }

  /**
   * Process input
   * 
   * @param WireInputData $input
   * @return $this
   * 
   */
  public function ___processInput(WireInputData $input) {

    parent::___processInput($input);

    /** @var User $user */
    $user = $this->wire('user');
    $key = $this->attr('name'); 
    $value = $this->attr('value');
    if($value) {}
    $confirmKey = "_" . $key;

    if(!isset($input->$key)) return $this;
    
    // form was submitted
    $pass = $input->$key;
    $confirmPass = $input->$confirmKey;

    if(strlen($pass) && strlen($confirmPass)) { 
      // password was submitted (with confirmation)
      $allowInput = true;
      
      if($this->requireOld > 0 && $user->isLoggedin()) {
        // old password is required to change
        $oldKey = "_old_" . $key;
        $oldPass = $input->$oldKey;
        if(!strlen($oldPass)) {
          $this->error($this->_('Old password is required in order to enter a new one.'));
          $allowInput = false;
        } else if(!$this->wire('user')->pass->matches($oldPass)) {
          $this->error($this->_('The old password you entered is not correct.'));
          $allowInput = false;
        }
      }

      if($allowInput) {
        if($confirmPass !== $pass) {
          $this->error($this->_("Passwords do not match"));
        }
        $this->isValidPassword($pass);
      }
      
    } else if($this->required) {
      $this->error($this->_("Required password was not specified")); 
    }

    if(count($this->getErrors())) {
      $this->attr('value', ''); 
      $this->resetTrackChanges(); // don't record a change
    }

    return $this;
  }

  /**
   * Return whether or not the given password is valid according to configured requirements
   * 
   * Exact error messages can be retrieved with $this->getErrors().
   * 
   * @param string $value Password to validate
   * @return bool
   * 
   */
  public function isValidPassword($value) {
    
    $numErrors = 0;
    $requirements = $this->getSetting('requirements');
    
    if(preg_match('/[\t\r\n]/', $value)) {
      $this->error($this->_("Password contained invalid whitespace"));
      $numErrors++;
    }

    if(strlen($value) < $this->attr('minlength')) {
      $this->error($this->_("Password is less than required number of characters"));
      $numErrors++;
    }

    if(in_array(self::requireNone, $requirements)) {
      // early exit if all following requirements are disabled
      return $numErrors === 0;
    }
    
    if(in_array(self::requireLetter, $requirements)) {
      // if(!preg_match('/[a-zA-Z]/', $value)) {
      if(!preg_match('/\p{L}/', $value)) {
        $this->error($this->_("Password does not contain at least one letter (a-z A-Z)"));
        $numErrors++;
      }
    }

    if(in_array(self::requireLowerLetter, $requirements)) {
      if(!preg_match('/\p{Ll}/', $value)) {
        $this->error($this->_("Password must have at least one lowercase letter (a-z)"));
        $numErrors++;
      }
    }

    if(in_array(self::requireUpperLetter, $requirements)) {
      if(!preg_match('/\p{Lu}/', $value)) {
        $this->error($this->_("Password must have at least one uppercase letter (A-Z)"));
        $numErrors++;
      }
    }

    if(in_array(self::requireDigit, $requirements)) {
      if(!preg_match('/\p{N}/', $value)) {
        $this->error($this->_("Password does not contain at least one digit (0-9)"));
        $numErrors++;
      }
    }

    if(in_array(self::requireOther, $requirements)) {
      if(!preg_match('/\p{P}/', $value) && !preg_match('/\p{S}/', $value)) {
        $this->error($this->_("Password must have at least one non-letter, non-digit character (like punctuation)"));
        $numErrors++;
      } 
    }
    
    return $numErrors === 0;
  }

  /**
   * Return the fields required to configure an instance of InputfieldPassword
   * 
   * @return InputfieldWrapper
   *
   */
  public function ___getConfigInputfields() {
    
    $inputfields = parent::___getConfigInputfields();
    $skips = array(
      'collapsed', 
      'showIf', 
      'placeholder', 
      'stripTags', 
      'pattern', 
      'visibility', 
      'minlength', 
      'maxlength', 
      'showCount',
      'size',
      );
    
    foreach($skips as $name) {
      $f = $inputfields->get($name);
      if($f) $inputfields->remove($f); 
    }
    
    $f = $this->wire('modules')->get('InputfieldCheckboxes'); 
    $f->attr('name', 'requirements');
    $f->label = $this->_('Password requirements');
    foreach($this->getSetting('requirementsLabels') as $value => $label) {
      $f->addOption($value, $label);
    }
    $value = $this->getSetting('requirements'); 
    if(in_array(self::requireNone, $value)) $value = array('none');
    $f->attr('value', $value);
    $f->columnWidth = 50;
    $inputfields->add($f);
    
    $f = $this->wire('modules')->get('InputfieldRadios');
    $f->attr('name', 'complexifyBanMode');
    $f->label = $this->_('Word ban mode');
    $f->description = $this->_('If you choose the strict mode, many passwords containing words will not be accepted.');
    $f->addOption('loose', $this->_('Ban just common passwords (recommended)'));
    $f->addOption('strict', $this->_('Ban all passwords containing any common words (strict)'));
    $f->attr('value', $this->complexifyBanMode);
    $f->columnWidth = 50;
    $inputfields->add($f);
    
    $f = $this->wire('modules')->get('InputfieldFloat');
    $f->attr('name', 'complexifyFactor');
    $f->label = $this->_('Complexify factor');
    $f->description = $this->_('Lower numbers allow weaker passwords, higher numbers require stronger passwords.');
    $f->description .= ' ' . $this->_('Specify -1 to disable this feature.'); 
    $f->notes = $this->_('We recommend something between 0.5 and 1.0');
    $f->attr('value', $this->complexifyFactor);
    $f->columnWidth = 50;
    $inputfields->add($f);

    $f = $this->wire('modules')->get('InputfieldInteger');
    $f->attr('name', 'minlength'); 
    $f->label = $this->_('Minimum password length'); 
    $f->attr('value', $this->attr('minlength'));
    $f->attr('min', 3); 
    $f->columnWidth = 50;
    $inputfields->add($f);
    
    if(!$this->getSetting('hasFieldtype')) {
      $f = $this->wire('modules')->get('InputfieldCheckbox');
      $f->attr('name', 'showPass');
      $f->label = $this->_('Allow existing passwords to be shown and/or rendered in form?');
      if($this->getSetting("showPass")) $f->attr('checked', 'checked');
      $inputfields->add($f);
    }
    
    $f = $this->wire('modules')->get('InputfieldRadios');
    $f->attr('name', 'requireOld'); 
    $f->label = $this->_('Require old password before allowing changes?');
    $f->description = $this->_('Applies to usages where this field appears to already logged-in users only.');
    $f->addOption(self::requireOldAuto, $this->_('Auto')); 
    $f->addOption(self::requireOldYes, $this->_('Yes'));
    $f->addOption(self::requireOldNo, $this->_('No'));
    $f->optionColumns = 1; 
    $f->attr('value', (int) $this->requireOld);
    $inputfields->add($f);
    
    return $inputfields; 
  }
}