Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/bundle/Controller/PasswordResetController.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ public function userResetPasswordAction(Request $request, string $hashKey): Inva

$view = new UserResetPasswordFormView(null, [
'form_reset_user_password' => $form->createView(),
'content_type' => $user->getContentType(),
]);
$view->setResponse($response);

Expand Down
2 changes: 2 additions & 0 deletions src/bundle/Resources/config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,5 @@ services:

Ibexa\User\Form\BaseSubmitHandler: ~
Ibexa\User\Form\SubmitHandler: '@Ibexa\User\Form\BaseSubmitHandler'

Ibexa\User\Password\PasswordRequirementsResolver: ~
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:jms="urn:jms:translation" version="1.2">
<file source-language="en" target-language="en" datatype="plaintext" original="not.available">
<header>
<tool tool-id="JMSTranslationBundle" tool-name="JMSTranslationBundle" tool-version="1.1.0-DEV"/>
<note>The source node in most cases contains the sample message as written by the developer. If it looks like a dot-delimitted string such as "form.label.firstname", then the developer has not provided a default message.</note>
</header>
<body>
<trans-unit id="be5d9fef59e98b6cce9bffc63da99e911f47700f" resname="password_requirement.lower_case">
<source>At least one lowercase letter</source>
<target state="new">At least one lowercase letter</target>
<note>key: password_requirement.lower_case</note>
</trans-unit>
<trans-unit id="d7dcd12a831cd755b3628422201b85a6cfdcfaec" resname="password_requirement.min_length">
<source>At least %length% characters long</source>
<target state="new">At least %length% characters long</target>
<note>key: password_requirement.min_length</note>
</trans-unit>
<trans-unit id="da32e36982b0ed40439bf55a8c9a94908f99a877" resname="password_requirement.new_password">
<source>Different from your current password</source>
<target state="new">Different from your current password</target>
<note>key: password_requirement.new_password</note>
</trans-unit>
<trans-unit id="6416aeffea0b83b4ff8d2c3e2b077cb050e1151b" resname="password_requirement.non_alphanumeric">
<source>At least one special character</source>
<target state="new">At least one special character</target>
<note>key: password_requirement.non_alphanumeric</note>
</trans-unit>
<trans-unit id="b6d90391b37d26c9b854d5ca2cf2171d107d0679" resname="password_requirement.not_compromised">
<source>Not found in known data breaches</source>
<target state="new">Not found in known data breaches</target>
<note>key: password_requirement.not_compromised</note>
</trans-unit>
<trans-unit id="7c6c7efd8270a131e81c6ae2026eb34efd7a17bf" resname="password_requirement.numeric">
<source>At least one number</source>
<target state="new">At least one number</target>
<note>key: password_requirement.numeric</note>
</trans-unit>
<trans-unit id="7545e6f371413792c31a8cc3393846d1af3ff075" resname="password_requirement.upper_case">
<source>At least one uppercase letter</source>
<target state="new">At least one uppercase letter</target>
<note>key: password_requirement.upper_case</note>
</trans-unit>
</body>
</file>
</xliff>
7 changes: 6 additions & 1 deletion src/bundle/Twig/UserExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@

namespace Ibexa\Bundle\User\Twig;

use Override;
use Twig\DeprecatedCallableInfo;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;

final class UserExtension extends AbstractExtension
{
#[\Override]
#[Override]
public function getFunctions(): array
{
return [
Expand All @@ -25,6 +26,10 @@ public function getFunctions(): array
'deprecation_info' => new DeprecatedCallableInfo('ibexa/user', '4.6', 'ibexa_current_user'),
]
),
new TwigFunction(
'ibexa_password_requirements',
[UserRuntime::class, 'getPasswordRequirements']
),
];
}
}
18 changes: 17 additions & 1 deletion src/bundle/Twig/UserRuntime.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,17 @@

use Ibexa\Contracts\Core\Repository\PermissionResolver;
use Ibexa\Contracts\Core\Repository\UserService;
use Ibexa\Contracts\Core\Repository\Values\ContentType\ContentType;
use Ibexa\Contracts\Core\Repository\Values\User\User;
use Ibexa\User\Password\PasswordRequirementsResolver;
use Twig\Extension\RuntimeExtensionInterface;

final readonly class UserRuntime implements RuntimeExtensionInterface
{
public function __construct(
private PermissionResolver $permissionResolver,
private UserService $userService
private UserService $userService,
private PasswordRequirementsResolver $passwordRequirementsResolver
) {
}

Expand All @@ -27,4 +30,17 @@ public function getCurrentUser(): User
$this->permissionResolver->getCurrentUserReference()->getUserId()
);
}

