diff --git a/application/forms/Account/ChangePasswordForm.php b/application/forms/Account/ChangePasswordForm.php index cdf222b60b..4c21a199dd 100644 --- a/application/forms/Account/ChangePasswordForm.php +++ b/application/forms/Account/ChangePasswordForm.php @@ -5,9 +5,9 @@ namespace Icinga\Forms\Account; +use Icinga\Authentication\PasswordPolicyHelper; use Icinga\Authentication\User\DbUserBackend; use Icinga\Data\Filter\Filter; -use Icinga\User; use Icinga\Web\Form; use Icinga\Web\Notification; @@ -40,27 +40,27 @@ public function createElements(array $formData) 'password', 'old_password', [ - 'label' => $this->translate('Old Password'), - 'required' => true + 'label' => $this->translate('Old Password'), + 'required' => true ] ); $this->addElement( 'password', 'new_password', [ - 'label' => $this->translate('New Password'), - 'required' => true + 'label' => $this->translate('New Password'), + 'required' => true ] ); + PasswordPolicyHelper::apply($this, 'new_password', 'old_password'); + $this->addElement( 'password', 'new_password_confirmation', [ - 'label' => $this->translate('Confirm New Password'), - 'required' => true, - 'validators' => [ - ['identical', false, ['new_password']] - ] + 'label' => $this->translate('Confirm New Password'), + 'required' => true, + 'validators' => [['identical', false, ['new_password']]] ] ); } diff --git a/application/forms/Config/General/PasswordPolicyConfigForm.php b/application/forms/Config/General/PasswordPolicyConfigForm.php new file mode 100644 index 0000000000..65062d9b98 --- /dev/null +++ b/application/forms/Config/General/PasswordPolicyConfigForm.php @@ -0,0 +1,70 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Forms\Config\General; + +use Exception; +use Icinga\Application\Config; +use Icinga\Application\Hook\PasswordPolicyHook; +use Icinga\Application\Logger; +use Icinga\Authentication\PasswordPolicyHelper; +use Icinga\Exception\IcingaException; +use Icinga\Web\Form; +use Throwable; + +/** + * Configuration form for password policy selection + * + * This form is not used directly but as subform for the {@see GeneralConfigForm}. + */ +class PasswordPolicyConfigForm extends Form +{ + /** + * @param Config $config The config to load the configured policy from + */ + public function __construct(protected Config $config) + { + parent::__construct(); + } + + public function init(): void + { + $this->setName('form_config_general_password_policy'); + } + + public function createElements(array $formData): static + { + $defaultPolicy = PasswordPolicyHook::DEFAULT_PASSWORD_POLICY; + $elementName = sprintf('%s_%s', PasswordPolicyHook::CONFIG_SECTION, PasswordPolicyHook::CONFIG_KEY); + $this->addElement('select', $elementName, [ + 'description' => $this->translate('Enforce password requirements for new passwords'), + 'label' => $this->translate('Password Policy'), + 'value' => $defaultPolicy, + 'multiOptions' => iterator_to_array(PasswordPolicyHook::yieldPolicies()), + 'autosubmit' => true, + ]); + + try { + $policy = PasswordPolicyHook::fromCanonicalName($formData[$elementName] ?? $defaultPolicy); + } catch (Throwable $e) { + Logger::error("%s\n%s", $e, IcingaException::getConfidentialTraceAsString($e)); + PasswordPolicyHelper::addError($this, true); + + return $this; + } + + PasswordPolicyHelper::addDescription($this, $policy); + + // Surface a load error if the saved policy is unavailable, e.g. because + // its providing module was disabled. The result is intentionally discarded. + try { + PasswordPolicyHook::loadConfigured($this->config); + } catch (Throwable) { + PasswordPolicyHelper::addError($this, true); + } + + return $this; + } +} diff --git a/application/forms/Config/GeneralConfigForm.php b/application/forms/Config/GeneralConfigForm.php index b64b5b085b..b36cf9a3f2 100644 --- a/application/forms/Config/GeneralConfigForm.php +++ b/application/forms/Config/GeneralConfigForm.php @@ -9,6 +9,7 @@ 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; /** @@ -34,9 +35,11 @@ public function createElements(array $formData) $loggingConfigForm = new LoggingConfigForm(); $themingConfigForm = new ThemingConfigForm(); $domainConfigForm = new DefaultAuthenticationDomainConfigForm(); + $passwordPolicyConfigForm = new PasswordPolicyConfigForm($this->config); $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)); } } diff --git a/application/forms/Config/User/UserForm.php b/application/forms/Config/User/UserForm.php index 7eb342cb57..78fc0f629d 100644 --- a/application/forms/Config/User/UserForm.php +++ b/application/forms/Config/User/UserForm.php @@ -6,6 +6,7 @@ namespace Icinga\Forms\Config\User; use Icinga\Application\Hook\ConfigFormEventsHook; +use Icinga\Authentication\PasswordPolicyHelper; use Icinga\Data\Filter\Filter; use Icinga\Forms\RepositoryForm; use Icinga\Web\Notification; @@ -13,37 +14,53 @@ class UserForm extends RepositoryForm { /** - * Create and add elements to this form to insert or update a user + * Create and add common elements to this form * - * @param array $formData The data sent by the user + * @param array $formData The data sent by the user + * + * @return void */ - protected function createInsertElements(array $formData) + protected function createCommonElements(array $formData): void { $this->addElement( 'checkbox', 'is_active', [ - 'value' => true, - 'label' => $this->translate('Active'), - 'description' => $this->translate('Prevents the user from logging in if unchecked') + 'value' => true, + 'label' => $this->translate('Active'), + 'description' => $this->translate('Prevents the user from logging in if unchecked') ] ); $this->addElement( 'text', 'user_name', [ - 'required' => true, - 'label' => $this->translate('Username') + 'required' => true, + 'label' => $this->translate('Username') ] ); + } + + /** + * Create and add elements to this form to insert or update a user + * + * @param array $formData The data sent by the user + * + * @return void + */ + protected function createInsertElements(array $formData) + { + $this->createCommonElements($formData); + $this->addElement( 'password', 'password', [ - 'required' => true, - 'label' => $this->translate('Password') + 'required' => true, + 'label' => $this->translate('Password') ] ); + PasswordPolicyHelper::apply($this, 'password'); $this->setTitle($this->translate('Add a new user')); $this->setSubmitLabel($this->translate('Add')); @@ -52,11 +69,13 @@ protected function createInsertElements(array $formData) /** * Create and add elements to this form to update a user * - * @param array $formData The data sent by the user + * @param array $formData The data sent by the user + * + * @return void */ protected function createUpdateElements(array $formData) { - $this->createInsertElements($formData); + $this->createCommonElements($formData); $this->addElement( 'password', @@ -66,6 +85,7 @@ protected function createUpdateElements(array $formData) 'label' => $this->translate('Password'), ] ); + PasswordPolicyHelper::apply($this, 'password'); $this->setTitle(sprintf($this->translate('Edit user %s'), $this->getIdentifier())); $this->setSubmitLabel($this->translate('Save')); diff --git a/doc/05-Authentication.md b/doc/05-Authentication.md index deb1beaf67..e0dbff21e5 100644 --- a/doc/05-Authentication.md +++ b/doc/05-Authentication.md @@ -158,6 +158,117 @@ resource = icingaweb-mysql Please read [this chapter](20-Advanced-Topics.md#advanced-topics-authentication-tips-manual-user-database-auth) in order to manually create users directly inside the database. +### Password Policy + +Icinga Web supports password policies when using database authentication. You can configure this under +**Configuration > Application > General**. + +By default, no password policy is enforced (`None`). Icinga Web provides a built-in policy called `Common` with the +following requirements: + +* Minimum length of 12 characters +* At least one number +* At least one special character +* At least one lowercase letter +* At least one uppercase letter + +#### Custom Password Policy + +You can create custom password policies by developing a module with a provided hook. + +**Create Module Structure** + +```bash +mkdir -p /usr/share/icingaweb2/modules/mypasswordpolicy/library/Mypasswordpolicy/ProvidedHook +cd /usr/share/icingaweb2/modules/mypasswordpolicy +``` + +Create `module.info`: + +```ini +Module: mypasswordpolicy +Name: My Password Policy +Version: 1.0.0 +Description: Custom password policy implementation +``` + +**Implement the Hook** + +Icinga Web provides the `PasswordPolicyHook` with predefined methods that simplify the extension of custom password +policies. + +Create `library/Mypasswordpolicy/ProvidedHook/PasswordPolicy.php`: + +```php +translate('My Custom Policy'); + } + + public function getName(): string + { + return 'my-custom-policy'; + } + + public function getDescription(): ?ValidHtml + { + return new Text( + $this->translate('More than 8 chars, at least 1 number, and must differ from the last password'), + ); + } + + public function validate(string $newPassword, ?string $oldPassword = null): array + { + $violations = []; + + if (strlen($newPassword) < 8) { + $violations[] = 'Password must be at least 8 characters'; + } + + if (! preg_match('/[0-9]/', $newPassword)) { + $violations[] = 'Password must contain at least one number'; + } + + if ($oldPassword !== null && hash_equals($oldPassword, $newPassword)) { + $violations[] = 'New password must be different from the old password'; + } + + return $violations; + } +} +``` + +**Register the Hook** + +Create `run.php`: + +```php + Application > General** under Password Policy. ## Groups diff --git a/library/Icinga/Application/ApplicationBootstrap.php b/library/Icinga/Application/ApplicationBootstrap.php index 7edee572ac..7ff3ed2633 100644 --- a/library/Icinga/Application/ApplicationBootstrap.php +++ b/library/Icinga/Application/ApplicationBootstrap.php @@ -8,16 +8,18 @@ use DirectoryIterator; use ErrorException; use Exception; -use Icinga\Application\ProvidedHook\DbMigration; -use ipl\I18n\GettextTranslator; -use ipl\I18n\StaticTranslator; -use LogicException; use Icinga\Application\Modules\Manager as ModuleManager; +use Icinga\Application\ProvidedHook\AnyPasswordPolicy; +use Icinga\Application\ProvidedHook\CommonPasswordPolicy; +use Icinga\Application\ProvidedHook\DbMigration; use Icinga\Authentication\User\UserBackend; use Icinga\Data\ConfigObject; use Icinga\Exception\ConfigurationError; -use Icinga\Exception\NotReadableError; use Icinga\Exception\IcingaException; +use Icinga\Exception\NotReadableError; +use ipl\I18n\GettextTranslator; +use ipl\I18n\StaticTranslator; +use LogicException; /** * This class bootstraps a thin Icinga application layer @@ -742,6 +744,8 @@ public function hasLocales() protected function registerApplicationHooks(): self { DbMigration::register(); + CommonPasswordPolicy::register(); + AnyPasswordPolicy::register(); return $this; } diff --git a/library/Icinga/Application/Hook/PasswordPolicyHook.php b/library/Icinga/Application/Hook/PasswordPolicyHook.php new file mode 100644 index 0000000000..772a6849ba --- /dev/null +++ b/library/Icinga/Application/Hook/PasswordPolicyHook.php @@ -0,0 +1,137 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Application\Hook; + +use Icinga\Application\Config; +use Icinga\Application\Logger; +use Icinga\Authentication\PasswordPolicy; +use Generator; +use RuntimeException; + +/** + * Base class for hookable password policies + */ +abstract class PasswordPolicyHook implements PasswordPolicy +{ + use HookEssentials { + all as private essentialsAll; + } + + /** @var string Default password policy class */ + public const DEFAULT_PASSWORD_POLICY = 'any'; + + /** @var string INI configuration section for password policy */ + public const CONFIG_SECTION = 'security'; + + /** @var string INI configuration key for password policy */ + public const CONFIG_KEY = 'password_policy'; + + final protected static function getHookName(): string + { + return 'PasswordPolicy'; + } + + /** + * Get whether the hook always runs without a permission check + * + * Password policies are a system hook and should always run for every user + * regardless of the user's permission to access the module. + */ + final protected static function isAlwaysRun(): bool + { + return true; + } + + /** + * Get all registered password policies + * + * Password policies are sorted by their display name. + * + * @return list + */ + final public static function all(): array + { + $policies = []; + foreach (self::essentialsAll() as $policy) { + if (! ($policy instanceof PasswordPolicy)) { + Logger::warning('Password policy %s is not an instance of PasswordPolicy', $policy::class); + + continue; + } + + $policies[] = $policy; + } + + usort($policies, fn(PasswordPolicy $a, PasswordPolicy $b) => $a->getDisplayName() <=> $b->getDisplayName()); + + return $policies; + } + + /** + * Get a password policy instance by its canonical name + * + * @param string $canonicalName The canonical name of the password policy + * + * @return PasswordPolicyHook + * + * @throws RuntimeException If no such policy is registered + */ + final public static function fromCanonicalName(string $canonicalName): self + { + foreach (self::all() as $policy) { + if ($policy->getCanonicalName() === $canonicalName) { + return $policy; + } + } + + throw new RuntimeException("No password policy found for canonical name '$canonicalName'"); + } + + /** + * Get the currently configured password policy + * + * @param Config $config Configuration containing the password policy + * + * @return PasswordPolicyHook + */ + final public static function loadConfigured(Config $config): self + { + return self::fromCanonicalName( + $config->get(self::CONFIG_SECTION, self::CONFIG_KEY, self::DEFAULT_PASSWORD_POLICY) + ); + } + + /** + * Yield display names indexed by the canonical policy name + * + * @return Generator + */ + final public static function yieldPolicies(): Generator + { + foreach (self::all() as $policy) { + yield $policy->getCanonicalName() => $policy->getDisplayName(); + } + } + + /** + * Get the globally unique identifier for this password policy + * + * Combines the providing module's name with {@see getDisplayName()}, e.g. 'mymodule/password-policy', + * so that two modules may register a method using the same {@see getDisplayName()} without + * colliding. Falls back to the plain {@see getDisplayName()} for hook classes that are not + * part of a module namespace. + * + * @return string + */ + final public function getCanonicalName(): string + { + if ($module = $this->getModule()?->getName()) { + return sprintf('%s/%s', $module, $this->getName()); + } + + return $this->getName(); + } +} diff --git a/library/Icinga/Application/ProvidedHook/AnyPasswordPolicy.php b/library/Icinga/Application/ProvidedHook/AnyPasswordPolicy.php new file mode 100644 index 0000000000..2732490ff0 --- /dev/null +++ b/library/Icinga/Application/ProvidedHook/AnyPasswordPolicy.php @@ -0,0 +1,45 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Application\ProvidedHook; + +use Icinga\Application\Hook\PasswordPolicyHook; +use ipl\Html\ValidHtml; +use ipl\I18n\Translation; + +/** + * Policy to allow any password + */ +class AnyPasswordPolicy extends PasswordPolicyHook +{ + use Translation; + + /** + * Get the human-readable name of the password policy + * + * Policy is named 'None' to indicate that no password policy is enforced and any password is accepted. + * + * @return string + */ + public function getDisplayName(): string + { + return $this->translate('None'); + } + + public function getName(): string + { + return self::DEFAULT_PASSWORD_POLICY; + } + + public function getDescription(): ?ValidHtml + { + return null; + } + + public function validate(string $newPassword, ?string $oldPassword = null): array + { + return []; + } +} diff --git a/library/Icinga/Application/ProvidedHook/CommonPasswordPolicy.php b/library/Icinga/Application/ProvidedHook/CommonPasswordPolicy.php new file mode 100644 index 0000000000..3342123586 --- /dev/null +++ b/library/Icinga/Application/ProvidedHook/CommonPasswordPolicy.php @@ -0,0 +1,70 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Application\ProvidedHook; + +use Icinga\Application\Hook\PasswordPolicyHook; +use ipl\Html\Text; +use ipl\Html\ValidHtml; +use ipl\I18n\Translation; + +/** + * Implementation of a common 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 CommonPasswordPolicy extends PasswordPolicyHook +{ + use Translation; + + final public function getDisplayName(): string + { + return $this->translate('Common'); + } + + final public function getName(): string + { + return 'common'; + } + + public function getDescription(): ?ValidHtml + { + return new Text($this->translate( + 'Minimum 12 characters, at least 1 number, 1 special character, lowercase and uppercase letters.', + )); + } + + public function validate(string $newPassword, ?string $oldPassword = null): array + { + $violations = []; + + if (mb_strlen($newPassword) < 12) { + $violations[] = $this->translate('Password must be at least 12 characters long'); + } + + if (! preg_match('/[0-9]/', $newPassword)) { + $violations[] = $this->translate('Password must contain at least one number'); + } + + if (! preg_match('/[^a-zA-Z0-9]/', $newPassword)) { + $violations[] = $this->translate('Password must contain at least one special character'); + } + + if (! preg_match('/[A-Z]/', $newPassword)) { + $violations[] = $this->translate('Password must contain at least one uppercase letter'); + } + + if (! preg_match('/[a-z]/', $newPassword)) { + $violations[] = $this->translate('Password must contain at least one lowercase letter'); + } + + return $violations; + } +} diff --git a/library/Icinga/Authentication/PasswordPolicy.php b/library/Icinga/Authentication/PasswordPolicy.php new file mode 100644 index 0000000000..0a6e9f7be8 --- /dev/null +++ b/library/Icinga/Authentication/PasswordPolicy.php @@ -0,0 +1,57 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Authentication; + +use ipl\Html\ValidHtml; + +/** + * Contract for password policy implementations + * + * A policy validates passwords and exposes metadata for the configuration + * UI: a display name for selection, a machine-readable name for + * persistence, and an optional description of its requirements. + */ +interface PasswordPolicy +{ + /** + * Get the human-readable name of the password policy + * + * @return string + */ + public function getDisplayName(): string; + + /** + * Get the machine-readable name of the password policy + * + * Used to identify the policy in configuration. Must be unique across all policies + * provided by a module. + * + * @return string + */ + public function getName(): string; + + /** + * Get the description of the password policy + * + * Shown in the form when the policy is active. Describe the requirements the policy + * enforces so that users know what to enter before submitting. + * + * @return ?ValidHtml Null if the policy provides no description + */ + public function getDescription(): ?ValidHtml; + + /** + * Validate a password against the policy + * + * @param string $newPassword The new password to validate + * @param ?string $oldPassword The current password, if available, for policies that + * verify the new password differs from the old one + * + * @return list Empty list if valid. One message per violation describing why + * the password was rejected + */ + public function validate(string $newPassword, ?string $oldPassword = null): array; +} diff --git a/library/Icinga/Authentication/PasswordPolicyHelper.php b/library/Icinga/Authentication/PasswordPolicyHelper.php new file mode 100644 index 0000000000..7256c8d035 --- /dev/null +++ b/library/Icinga/Authentication/PasswordPolicyHelper.php @@ -0,0 +1,121 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Authentication; + +use Icinga\Application\Config; +use Icinga\Application\Hook\PasswordPolicyHook; +use Icinga\Application\Logger; +use Icinga\Exception\IcingaException; +use Icinga\Web\Form; +use ipl\Web\Common\CalloutType; +use ipl\Web\Compat\DisplayFormElement; +use ipl\Web\Widget\Callout; +use LogicException; +use Throwable; + +/** + * Helper class for password policy configuration + * + * Allows for loading and applying the configured password policy. The password policy + * is loaded from the application configuration and attached to the given form element. + * The description of the policy is also added to the form. In case of an error, + * a warning is displayed to the user. + */ +class PasswordPolicyHelper +{ + /** + * Apply the configured password policy to the given form element + * + * Load the configured password policy, fall back to a warning if the policy + * configuration is invalid. The description of the policy is also added to the form. + * On success, attaches the policy validator to the given new-password form element. + * + * @param Form $form The form containing the elements and to attach the elements to + * @param string $newPasswordElementName Name of the new password form element + * @param ?string $oldPasswordElementName Optional name of the old password form + * element for comparison + * + * @return void + * + * @throws LogicException If the old password element is specified but does not exist in the form + */ + public static function apply( + Form $form, + string $newPasswordElementName, + ?string $oldPasswordElementName = null + ): void { + if ($oldPasswordElementName !== null && $form->getElement($oldPasswordElementName) === null) { + throw new LogicException(sprintf( + t('Form element "%s" was specified but does not exist in the form'), + $oldPasswordElementName + )); + } + + try { + $passwordPolicy = PasswordPolicyHook::loadConfigured(Config::app()); + } catch (Throwable $e) { + Logger::error("%s\n%s", $e, IcingaException::getConfidentialTraceAsString($e)); + static::addError($form); + + return; + } + + $form->getElement($newPasswordElementName)->addValidator( + new PasswordPolicyValidator($passwordPolicy, $oldPasswordElementName) + ); + static::addDescription($form, $passwordPolicy); + } + + /** + * Add the password policy description to the form as an info callout + * + * @param Form $form The form to attach the description callout to + * @param PasswordPolicy $passwordPolicy The policy to retrieve the description from + * + * @return void + */ + public static function addDescription(Form $form, PasswordPolicy $passwordPolicy): void + { + try { + $description = $passwordPolicy->getDescription(); + } catch (Throwable $e) { + Logger::error("%s\n%s", $e, IcingaException::getConfidentialTraceAsString($e)); + + return; + } + + if ($description === null) { + return; + } + + $form->addElement('note', 'policy-note-callout', [ + 'decorators' => ['ViewHelper'], + 'value' => (new DisplayFormElement( + new Callout(CalloutType::Info, $description, t('Password requirements')), + ))->render(), + ]); + } + + /** + * Add a password policy load-error callout to the form + * + * @param Form $form The form to attach the error callout to + * @param bool $forAdmin Whether the error message targets an administrator + * + * @return void + */ + public static function addError(Form $form, bool $forAdmin = false): void + { + $errorMessage = $forAdmin + ? t('There was a problem loading the configured password policy.') + : t('There was a problem loading the configured password policy. Please contact your administrator.'); + + $form->addElement('note', 'policy-error-callout', [ + 'decorators' => ['ViewHelper'], + 'value' => (new DisplayFormElement(new Callout(CalloutType::Error, $errorMessage)))->render(), + ]); + } +} diff --git a/library/Icinga/Authentication/PasswordPolicyValidator.php b/library/Icinga/Authentication/PasswordPolicyValidator.php new file mode 100644 index 0000000000..2f69c452f3 --- /dev/null +++ b/library/Icinga/Authentication/PasswordPolicyValidator.php @@ -0,0 +1,69 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Authentication; + +use ipl\Stdlib\Str; +use Zend_Validate_Abstract; + +/** + * Validate passwords against a configured policy + * + * Optionally, retrieve the old password from the form context using the configured form element name + * and pass it to the policy for validation. + * Delegate all validation logic to the policy instance and expose any returned violation messages. + */ +class PasswordPolicyValidator extends Zend_Validate_Abstract +{ + /** @var PasswordPolicy Policy to use for validation */ + protected PasswordPolicy $passwordPolicy; + + /** @var ?string Name of the old password form element */ + protected ?string $oldPasswordElementName; + + /** + * Create a new PasswordPolicyValidator + * + * @param PasswordPolicy $passwordPolicy + * @param ?string $oldPasswordElementName + */ + public function __construct(PasswordPolicy $passwordPolicy, ?string $oldPasswordElementName = null) + { + $this->passwordPolicy = $passwordPolicy; + $this->oldPasswordElementName = $oldPasswordElementName; + } + + /** + * Check whether the given password satisfies the configured policy + * + * If $context is an array and an old-password element name is configured, extracts + * the old password from the context and passes it to the policy. Violation messages + * returned by the policy are stored in $this->_messages. + * + * @param mixed $value The new password to validate + * @param mixed $context Form data array used to look up the old password + * + * @return bool True if the password satisfies the policy, false otherwise + */ + public function isValid(mixed $value, mixed $context = null): bool + { + $oldPassword = null; + if (is_array($context)) { + $oldPasswordValue = $context[$this->oldPasswordElementName] ?? null; + if (! Str::isEmpty($oldPasswordValue)) { + $oldPassword = $oldPasswordValue; + } + } + + $message = $this->passwordPolicy->validate($value, $oldPassword); + if (empty($message)) { + return true; + } + + $this->_messages = $message; + + return false; + } +} diff --git a/modules/setup/application/forms/AdminAccountPage.php b/modules/setup/application/forms/AdminAccountPage.php index 25a7d6e3c3..28839e2abd 100644 --- a/modules/setup/application/forms/AdminAccountPage.php +++ b/modules/setup/application/forms/AdminAccountPage.php @@ -7,6 +7,7 @@ use Exception; use Icinga\Application\Config; +use Icinga\Authentication\PasswordPolicyHelper; use Icinga\Authentication\User\ExternalBackend; use Icinga\Authentication\User\UserBackend; use Icinga\Authentication\User\DbUserBackend; @@ -230,6 +231,8 @@ public function createElements(array $formData) 'autocomplete' => 'new-password' ] ); + PasswordPolicyHelper::apply($this, 'new_user_password'); + $this->addElement( 'password', 'new_user_2ndpass', diff --git a/test/php/library/Icinga/Application/AnyPasswordPolicyTest.php b/test/php/library/Icinga/Application/AnyPasswordPolicyTest.php new file mode 100644 index 0000000000..67eb830915 --- /dev/null +++ b/test/php/library/Icinga/Application/AnyPasswordPolicyTest.php @@ -0,0 +1,14 @@ +assertEmpty((new AnyPasswordPolicy())->validate('a')); + } +} diff --git a/test/php/library/Icinga/Application/CommonPasswordPolicyTest.php b/test/php/library/Icinga/Application/CommonPasswordPolicyTest.php new file mode 100644 index 0000000000..a49d91a35f --- /dev/null +++ b/test/php/library/Icinga/Application/CommonPasswordPolicyTest.php @@ -0,0 +1,75 @@ +assertSame( + ['Password must be at least 12 characters long'], + (new CommonPasswordPolicy())->validate('Icinga1#') + ); + } + + public function testValidatePasswordNoNumber(): void + { + $this->assertSame( + ['Password must contain at least one number'], + (new CommonPasswordPolicy())->validate('Icingaadmin#') + ); + } + + public function testValidatePasswordNoSpecialCharacter(): void + { + $this->assertSame( + ['Password must contain at least one special character'], + (new CommonPasswordPolicy())->validate('Icingaadmin1') + ); + } + + public function testValidatePasswordNoUpperCaseLetters(): void + { + $this->assertSame( + ['Password must contain at least one uppercase letter'], + (new CommonPasswordPolicy())->validate('icingaadmin1#') + ); + } + + public function testValidatePasswordNoLowerCaseLetters(): void + { + $this->assertSame( + ['Password must contain at least one lowercase letter'], + (new CommonPasswordPolicy())->validate('ICINGAADMIN1#') + ); + } + + public function testValidatePasswordValid(): void + { + $this->assertEmpty((new CommonPasswordPolicy())->validate('Icingaadmin1#')); + } + + public function testValidatePasswordOnlyLowerCaseLetters(): void + { + $expected = [ + 'Password must contain at least one number', + 'Password must contain at least one special character', + 'Password must contain at least one uppercase letter' + ]; + $this->assertSame($expected, (new CommonPasswordPolicy())->validate('icingawebadmin')); + } + + public function testValidatePasswordToShortAndOnlyUpperCaseLetters(): void + { + $expected = [ + 'Password must be at least 12 characters long', + 'Password must contain at least one number', + 'Password must contain at least one special character', + 'Password must contain at least one lowercase letter' + ]; + $this->assertSame($expected, (new CommonPasswordPolicy())->validate('ICINGAADMIN')); + } +}