Skip to content

Implement password policy with hook#5419

Open
JolienTrog wants to merge 33 commits intomainfrom
password-policy
Open

Implement password policy with hook#5419
JolienTrog wants to merge 33 commits intomainfrom
password-policy

Conversation

@JolienTrog
Copy link
Contributor

Ref #4401

@JolienTrog JolienTrog requested a review from Al2Klimov August 26, 2025 09:22
@cla-bot cla-bot bot added the cla/signed label Aug 26, 2025
Copy link
Member

@Al2Klimov Al2Klimov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix your code style as told by the failing Github actions.

Copy link
Member

@Al2Klimov Al2Klimov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please push without -f for now. This eases it for me to keep track of your changes.

@JolienTrog JolienTrog requested a review from Al2Klimov August 26, 2025 15:21
@JolienTrog JolienTrog requested a review from Al2Klimov August 27, 2025 07:53
@JolienTrog JolienTrog requested a review from Al2Klimov August 28, 2025 08:35
@JolienTrog JolienTrog requested a review from Al2Klimov August 28, 2025 11:05
@JolienTrog JolienTrog requested a review from Al2Klimov August 28, 2025 13:15
@JolienTrog JolienTrog requested a review from Al2Klimov August 28, 2025 14:05
@JolienTrog JolienTrog requested a review from Al2Klimov August 28, 2025 15:26
Copy link
Member

@Al2Klimov Al2Klimov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During setup, the wizard prompts me for a username and password "to configure your first administrative account". This should enforce the chosen password policy.

@JolienTrog JolienTrog force-pushed the password-policy branch 2 times, most recently from 97e07ad to 39bbc53 Compare September 2, 2025 10:24
@JolienTrog JolienTrog requested a review from Al2Klimov October 14, 2025 09:38
Copy link
Member

@Al2Klimov Al2Klimov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not done yet:

Also, regarding #5419 (review): Situations where you push -f anyway, such as #5419 (comment), are good opportunities to squash at least commits which address only reviews and/or GitHub action failures.


namespace Icinga\Application\Hook;

interface PasswordPolicyHook
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given PHP has no multiple inheritance, I wanted to preventively avoid unnecessary base classes and instructed you to make an interface.

See how tables have turned: #5442 (comment) 🙈

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment from Alex doesn't read like an instruction, so here's the explanation:

We use arbitrary strings such as PasswordPolicy to register and load hooks, which makes it difficult to actually find hook usages. I would suggest making PasswordPolicyHook an abstract class that also provides the methods all() and register() so that the string PasswordPolicy only appears in the hook itself. Please then also adjust the documentation.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I would also do the following to make it clean:

  • Rename PasswordPolicyHook to PasswordPolicy (and move it to the Authentication namespace).
  • Use the PasswordPolicy interface everywhere in the code where typing is required. This is better than using the hook as a type, since the hook is used to load the functionality.
  • Have the new abstract PasswordPolicyHook implement PasswordPolicy.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lippserd But the framework requires hooks to extend PasswordPolicyHook anyway. Doesn't an abstract base class AND an interface rather provide more confusion than benefits?

- Fix validator handeling
- Adjust hook registration name
@JolienTrog JolienTrog requested a review from Al2Klimov November 10, 2025 07:38
$helper = new PasswordPolicyHelper();
$validators[] = $helper->getPasswordValidator();
$helper->addPasswordPolicyDescription($this);
} catch (\Throwable $e) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use Throwable;.

