Internal: Add password validation and toggle visibility for change-password page - refs BT#21546

pull/5597/head
christianbeeznst 5 months ago
parent 2e4ab279d2
commit fe02e1adcc
  1. 14
      assets/css/app.scss
  2. 45
      src/CoreBundle/Controller/AccountController.php
  3. 2
      src/CoreBundle/Form/ChangePasswordType.php
  4. 59
      src/CoreBundle/Resources/views/Account/change_password.html.twig
  5. 118
      src/CoreBundle/Twig/Extension/ChamiloExtension.php

@ -646,6 +646,20 @@ form .field {
margin-right: 1rem; 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 { #legacy_content {
.exercise-overview { .exercise-overview {
padding: 30px 10px 60px; padding: 30px 10px 60px;

@ -14,6 +14,7 @@ use Chamilo\CoreBundle\Repository\Node\UserRepository;
use Chamilo\CoreBundle\ServiceHelper\UserHelper; use Chamilo\CoreBundle\ServiceHelper\UserHelper;
use Chamilo\CoreBundle\Settings\SettingsManager; use Chamilo\CoreBundle\Settings\SettingsManager;
use Chamilo\CoreBundle\Traits\ControllerTrait; use Chamilo\CoreBundle\Traits\ControllerTrait;
use Security;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@ -22,6 +23,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormError;
use Symfony\Component\Security\Csrf\CsrfToken; use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/** /**
* @author Julio Montoya <gugli100@gmail.com> * @author Julio Montoya <gugli100@gmail.com>
@ -33,6 +35,7 @@ class AccountController extends BaseController
public function __construct( public function __construct(
private readonly UserHelper $userHelper, private readonly UserHelper $userHelper,
private readonly TranslatorInterface $translator
) {} ) {}
#[Route('/edit', name: 'chamilo_core_account_edit', methods: ['GET', 'POST'])] #[Route('/edit', name: 'chamilo_core_account_edit', methods: ['GET', 'POST'])]
@ -90,27 +93,61 @@ class AccountController extends BaseController
$submittedToken = $request->request->get('_token'); $submittedToken = $request->request->get('_token');
if (!$csrfTokenManager->isTokenValid(new CsrfToken('change_password', $submittedToken))) { 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 { } else {
$currentPassword = $form->get('currentPassword')->getData(); $currentPassword = $form->get('currentPassword')->getData();
$newPassword = $form->get('newPassword')->getData(); $newPassword = $form->get('newPassword')->getData();
$confirmPassword = $form->get('confirmPassword')->getData(); $confirmPassword = $form->get('confirmPassword')->getData();
if (!$userRepository->isPasswordValid($user, $currentPassword)) { 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) { } 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 {
$errors = $this->validatePassword($newPassword);
if (count($errors) > 0) {
foreach ($errors as $error) {
$form->get('newPassword')->addError(new FormError($error));
}
} else { } else {
$user->setPlainPassword($newPassword); $user->setPlainPassword($newPassword);
$userRepository->updateUser($user); $userRepository->updateUser($user);
$this->addFlash('success', 'Password changed successfully.'); $this->addFlash('success', $this->translator->trans('Password changed successfully.'));
return $this->redirectToRoute('chamilo_core_account_home'); return $this->redirectToRoute('chamilo_core_account_home');
} }
} }
} }
}
return $this->render('@ChamiloCore/Account/change_password.html.twig', [ return $this->render('@ChamiloCore/Account/change_password.html.twig', [
'form' => $form->createView(), '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;
}
} }

@ -8,10 +8,8 @@ namespace Chamilo\CoreBundle\Form;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
class ChangePasswordType extends AbstractType class ChangePasswordType extends AbstractType
{ {

@ -14,26 +14,38 @@
{% endfor %} {% endfor %}
{% if form.vars.errors|length > 0 %} {% if form.vars.errors|length > 0 %}
<div class="alert alert-danger"> <div class="alert alert-danger" id="server-errors">
{{ form_errors(form) }} {{ form_errors(form) }}
</div> </div>
{% endif %} {% endif %}
<div class="mb-4"> <div class="mb-4 relative">
{{ form_label(form.currentPassword) }} {{ 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'}}) }}
<span class="toggle-password absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer" data-target="#change_password_currentPassword">
<i class="mdi mdi-eye-outline text-gray-700"></i>
</span>
{{ form_errors(form.currentPassword) }} {{ form_errors(form.currentPassword) }}
</div> </div>
<div class="mb-4"> <div class="mb-4 relative">
{{ form_label(form.newPassword) }} {{ 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_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'}}) }}
<span class="toggle-password absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer" data-target="#change_password_newPassword">
<i class="mdi mdi-eye-outline text-gray-700"></i>
</span>
<ul id="password-requirements" class="text-sm text-red-500 mt-2" style="display: none;"></ul>
<div id="new-password-errors">
{{ form_errors(form.newPassword) }} {{ form_errors(form.newPassword) }}
</div> </div>
</div>
<div class="mb-4"> <div class="mb-4 relative">
{{ form_label(form.confirmPassword) }} {{ 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'}}) }}
<span class="toggle-password absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer" data-target="#change_password_confirmPassword">
<i class="mdi mdi-eye-outline text-gray-700"></i>
</span>
{{ form_errors(form.confirmPassword) }} {{ form_errors(form.confirmPassword) }}
</div> </div>
@ -45,4 +57,37 @@
{{ form_end(form) }} {{ form_end(form) }}
</div> </div>
</section> </section>
{{ password_checker_js('#change_password_newPassword') }}
<script>
document.addEventListener('DOMContentLoaded', function() {
const togglePasswordButtons = document.querySelectorAll('.toggle-password');
togglePasswordButtons.forEach(button => {
button.addEventListener('click', function() {
const input = document.querySelector(this.getAttribute('data-target'));
if (input) {
const type = input.getAttribute('type') === 'password' ? 'text' : 'password';
input.setAttribute('type', type);
this.querySelector('i').classList.toggle('mdi-eye-outline');
this.querySelector('i').classList.toggle('mdi-eye-off-outline');
}
});
});
const newPasswordInput = document.querySelector('#change_password_newPassword');
const newPasswordErrors = document.querySelector('#new-password-errors');
const serverErrors = document.querySelector('#server-errors');
newPasswordInput.addEventListener('input', function() {
if (serverErrors) {
serverErrors.style.display = 'none';
}
if (newPasswordErrors) {
newPasswordErrors.style.display = 'none';
}
});
});
</script>
{% endblock %} {% endblock %}

@ -11,6 +11,7 @@ use Chamilo\CoreBundle\Entity\ResourceIllustrationInterface;
use Chamilo\CoreBundle\Entity\User; use Chamilo\CoreBundle\Entity\User;
use Chamilo\CoreBundle\Repository\Node\IllustrationRepository; use Chamilo\CoreBundle\Repository\Node\IllustrationRepository;
use Chamilo\CoreBundle\Twig\SettingsHelper; use Chamilo\CoreBundle\Twig\SettingsHelper;
use Security;
use Sylius\Bundle\SettingsBundle\Model\SettingsInterface; use Sylius\Bundle\SettingsBundle\Model\SettingsInterface;
use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Routing\RouterInterface;
use Twig\Extension\AbstractExtension; use Twig\Extension\AbstractExtension;
@ -64,6 +65,7 @@ class ChamiloExtension extends AbstractExtension
new TwigFunction('chamilo_settings_all', $this->getSettings(...)), new TwigFunction('chamilo_settings_all', $this->getSettings(...)),
new TwigFunction('chamilo_settings_get', $this->getSettingsParameter(...)), new TwigFunction('chamilo_settings_get', $this->getSettingsParameter(...)),
new TwigFunction('chamilo_settings_has', [$this, 'hasSettingsParameter']), 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); 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 = "<script>
(function($) {
$.fn.passwordCheckerChange = function(options) {
var settings = $.extend({
rules: []
}, options );
return this.each(function() {
var \$passwordInput = $(this);
var \$requirements = $('#password-requirements');
function validatePassword(password) {
var html = '';
settings.rules.forEach(function(rule) {
var isValid = new RegExp(rule.pattern).test(password) && password.length >= rule.minChar;
var color = isValid ? 'green' : 'red';
html += '<li style=\"color:' + color + '\">' + rule.helpText + '</li>';
});
\$requirements.html(html);
}
\$passwordInput.on('input', function() {
validatePassword(\$passwordInput.val());
\$requirements.show();
});
\$passwordInput.on('blur', function() {
\$requirements.hide();
});
});
};
}(jQuery));
$(function() {
$('".$passwordInputId."').passwordCheckerChange(".json_encode($options).");
});
</script>";
return $js;
}
/** /**
* Returns the name of the extension. * Returns the name of the extension.
*/ */

Loading…
Cancel
Save