548 lines
17 KiB
Text
548 lines
17 KiB
Text
<?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 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() {
|
|
|
|
$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;
|
|
}
|
|
}
|