$validators[] = $helper->getPasswordValidator();
$helper->addPasswordPolicyDescription($this);
} catch (\Throwable $e) {
Logger::error($e);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would adjust the log message so that it includes the topic, e.g., 'The configured password policy could not be loaded: %s', $e

$helper->addPasswordPolicyDescription($this);
} catch (\Throwable $e) {
Logger::error($e);
Notification::error("The configured password policy could not be loaded.");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want this information to be displayed permanently, so Notification should be avoided. If styling is required, there is a form element called Note that can be used instead. You can search for 'note' in the Icinga web codebase to find examples of how it is used. addDescription() can also be used. The message could then be changed to something like this: There was a problem loading the configured password policy. Please contact your administrator.

@@ -0,0 +1,51 @@
<?php
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

License header missing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be 2025 not 2016.

{
$message = $this->passwordPolicyObject->validatePassword($value);

if (!empty($message)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Space after ! missing.

use Icinga\Application\Hook\PasswordPolicyHook;
use Zend_Validate_Abstract;

class PasswordValidator extends Zend_Validate_Abstract
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would rename the class to PasswordPolicyValidator.

* @return string[] Returns an empty array if the password is valid,
* otherwise returns error messages describing the violations
*/
public function validatePassword(string $password): array;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validate() is sufficient.


namespace Icinga\Application\Hook;

interface PasswordPolicyHook
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I would also do the following to make it clean:

  • Rename PasswordPolicyHook to PasswordPolicy (and move it to the Authentication namespace).
  • Use the PasswordPolicy interface everywhere in the code where typing is required. This is better than using the hook as a type, since the hook is used to load the functionality.
  • Have the new abstract PasswordPolicyHook implement PasswordPolicy.


public function testValidatePasswordTooShort(): void
{
$expectedResults = ['Password must be at least 12 characters long'];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need those variables? The signature of assert*() is $expected, $actual, so having the following is perfectly fine:

$this->assertSame(
    expected,
    actual
);

And if you really need such variables, please use $expected and $actual.

@JolienTrog JolienTrog requested a review from lippserd November 21, 2025 12:36
@Al2Klimov
Copy link
Member

💡

@lippserd What do you think about this:

ChangePasswordForm already has the old and new password. So PasswordPolicy can take both.

It would cost us nothing, but admins could implement #5417 by themselves. They could also use e.g Levenshtein distance and/or A.I to prevent the "smartest" users from using passwords similar to their current ones.

@lippserd
Copy link
Member

lippserd commented Dec 3, 2025

💡

@lippserd What do you think about this:

ChangePasswordForm already has the old and new password. So PasswordPolicy can take both.

It would cost us nothing, but admins could implement #5417 by themselves. They could also use e.g Levenshtein distance and/or A.I to prevent the "smartest" users from using passwords similar to their current ones.

That's a good idea! @JolienTrog Could you please adjust the implementation so that the validation function accepts both $newPassword and $oldPassword, where the latter may be null?

@Al2Klimov Al2Klimov removed their request for review December 17, 2025 15:30
Comment on lines 45 to 47
// if($oldPassword !== null) {
$form->getElement($oldPassword);
// }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this code even do?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I forgot to delete this part.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is indeed necessary to do something with the element for the old password, as your introduced validator expects the old password element to be named old_password, which may not be the case. This shows that it also makes sense to introduce test cases for an actual form that implements password elements, a policy, and the validator.

Copy link
Member

@lippserd lippserd left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In addition to the changes requested, please revert anything related to 81e6859. This does not look like a necessary change.

Comment on lines 166 to 167
By default, no password policy is enforced ('Any').
Icinga Web provides a built-in policy called 'Common' with the following requirements:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please annotate Any and Common as code.

use Icinga\Authentication\PasswordPolicy;

/**
* This class connects with the PasswordPolicy interface to use it as hook
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* This class connects with the PasswordPolicy interface to use it as hook
* Base class for hookable password policies

abstract class PasswordPolicyHook implements PasswordPolicy
{
/**
* Registers Hook as Password Policy
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Registers Hook as Password Policy
* Register password policy

*/
public static function register(): void
{
Hook::register('PasswordPolicy', static::class, static::class);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PasswordPolicy could be a protected const, e.g., HOOK_NAME.

}

/**
* Returns all registered password policies sorted by
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Returns all registered password policies sorted by
* Return all registered password policies sorted by name


class AnyPasswordPolicyTest extends TestCase
{
private PasswordPolicy $instance;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rename to passwordPolicy, as everywhere else in the code.


class CommonPasswordPolicyTest extends TestCase
{
protected PasswordPolicy $instance;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rename to passwordPolicy, as everywhere else in the code.

);
}

public function testMethodValidatePasswordAlwaysReturnAnEmptyArray(): void
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public function testMethodValidatePasswordAlwaysReturnAnEmptyArray(): void
public function testValidatePasswordValid(): void

Comment on lines 78 to 86
$expectedResult = [
'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',
];
$res =
$this->assertSame(
$expectedResult,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
$expectedResult = [
'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',
];
$res =
$this->assertSame(
$expectedResult,
$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,

Comment on lines 90 to 95

public function testValidatePasswordWithManyCharacters(): void
{
$longPassword = str_repeat('a', 1000);
$this->assertCount(3, $this->instance->validate($longPassword, 'null'));
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is not very useful.

Suggested change
public function testValidatePasswordWithManyCharacters(): void
{
$longPassword = str_repeat('a', 1000);
$this->assertCount(3, $this->instance->validate($longPassword, 'null'));
}

@JolienTrog JolienTrog requested a review from lippserd January 30, 2026 08:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants