Rev 22 | Blame | Compare with Previous | Last modification | View Log | Download
<?php namespace ProcessWire;/*** An Inputfield for handling a password** ProcessWire 3.x, Copyright 2023 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 bool $unmask Allow passwords to be unmasked? (3.0.173+)* @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('showPass', false); // allow password to be rendered in renderValue and/or re-populated in form?$this->set('unmask', false);}/*** Init Inputfield, establishing the label if none has been set**/public function init() {parent::init();$defaultLabel = $this->_('Set Password');$this->set('defaultLabel', $defaultLabel);$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('oldPassLabel', $this->_('Current password'));$this->set('newPassLabel', $this->_('New password'));$this->set('confirmLabel', $this->_('Confirm'));$this->label = $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');$jQueryCore = $this->wire()->modules->get('JqueryCore'); /** @var JqueryCore $jQueryCore */$jQueryCore->use('xregexp');$page = $this->wire()->page;if(($page && $page->template->name == '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() {$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 = $sanitizer->entities($value);}$this->attr('data-banMode', $this->complexifyBanMode ? $this->complexifyBanMode : 'loose');$this->attr('data-factor', (float) $this->complexifyFactor >= 0 ? str_replace(',', '.', "$this->complexifyFactor") : 0);$inputClass = $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>";if($this->unmask) {$out .="<p class='pass-mask detail'>" ."<a class='pass-mask-show' href='#'>" . $this->_('Show Password') . "</a>" ."<a class='pass-mask-hide' href='#'>" . $this->_('Hide Password') . "</a>" ."</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);$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 = (string) $input->$key;$confirmPass = (string) $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(!$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 disabledreturn $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() {$modules = $this->wire()->modules;$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);}/** @var InputfieldCheckboxes $f */$f = $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);/** @var InputfieldRadios $f */$f = $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);/** @var InputfieldFloat $f */$f = $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);/** @var InputfieldInteger $f */$f = $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')) {/** @var InputfieldCheckbox $f */$f = $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);}/** @var InputfieldRadios $f */$f = $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);/** @var InputfieldRadios $f */$f = $modules->get('InputfieldRadios');$f->attr('name', 'unmask');$f->label = $this->_('Allow user to show/unmask password during changes?');$f->description = $this->_('Provides a show/hide password control so users can see what they type when in an appropriate environment.');$f->addOption(1, $this->_('Yes'));$f->addOption(0, $this->_('No'));$f->optionColumns = 1;$f->attr('value', (int) $this->unmask);$inputfields->add($f);return $inputfields;}}