/**
* @param \Ibexa\Contracts\Core\Repository\Values\ContentType\ContentType|null $contentType required
* on anonymous pages (e.g. password reset); defaults to the current user's content type
*
* @return \Ibexa\User\Password\PasswordRequirement[]
*/
public function getPasswordRequirements(?ContentType $contentType = null): array
{
return $this->passwordRequirementsResolver->getRequirements(
$contentType ?? $this->getCurrentUser()->getContentType()
);
}
}
49 changes: 49 additions & 0 deletions src/lib/Password/PasswordRequirement.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\User\Password;

final readonly class PasswordRequirement
Comment thread
tbialcz marked this conversation as resolved.
{
public const string MIN_LENGTH = 'min_length';
public const string UPPER_CASE = 'upper_case';
public const string LOWER_CASE = 'lower_case';
public const string NUMERIC = 'numeric';
public const string NON_ALPHANUMERIC = 'non_alphanumeric';
public const string NEW_PASSWORD = 'new_password';
public const string NOT_COMPROMISED = 'not_compromised';

private const string TRANSLATION_KEY_PREFIX = 'password_requirement.';

/**
* @param array<string, scalar> $parameters
*/
public function __construct(
private string $identifier,
private array $parameters = []
) {
}

public function getIdentifier(): string
{
return $this->identifier;
}

/**
* @return array<string, scalar>
*/
public function getParameters(): array
{
return $this->parameters;
}

public function getTranslationKey(): string
{
return self::TRANSLATION_KEY_PREFIX . $this->identifier;
}
}
102 changes: 102 additions & 0 deletions src/lib/Password/PasswordRequirementsResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\User\Password;

use Ibexa\Contracts\Core\Repository\Values\ContentType\ContentType;
use Ibexa\Core\FieldType\User\Type as UserType;
use JMS\TranslationBundle\Model\Message;
use JMS\TranslationBundle\Translation\TranslationContainerInterface;

final readonly class PasswordRequirementsResolver implements TranslationContainerInterface
{
/**
* Built-in password rules: constraint key in the core PasswordValueValidator
* schema => requirement identifier and its English label. Adding a rule here
* is all that is needed — translations are generated from this list.
*/
/** Constraint key in the core PasswordValueValidator schema, unlike {@see PasswordRequirement::MIN_LENGTH}. */
private const string MIN_LENGTH_CONSTRAINT = 'minLength';

private const array RULES = [
self::MIN_LENGTH_CONSTRAINT => [PasswordRequirement::MIN_LENGTH, 'At least %length% characters long'],
'requireAtLeastOneUpperCaseCharacter' => [PasswordRequirement::UPPER_CASE, 'At least one uppercase letter'],
'requireAtLeastOneLowerCaseCharacter' => [PasswordRequirement::LOWER_CASE, 'At least one lowercase letter'],
'requireAtLeastOneNumericCharacter' => [PasswordRequirement::NUMERIC, 'At least one number'],
'requireAtLeastOneNonAlphanumericCharacter' => [PasswordRequirement::NON_ALPHANUMERIC, 'At least one special character'],
'requireNewPassword' => [PasswordRequirement::NEW_PASSWORD, 'Different from your current password'],
'requireNotCompromisedPassword' => [PasswordRequirement::NOT_COMPROMISED, 'Not found in known data breaches'],
];

/**
* @return \Ibexa\User\Password\PasswordRequirement[]
*/
public function getRequirements(ContentType $contentType): array
{
$fieldDefinition = $contentType->getFirstFieldDefinitionOfType(UserType::FIELD_TYPE_IDENTIFIER);
if ($fieldDefinition === null) {
return [];
}

$constraints = $fieldDefinition->getValidatorConfiguration()['PasswordValueValidator'] ?? [];
$fieldSettings = $fieldDefinition->getFieldSettings();

$requirements = [];
foreach (self::RULES as $constraintKey => [$identifier]) {
if ($this->isEnabled($constraintKey, $constraints, $fieldSettings)) {
$requirements[] = new PasswordRequirement($identifier, $this->getParameters($constraintKey, $constraints));
}
}

return $requirements;
}

/**
* @param array<string, mixed> $constraints
* @param array<string, mixed> $fieldSettings
*/
private function isEnabled(string $constraintKey, array $constraints, array $fieldSettings): bool
{
return match ($constraintKey) {
self::MIN_LENGTH_CONSTRAINT => (int)($constraints[self::MIN_LENGTH_CONSTRAINT] ?? 0) > 0,
// A configured password TTL implies this rule, {@see \Ibexa\Core\FieldType\User\Type::isNewPasswordRequired()}
'requireNewPassword' => !empty($constraints['requireNewPassword'])
|| (int)($fieldSettings[UserType::PASSWORD_TTL_SETTING] ?? 0) > 0,
// Covers boolean on/off flags only; a numeric rule needs its own arm, like minLength above
default => !empty($constraints[$constraintKey]),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I propose to make more strict check here, 0 is actually empty and it may interfere. Shouldn't it be !isset() instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

i used !empty() because it behaves the same way as the core code. If requireNewPassword is false, the rule should be disabled. If we use isset(), requireNewPassword: false would still be treated as enabled, which would be incorrect.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

My point was, it works now, but if there is a new password rule like minSpecialChars = 0 it would be incorrectly resolved as not enabled. If it's ported directly from core then it's fine, but imo it could be reiterated on this step

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ok, it follows the current core behavior, but a rule like minSpecialChars = 0 would need its own match case anyway. I’ll add a comment to make that clear

};
}

/**
* @param array<string, mixed> $constraints
*
* @return array<string, scalar>
*/
private function getParameters(string $constraintKey, array $constraints): array
{
return $constraintKey === self::MIN_LENGTH_CONSTRAINT
? ['%length%' => (int)$constraints[self::MIN_LENGTH_CONSTRAINT]]
: [];
}

/**
* @return \JMS\TranslationBundle\Model\Message[]
*/
public static function getTranslationMessages(): array
{
$messages = [];
foreach (self::RULES as [$identifier, $label]) {
$messages[] = Message::create(
(new PasswordRequirement($identifier))->getTranslationKey(),
'ibexa_password_requirements'
)->setDesc($label);
}

return $messages;
}
}
39 changes: 30 additions & 9 deletions src/lib/Validator/Constraints/PasswordValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,28 @@

