From 2e4ab279d2c153478978ee904985a8ed95eb940f Mon Sep 17 00:00:00 2001 From: christianbeeznst Date: Wed, 19 Jun 2024 13:11:56 -0500 Subject: [PATCH 1/2] Internal: Make password edition a single independent page - refs BT#21546 --- .../vue/components/social/UserProfileCard.vue | 12 +++++ .../Controller/AccountController.php | 44 +++++++++++++++++ src/CoreBundle/Form/ChangePasswordType.php | 43 +++++++++++++++++ .../Repository/Node/UserRepository.php | 5 ++ src/CoreBundle/Resources/config/services.yml | 3 ++ .../views/Account/change_password.html.twig | 48 +++++++++++++++++++ 6 files changed, 155 insertions(+) create mode 100644 src/CoreBundle/Form/ChangePasswordType.php create mode 100644 src/CoreBundle/Resources/views/Account/change_password.html.twig diff --git a/assets/vue/components/social/UserProfileCard.vue b/assets/vue/components/social/UserProfileCard.vue index fcc773b7ee5..c542e57a1ad 100644 --- a/assets/vue/components/social/UserProfileCard.vue +++ b/assets/vue/components/social/UserProfileCard.vue @@ -104,6 +104,14 @@ type="primary" @click="editProfile" /> + @@ -140,6 +148,10 @@ const editProfile = () => { window.location = "/account/edit" } +const changePassword = () => { + window.location = "/account/change-password" +} + async function fetchUserProfile(userId) { try { const { data } = await axios.get(`/social-network/user-profile/${userId}`) diff --git a/src/CoreBundle/Controller/AccountController.php b/src/CoreBundle/Controller/AccountController.php index 3c32ed7bcb0..13e931ebbe9 100644 --- a/src/CoreBundle/Controller/AccountController.php +++ b/src/CoreBundle/Controller/AccountController.php @@ -7,6 +7,7 @@ namespace Chamilo\CoreBundle\Controller; use Chamilo\CoreBundle\Entity\User; +use Chamilo\CoreBundle\Form\ChangePasswordType; use Chamilo\CoreBundle\Form\ProfileType; use Chamilo\CoreBundle\Repository\Node\IllustrationRepository; use Chamilo\CoreBundle\Repository\Node\UserRepository; @@ -18,6 +19,9 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Form\FormError; +use Symfony\Component\Security\Csrf\CsrfToken; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; /** * @author Julio Montoya @@ -69,4 +73,44 @@ public function edit(Request $request, UserRepository $userRepository, Illustrat 'user' => $user, ]); } + + #[Route('/change-password', name: 'chamilo_core_account_change_password', methods: ['GET', 'POST'])] + public function changePassword(Request $request, UserRepository $userRepository, CsrfTokenManagerInterface $csrfTokenManager): Response + { + $user = $this->getUser(); + + if (!\is_object($user) || !$user instanceof UserInterface) { + throw $this->createAccessDeniedException('This user does not have access to this section'); + } + + $form = $this->createForm(ChangePasswordType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $submittedToken = $request->request->get('_token'); + + if (!$csrfTokenManager->isTokenValid(new CsrfToken('change_password', $submittedToken))) { + $form->addError(new FormError('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.')); + } elseif ($newPassword !== $confirmPassword) { + $form->get('confirmPassword')->addError(new FormError('Passwords do not match.')); + } else { + $user->setPlainPassword($newPassword); + $userRepository->updateUser($user); + $this->addFlash('success', 'Password changed successfully.'); + return $this->redirectToRoute('chamilo_core_account_home'); + } + } + } + + return $this->render('@ChamiloCore/Account/change_password.html.twig', [ + 'form' => $form->createView(), + ]); + } } diff --git a/src/CoreBundle/Form/ChangePasswordType.php b/src/CoreBundle/Form/ChangePasswordType.php new file mode 100644 index 00000000000..a2887fc02dd --- /dev/null +++ b/src/CoreBundle/Form/ChangePasswordType.php @@ -0,0 +1,43 @@ +add('currentPassword', PasswordType::class, [ + 'label' => 'Current Password', + 'required' => true, + ]) + ->add('newPassword', PasswordType::class, [ + 'label' => 'New Password', + 'required' => true, + ]) + ->add('confirmPassword', PasswordType::class, [ + 'label' => 'Confirm New Password', + 'required' => true, + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'csrf_protection' => true, + 'csrf_field_name' => '_token', + 'csrf_token_id' => 'change_password', + ]); + } +} diff --git a/src/CoreBundle/Repository/Node/UserRepository.php b/src/CoreBundle/Repository/Node/UserRepository.php index 96a279fd571..40707c78ba5 100644 --- a/src/CoreBundle/Repository/Node/UserRepository.php +++ b/src/CoreBundle/Repository/Node/UserRepository.php @@ -105,6 +105,11 @@ public function updatePassword(User $user): void } } + public function isPasswordValid(User $user, string $plainPassword): bool + { + return $this->hasher->isPasswordValid($user, $plainPassword); + } + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void { /** @var User $user */ diff --git a/src/CoreBundle/Resources/config/services.yml b/src/CoreBundle/Resources/config/services.yml index b35bcc83bde..00540aeb2b4 100644 --- a/src/CoreBundle/Resources/config/services.yml +++ b/src/CoreBundle/Resources/config/services.yml @@ -5,6 +5,9 @@ services: public: true autoconfigure: true + csrf.token_manager: + class: Symfony\Component\Security\Csrf\CsrfTokenManager + chamilo_core.translation.loader.po: class: Symfony\Component\Translation\Loader\PoFileLoader tags: diff --git a/src/CoreBundle/Resources/views/Account/change_password.html.twig b/src/CoreBundle/Resources/views/Account/change_password.html.twig new file mode 100644 index 00000000000..177a38cb041 --- /dev/null +++ b/src/CoreBundle/Resources/views/Account/change_password.html.twig @@ -0,0 +1,48 @@ +{% extends "@ChamiloCore/Layout/layout_one_col.html.twig" %} + +{% block content %} +
+
+

