Implement password policy with hook

This commit is contained in:
Jolien Trog 2025-08-26 10:48:56 +02:00
parent 8603044881
commit 95f0c93c73
8 changed files with 250 additions and 11 deletions

View File

@ -3,11 +3,15 @@
namespace Icinga\Forms\Account;
use Icinga\Application\Config;
use Icinga\Authentication\PasswordValidator;
use Icinga\Authentication\User\DbUserBackend;
use Icinga\Data\Filter\Filter;
use Icinga\User;
use Icinga\Web\Form;
use Icinga\Web\Notification;
use ipl\Html\Text;
use Icinga\Forms\Config\GeneralConfigForm;
/**
* Form for changing user passwords
@ -29,11 +33,19 @@ class ChangePasswordForm extends Form
$this->setSubmitLabel($this->translate('Update Account'));
}
/**
* {@inheritdoc}
* @throws \Zend_Validate_Exception
*/
public function createElements(array $formData)
{
$passwordPolicy = Config::app()->get('global', 'password_policy');
if(isset($passwordPolicy) && class_exists($passwordPolicy)) {
$passwordPolicyObject = new $passwordPolicy();
$this->addDescription($passwordPolicyObject->displayPasswordPolicy());
}
$this->addElement(
'password',
'old_password',
@ -47,7 +59,8 @@ class ChangePasswordForm extends Form
'new_password',
array(
'label' => $this->translate('New Password'),
'required' => true
'required' => true,
'validators' => array(new PasswordValidator())
)
);
$this->addElement(
@ -63,6 +76,7 @@ class ChangePasswordForm extends Form
);
}
/**
* {@inheritdoc}
*/

View File

@ -0,0 +1,66 @@
<?php
namespace Icinga\Forms\Config\General;
use Icinga\Application\Hook;
use Icinga\Web\Form;
/**
* Configuration form for password policy selection
*
* This form is not used directly but as subform for the {@link GeneralConfigForm}.
*/
class PasswordPolicyConfigForm extends Form
{
/**
* {@inheritdoc}
*/
public function init(): void
{
$this->setName('form_config_general_password_policy');
}
/**
* {@inheritdoc}
*
* @return $this
*/
public function createElements(array $formData)
{
$this->addElement(
'checkbox',
'global_password_policy',
array(
'label' => $this->translate('Password Policy'),
'value' => true,
'description' => $this->translate(
'Enforce strong password requirements for new passwords'
),
)
);
$passwordPolicies = [];
foreach (Hook::all('passwordpolicy') as $class => $policy) {
$passwordPolicies[$class] = $policy->getName();
}
asort($passwordPolicies);
$this->addElement(
'select',
'global_password_policy',
array(
'description' => $this->translate('Enforce strong '.
'password requirements for new passwords'),
'label' => $this->translate('Password Policy'),
'multiOptions' => array_merge(
['' => sprintf(' - %s - ',
$this->translate('No Password Policy'))],
$passwordPolicies
),
)
);
return $this;
}
}

View File