namespace Ibexa\User\Validator\Constraints;

use Ibexa\ContentForms\Validator\ValidationErrorsProcessor;
use Ibexa\Contracts\Core\Repository\UserService;
use Ibexa\Contracts\Core\Repository\Values\User\PasswordValidationContext;
use Ibexa\User\Password\PasswordRequirement;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;

class PasswordValidator extends ConstraintValidator
{
/**
* Message templates from {@see \Ibexa\Core\Repository\Validator\UserPasswordValidator}
* and {@see \Ibexa\Core\Repository\User\PasswordValidator}.
*/
private const array REQUIREMENT_CODE_MAP = [
'User password must be at least %length% characters long' => PasswordRequirement::MIN_LENGTH,
Comment thread
tbialcz marked this conversation as resolved.
'User password must include at least one upper case letter' => PasswordRequirement::UPPER_CASE,
'User password must include at least one lower case letter' => PasswordRequirement::LOWER_CASE,
'User password must include at least one number' => PasswordRequirement::NUMERIC,
'User password must include at least one special character' => PasswordRequirement::NON_ALPHANUMERIC,
'New password cannot be the same as old password' => PasswordRequirement::NEW_PASSWORD,
'This password has been leaked in a data breach, it must not be used. Please use another password.' => PasswordRequirement::NOT_COMPROMISED,
];

public function __construct(
private readonly UserService $userService
) {
Expand All @@ -41,14 +55,21 @@ public function validate(mixed $value, Constraint $constraint): void
$value,
$passwordValidationContext
);
if (!empty($validationErrors)) {
$validationErrorsProcessor = $this->createValidationErrorsProcessor();
$validationErrorsProcessor->processValidationErrors($validationErrors);
}
}

protected function createValidationErrorsProcessor(): ValidationErrorsProcessor
{
return new ValidationErrorsProcessor($this->context);
foreach ($validationErrors as $validationError) {
$message = $validationError->getTranslatableMessage();
$messageTemplate = $message->getMessageTemplate();

$violationBuilder = $this->context
->buildViolation($messageTemplate)
->setParameters($message->getValues());

$code = self::REQUIREMENT_CODE_MAP[$messageTemplate] ?? null;
if ($code !== null) {
$violationBuilder->setCode($code);
}

$violationBuilder->addViolation();
}
}
}
Loading
Loading