artabro/wire/modules/Inputfield/InputfieldPassword/InputfieldPassword.module
2024-08-27 11:35:37 +02:00

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