@ -7,6 +7,7 @@ use Icinga\Forms\Config\General\ApplicationConfigForm;
use Icinga\Forms\Config\General\DefaultAuthenticationDomainConfigForm;
use Icinga\Forms\Config\General\LoggingConfigForm;
use Icinga\Forms\Config\General\ThemingConfigForm;
use Icinga\Forms\Config\General\PasswordPolicyConfigForm;
use Icinga\Forms\ConfigForm;
/**
@ -32,9 +33,11 @@ class GeneralConfigForm extends ConfigForm
$loggingConfigForm = new LoggingConfigForm();
$themingConfigForm = new ThemingConfigForm();
$domainConfigForm = new DefaultAuthenticationDomainConfigForm();
$passwordPolicyConfigForm = new PasswordPolicyConfigForm();
$this->addSubForm($appConfigForm->create($formData));
$this->addSubForm($loggingConfigForm->create($formData));
$this->addSubForm($themingConfigForm->create($formData));
$this->addSubForm($domainConfigForm->create($formData));
$this->addSubForm($passwordPolicyConfigForm->create($formData));
}
}

View File

@ -3,7 +3,9 @@
namespace Icinga\Forms\Config\User;
use Icinga\Application\Config;
use Icinga\Application\Hook\ConfigFormEventsHook;
use Icinga\Authentication\PasswordValidator;
use Icinga\Data\Filter\Filter;
use Icinga\Forms\RepositoryForm;
use Icinga\Web\Notification;
@ -15,8 +17,15 @@ class UserForm extends RepositoryForm
*
* @param array $formData The data sent by the user
*/
protected function createInsertElements(array $formData)
{
$passwordPolicy = Config::app()->get('global', 'password_policy');
if(isset($passwordPolicy) && class_exists($passwordPolicy)) {
$passwordPolicyObject = new $passwordPolicy();
$this->addDescription($passwordPolicyObject->displayPasswordPolicy());
}
$this->addElement(
'checkbox',
'is_active',
@ -39,7 +48,8 @@ class UserForm extends RepositoryForm
'password',
array(
'required' => true,
'label' => $this->translate('Password')
'label' => $this->translate('Password'),
'validators' => array(new PasswordValidator())
)
);

View File

@ -7,6 +7,7 @@ use DirectoryIterator;
use ErrorException;
use Exception;
use Icinga\Application\ProvidedHook\DbMigration;
use Icinga\Application\ProvidedHook\DefaultPasswordPolicy;
use ipl\I18n\GettextTranslator;
use ipl\I18n\StaticTranslator;
use LogicException;
@ -740,6 +741,7 @@ abstract class ApplicationBootstrap
protected function registerApplicationHooks(): self
{
Hook::register('DbMigration', DbMigration::class, DbMigration::class);
Hook::register('passwordpolicy', DefaultPasswordPolicy::class, DefaultPasswordPolicy::class);
return $this;
}

View File

@ -0,0 +1,30 @@
<?php
namespace Icinga\Application\Hook;
interface PasswordPolicyHook
{
/**
* Get the name of the password policy
*
* @return string
*/
public function getName(): string;
/**
* Displays the rules of the password policy for users
*
* @return string
*/
public function displayPasswordPolicy(): string;
/**
* Validate a given password against the defined policy
*
* @param string $password
* @return string|null Returns null if the password is valid,
* otherwise returns an error message describing the violations
*/
public function validatePassword(string $password): ?string;
}

View File

@ -0,0 +1,73 @@
<?php
namespace Icinga\Application\ProvidedHook;
use Icinga\Application\Hook\PasswordPolicyHook;
/**
* Default implementation of a password policy
*
* Enforces:
* - Minimum length of 12 characters
* - At least one number
* - At least one special character
* - At least one uppercase letter
* - At least one lowercase letter
*/
class DefaultPasswordPolicy implements PasswordPolicyHook
{
/**
* @inheritdoc
*/
public function getName(): string
{
return 'Default Password Policy';
}
/**
* @inheritdoc
*/
public function displayPasswordPolicy(): string
{
$message = (
'Password requirements: ' . 'minimum 12 characters, ' .
'at least 1 number, ' .
'1 special character, ' . 'uppercase and lowercase letters'
);
return $message;
}
/**
* @inheritdoc
*/
public function validatePassword(string $password): ?string
{
$violations = [];
if (strlen($password) < 12) {
$violations[] = ('Password must be at least 12 characters long');
}
if (! preg_match('/[0-9]/', $password)) {
$violations[] = ('Password must contain at least one number');
}
if (! preg_match('/[^a-zA-Z0-9]/', $password)) {
$violations[] = ('Password must contain at least one special character');
}
if (! preg_match('/[A-Z]/', $password)) {
$violations[] = ('Password must contain at least one uppercase letter');
}
if (! preg_match('/[a-z]/', $password)) {
$violations[] = ('Password must contain at least one lowercase letter');
}
if (! empty($violations)) {
return implode(", ", $violations);
}
return null;
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace Icinga\Authentication;
use Icinga\Application\Config;
use Zend_Validate_Abstract;
class PasswordValidator extends Zend_Validate_Abstract
{
/**
* Checks if password matches with password policy
* throws a message if not
*
* If no password policy is configured, all passwords are considered valid
*
* @param mixed $value The password to validate
*
* @return bool
*
*/
public function isValid($value): bool
{
$this->_messages = [];
$passwordPolicy = Config::app()
->get('global', 'password_policy');
if (! isset($passwordPolicy) || ! class_exists($passwordPolicy)) {
return true;
}
$passwordPolicyObject = new $passwordPolicy();
$errorMessage = $passwordPolicyObject->validatePassword($value);
if ($errorMessage != null) {
$this->_messages[] = $errorMessage;
return false;
}
return true;
}
}