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'));
+ }
+}