{{ "Change Password"|trans }}

+ + {{ form_start(form, {'attr': {'class': 'bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4'}}) }} + + {% for message in app.flashes('success') %} +
+ {{ message }} +
+ {% 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_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_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_errors(form.confirmPassword) }} +
+ +
+ + +
+ + {{ form_end(form) }} +
+
+{% endblock %} From fe02e1adcce52ec572bdee5b6d52906a97da4c3c Mon Sep 17 00:00:00 2001 From: christianbeeznst Date: Thu, 20 Jun 2024 12:58:31 -0500 Subject: [PATCH 2/2] 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 64165edcaa0..fa46d51e81b 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 13e931ebbe9..3bae5409efb 100644 --- a/src/CoreBundle/Controller/AccountController.php +++ b/src/CoreBundle/Controller/AccountController.php @@ -14,6 +14,7 @@ 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\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 @@ public function changePassword(Request $request, UserRepository $userRepository, $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 @@ public function changePassword(Request $request, UserRepository $userRepository, '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 a2887fc02dd..34aba6dc9c1 100644 --- a/src/CoreBundle/Form/ChangePasswordType.php +++ b/src/CoreBundle/Form/ChangePasswordType.php @@ -8,10 +8,8 @@ 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 177a38cb041..a2b9db109c4 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 1b971d81290..196659a4a9b 100644 --- a/src/CoreBundle/Twig/Extension/ChamiloExtension.php +++ b/src/CoreBundle/Twig/Extension/ChamiloExtension.php @@ -11,6 +11,7 @@ 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 @@ public function getFunctions(): array 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 @@ public function getSettingsParameter($name) 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. */