From fe02e1adcce52ec572bdee5b6d52906a97da4c3c Mon Sep 17 00:00:00 2001 From: christianbeeznst Date: Thu, 20 Jun 2024 12:58:31 -0500 Subject: [PATCH] Internal: Add password validation and toggle visibility for change-password page - refs BT#21546 --- assets/css/app.scss | 14 +++ .../Controller/AccountController.php | 51 ++++++-- src/CoreBundle/Form/ChangePasswordType.php | 2 - .../views/Account/change_password.html.twig | 61 +++++++-- .../Twig/Extension/ChamiloExtension.php | 118 ++++++++++++++++++ 5 files changed, 229 insertions(+), 17 deletions(-) diff --git a/assets/css/app.scss b/assets/css/app.scss index 64165edcaa..fa46d51e81 100644 --- a/assets/css/app.scss +++ b/assets/css/app.scss @@ -646,6 +646,20 @@ form .field { margin-right: 1rem; } +.toggle-password { + position: absolute !important; + top: 65% !important; + transform: translateY(-50%); + right: 0.75rem; + display: flex; + align-items: center; + cursor: pointer; +} + +.toggle-password i { + font-size: 24px; +} + #legacy_content { .exercise-overview { padding: 30px 10px 60px; diff --git a/src/CoreBundle/Controller/AccountController.php b/src/CoreBundle/Controller/AccountController.php index 13e931ebbe..3bae5409ef 100644 --- a/src/CoreBundle/Controller/AccountController.php +++ b/src/CoreBundle/Controller/AccountController.php @@ -14,6 +14,7 @@ use Chamilo\CoreBundle\Repository\Node\UserRepository; use Chamilo\CoreBundle\ServiceHelper\UserHelper; use Chamilo\CoreBundle\Settings\SettingsManager; use Chamilo\CoreBundle\Traits\ControllerTrait; +use Security; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -22,6 +23,7 @@ use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Form\FormError; use Symfony\Component\Security\Csrf\CsrfToken; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Symfony\Contracts\Translation\TranslatorInterface; /** * @author Julio Montoya @@ -33,6 +35,7 @@ class AccountController extends BaseController public function __construct( private readonly UserHelper $userHelper, + private readonly TranslatorInterface $translator ) {} #[Route('/edit', name: 'chamilo_core_account_edit', methods: ['GET', 'POST'])] @@ -90,21 +93,28 @@ class AccountController extends BaseController $submittedToken = $request->request->get('_token'); if (!$csrfTokenManager->isTokenValid(new CsrfToken('change_password', $submittedToken))) { - $form->addError(new FormError('CSRF token is invalid. Please try again.')); + $form->addError(new FormError($this->translator->trans('CSRF token is invalid. Please try again.'))); } else { $currentPassword = $form->get('currentPassword')->getData(); $newPassword = $form->get('newPassword')->getData(); $confirmPassword = $form->get('confirmPassword')->getData(); if (!$userRepository->isPasswordValid($user, $currentPassword)) { - $form->get('currentPassword')->addError(new FormError('Current password is incorrect.')); + $form->get('currentPassword')->addError(new FormError($this->translator->trans('Current password is incorrect.'))); } elseif ($newPassword !== $confirmPassword) { - $form->get('confirmPassword')->addError(new FormError('Passwords do not match.')); + $form->get('confirmPassword')->addError(new FormError($this->translator->trans('Passwords do not match.'))); } else { - $user->setPlainPassword($newPassword); - $userRepository->updateUser($user); - $this->addFlash('success', 'Password changed successfully.'); - return $this->redirectToRoute('chamilo_core_account_home'); + $errors = $this->validatePassword($newPassword); + if (count($errors) > 0) { + foreach ($errors as $error) { + $form->get('newPassword')->addError(new FormError($error)); + } + } else { + $user->setPlainPassword($newPassword); + $userRepository->updateUser($user); + $this->addFlash('success', $this->translator->trans('Password changed successfully.')); + return $this->redirectToRoute('chamilo_core_account_home'); + } } } } @@ -113,4 +123,31 @@ class AccountController extends BaseController 'form' => $form->createView(), ]); } + + /** + * Validate the password against the same requirements as the client-side validation. + */ + private function validatePassword(string $password): array + { + $errors = []; + $minRequirements = Security::getPasswordRequirements()['min']; + + if (strlen($password) < $minRequirements['length']) { + $errors[] = $this->translator->trans('Password must be at least %length% characters long.', ['%length%' => $minRequirements['length']]); + } + if ($minRequirements['lowercase'] > 0 && !preg_match('/[a-z]/', $password)) { + $errors[] = $this->translator->trans('Password must contain at least %count% lowercase characters.', ['%count%' => $minRequirements['lowercase']]); + } + if ($minRequirements['uppercase'] > 0 && !preg_match('/[A-Z]/', $password)) { + $errors[] = $this->translator->trans('Password must contain at least %count% uppercase characters.', ['%count%' => $minRequirements['uppercase']]); + } + if ($minRequirements['numeric'] > 0 && !preg_match('/[0-9]/', $password)) { + $errors[] = $this->translator->trans('Password must contain at least %count% numerical (0-9) characters.', ['%count%' => $minRequirements['numeric']]); + } + if ($minRequirements['specials'] > 0 && !preg_match('/[\W]/', $password)) { + $errors[] = $this->translator->trans('Password must contain at least %count% special characters.', ['%count%' => $minRequirements['specials']]); + } + + return $errors; + } } diff --git a/src/CoreBundle/Form/ChangePasswordType.php b/src/CoreBundle/Form/ChangePasswordType.php index a2887fc02d..34aba6dc9c 100644 --- a/src/CoreBundle/Form/ChangePasswordType.php +++ b/src/CoreBundle/Form/ChangePasswordType.php @@ -8,10 +8,8 @@ namespace Chamilo\CoreBundle\Form; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\PasswordType; -use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; -use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; class ChangePasswordType extends AbstractType { diff --git a/src/CoreBundle/Resources/views/Account/change_password.html.twig b/src/CoreBundle/Resources/views/Account/change_password.html.twig index 177a38cb04..a2b9db109c 100644 --- a/src/CoreBundle/Resources/views/Account/change_password.html.twig +++ b/src/CoreBundle/Resources/views/Account/change_password.html.twig @@ -14,26 +14,38 @@ {% endfor %} {% if form.vars.errors|length > 0 %} -
+
{{ form_errors(form) }}
{% endif %} -
+
{{ form_label(form.currentPassword) }} - {{ form_widget(form.currentPassword, {'attr': {'class': 'shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline'}}) }} + {{ form_widget(form.currentPassword, {'attr': {'class': 'shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline', 'id': 'change_password_currentPassword'}}) }} + + + {{ form_errors(form.currentPassword) }}
-
+
{{ form_label(form.newPassword) }} - {{ form_widget(form.newPassword, {'attr': {'class': 'shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline'}}) }} - {{ form_errors(form.newPassword) }} + {{ form_widget(form.newPassword, {'attr': {'class': 'shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline', 'id': 'change_password_newPassword'}}) }} + + + + +
+ {{ form_errors(form.newPassword) }} +
-
+
{{ form_label(form.confirmPassword) }} - {{ form_widget(form.confirmPassword, {'attr': {'class': 'shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline'}}) }} + {{ form_widget(form.confirmPassword, {'attr': {'class': 'shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline', 'id': 'change_password_confirmPassword'}}) }} + + + {{ form_errors(form.confirmPassword) }}
@@ -45,4 +57,37 @@ {{ form_end(form) }}
+ + {{ password_checker_js('#change_password_newPassword') }} + + {% endblock %} diff --git a/src/CoreBundle/Twig/Extension/ChamiloExtension.php b/src/CoreBundle/Twig/Extension/ChamiloExtension.php index 1b971d8129..196659a4a9 100644 --- a/src/CoreBundle/Twig/Extension/ChamiloExtension.php +++ b/src/CoreBundle/Twig/Extension/ChamiloExtension.php @@ -11,6 +11,7 @@ use Chamilo\CoreBundle\Entity\ResourceIllustrationInterface; use Chamilo\CoreBundle\Entity\User; use Chamilo\CoreBundle\Repository\Node\IllustrationRepository; use Chamilo\CoreBundle\Twig\SettingsHelper; +use Security; use Sylius\Bundle\SettingsBundle\Model\SettingsInterface; use Symfony\Component\Routing\RouterInterface; use Twig\Extension\AbstractExtension; @@ -64,6 +65,7 @@ class ChamiloExtension extends AbstractExtension new TwigFunction('chamilo_settings_all', $this->getSettings(...)), new TwigFunction('chamilo_settings_get', $this->getSettingsParameter(...)), new TwigFunction('chamilo_settings_has', [$this, 'hasSettingsParameter']), + new TwigFunction('password_checker_js', [$this, 'getPasswordCheckerJs'], ['is_safe' => ['html']]), ]; } @@ -96,6 +98,122 @@ class ChamiloExtension extends AbstractExtension return $this->helper->getSettingsParameter($name); } + /** + * Generates and returns JavaScript code for a password strength checker. + */ + public function getPasswordCheckerJs(string $passwordInputId): ?string + { + $checkPass = api_get_setting('allow_strength_pass_checker'); + $useStrengthPassChecker = 'true' === $checkPass; + + if (false === $useStrengthPassChecker) { + return null; + } + + $minRequirements = Security::getPasswordRequirements()['min']; + + $options = [ + 'rules' => [], + ]; + + if ($minRequirements['length'] > 0) { + $options['rules'][] = [ + 'minChar' => $minRequirements['length'], + 'pattern' => '.', + 'helpText' => sprintf( + get_lang('Minimum %s characters in total'), + $minRequirements['length'] + ), + ]; + } + + if ($minRequirements['lowercase'] > 0) { + $options['rules'][] = [ + 'minChar' => $minRequirements['lowercase'], + 'pattern' => '[a-z]', + 'helpText' => sprintf( + get_lang('Minimum %s lowercase characters'), + $minRequirements['lowercase'] + ), + ]; + } + + if ($minRequirements['uppercase'] > 0) { + $options['rules'][] = [ + 'minChar' => $minRequirements['uppercase'], + 'pattern' => '[A-Z]', + 'helpText' => sprintf( + get_lang('Minimum %s uppercase characters'), + $minRequirements['uppercase'] + ), + ]; + } + + if ($minRequirements['numeric'] > 0) { + $options['rules'][] = [ + 'minChar' => $minRequirements['numeric'], + 'pattern' => '[0-9]', + 'helpText' => sprintf( + get_lang('Minimum %s numerical (0-9) characters'), + $minRequirements['numeric'] + ), + ]; + } + + if ($minRequirements['specials'] > 0) { + $options['rules'][] = [ + 'minChar' => $minRequirements['specials'], + 'pattern' => '[!"#$%&\'()*+,\-./\\\:;<=>?@[\\]^_`{|}~]', + 'helpText' => sprintf( + get_lang('Minimum %s special characters'), + $minRequirements['specials'] + ), + ]; + } + + $js = ""; + + return $js; + } + /** * Returns the name of the extension. */