Password change #13

Merged
skobkin merged 3 commits from feature_user_password_change into master 2022-07-20 00:48:20 +00:00
14 changed files with 166 additions and 54 deletions

View file

@ -53,6 +53,12 @@ user_account:
requirements: requirements:
method: GET method: GET
user_account_password_change:
path: /account/password
controller: App\Controller\AccountController::changePassword
requirements:
method: POST
user_account_token_create: user_account_token_create:
path: /profile/api/token/create path: /profile/api/token/create
controller: App\Controller\AccountController::addApiToken controller: App\Controller\AccountController::addApiToken

View file

@ -44,7 +44,7 @@ class AddInvitesCommand extends Command
$this->em->flush(); $this->em->flush();
$output->writeln(sprintf('<info>%d invites added to \'%s\'.</info>', $number, $user->getUsername())); $output->writeln(sprintf('<info>%d invites added to \'%s\'.</info>', $number, $user->getUserIdentifier()));
return Command::SUCCESS; return Command::SUCCESS;
} }

View file

@ -74,7 +74,7 @@ class AddUserCommand extends Command
$this->em->flush(); $this->em->flush();
$output->writeln(sprintf('<info>User \'%s\' registered, %d invites added.</info>', $user->getUsername(), $invites)); $output->writeln(sprintf('<info>User \'%s\' registered, %d invites added.</info>', $user->getUserIdentifier(), $invites));
return Command::SUCCESS; return Command::SUCCESS;
} }

View file

@ -5,14 +5,21 @@ namespace App\Controller;
use App\Entity\{ApiToken, User}; use App\Entity\{ApiToken, User};
use App\Repository\{ApiTokenRepository, InviteRepository}; use App\Repository\{ApiTokenRepository, InviteRepository};
use App\Form\Data\PasswordChangeData;
use App\Form\PasswordChangeType;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\{Request, Response};
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
class AccountController extends AbstractController class AccountController extends AbstractController
{ {
public function account(InviteRepository $inviteRepo, ApiTokenRepository $apiTokenRepo): Response public function account(
{ InviteRepository $inviteRepo,
ApiTokenRepository $apiTokenRepo
): Response {
/** @var User $user */ /** @var User $user */
if (null === $user = $this->getUser()) { if (null === $user = $this->getUser()) {
throw $this->createAccessDeniedException('User not found exception'); throw $this->createAccessDeniedException('User not found exception');
@ -26,6 +33,32 @@ class AccountController extends AbstractController
]); ]);
} }
public function changePassword(
Request $request,
EntityManagerInterface $em,
PasswordHasherFactoryInterface $hasherFactory,
): Response {
$data = new PasswordChangeData();
$form = $this->createChangePasswordForm($data);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var User $user */
$user = $this->getUser();
$hasher = $hasherFactory->getPasswordHasher($user);
$user->changePassword($hasher, $data->newPassword);
$em->flush();
$this->addFlash('success', 'Password changed.');
return $this->redirectToRoute('user_account');
}
return $this->renderForm('Account/password.html.twig', [
'form' => $form,
]);
}
public function addApiToken(EntityManagerInterface $em): Response public function addApiToken(EntityManagerInterface $em): Response
{ {
/** @var User|null $user */ /** @var User|null $user */
@ -57,4 +90,13 @@ class AccountController extends AbstractController
return $this->redirectToRoute('user_account'); return $this->redirectToRoute('user_account');
} }
private function createChangePasswordForm(PasswordChangeData $data): FormInterface
{
return $this
->createForm(PasswordChangeType::class, $data, [
'action' => $this->generateUrl('user_account_password_change'),
])
->add('submit', SubmitType::class);
}
} }

View file

@ -5,13 +5,15 @@ namespace App\Controller;
use App\Entity\{Invite, PasswordResetToken}; use App\Entity\{Invite, PasswordResetToken};
use App\Repository\PasswordResetTokenRepository; use App\Repository\PasswordResetTokenRepository;
use App\Form\{Data\PasswordResetRequestData, Data\PasswordResetData, PasswordResetRequestType, PasswordResetType, RegisterType, Data\RegisterData}; use App\Form\Data\{PasswordResetRequestData, PasswordResetData, RegisterData};
use App\Form\{PasswordResetRequestType, PasswordResetType, RegisterType};
use App\Repository\InviteRepository; use App\Repository\InviteRepository;
use App\User\{Exception\UserNotFoundException, InviteManager, PasswordResetManager, UserManager}; use App\User\{Exception\UserNotFoundException, InviteManager, PasswordResetManager, UserManager};
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\{Extension\Core\Type\SubmitType, FormError, FormInterface}; use Symfony\Component\Form\{Extension\Core\Type\SubmitType, FormError, FormInterface};
use Symfony\Component\HttpFoundation\{Request, Response}; use Symfony\Component\HttpFoundation\{Request, Response};
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
class UserController extends AbstractController class UserController extends AbstractController
{ {
@ -23,8 +25,8 @@ class UserController extends AbstractController
InviteManager $inviteManager, InviteManager $inviteManager,
InviteRepository $inviteRepo InviteRepository $inviteRepo
): Response { ): Response {
$formData = new RegisterData($code); $data = new RegisterData($code);
$form = $this->createRegisterForm($formData, $code); $form = $this->createRegisterForm($data, $code);
/** @var Invite $invite */ /** @var Invite $invite */
$invite = $inviteRepo->findOneBy(['code' => $code, 'usedBy' => null]); $invite = $inviteRepo->findOneBy(['code' => $code, 'usedBy' => null]);
@ -33,9 +35,9 @@ class UserController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$user = $userManager->createUserByInvite( $user = $userManager->createUserByInvite(
$formData->username, $data->username,
$formData->password, $data->password,
$formData->email, $data->email,
$invite $invite
); );
@ -54,8 +56,8 @@ class UserController extends AbstractController
public function requestReset(Request $request, EntityManagerInterface $em, PasswordResetManager $manager): Response public function requestReset(Request $request, EntityManagerInterface $em, PasswordResetManager $manager): Response
{ {
$formData = new PasswordResetRequestData(); $data = new PasswordResetRequestData();
$form = $this->createResetRequestForm($formData); $form = $this->createResetRequestForm($data);
$form->handleRequest($request); $form->handleRequest($request);
@ -63,7 +65,7 @@ class UserController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
try { try {
$manager->sendResetLink($formData->email); $manager->sendResetLink($data->email);
$message = 'Password reset link was sent'; $message = 'Password reset link was sent';
} catch (UserNotFoundException $e) { } catch (UserNotFoundException $e) {
@ -84,12 +86,12 @@ class UserController extends AbstractController
string $code, string $code,
Request $request, Request $request,
EntityManagerInterface $em, EntityManagerInterface $em,
UserManager $manager, PasswordHasherFactoryInterface $hasherFactory,
PasswordResetTokenRepository $tokenRepository PasswordResetTokenRepository $tokenRepository,
): Response ): Response
{ {
$formData = new PasswordResetData(); $data = new PasswordResetData();
$form = $this->createResetForm($formData, $code); $form = $this->createPasswordResetForm($data, $code);
/** @var PasswordResetToken $token */ /** @var PasswordResetToken $token */
$token = $tokenRepository->find($code); $token = $tokenRepository->find($code);
@ -98,15 +100,17 @@ class UserController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
if ($token && $token->isValid()) { if ($token && $token->isValid()) {
$manager->changePassword($token->getUser(), $formData->password); $user = $token->getUser();
$hasher = $hasherFactory->getPasswordHasher($user);
$user->changePassword($hasher, $data->password);
$em->remove($token); $em->remove($token);
$em->flush(); $em->flush();
return $this->redirectToRoute('user_auth_login'); return $this->redirectToRoute('user_auth_login');
} else {
$form->addError(new FormError('Invalid token.'));
} }
$form->addError(new FormError('Invalid token.'));
} }
return $this->render('User/reset.html.twig', [ return $this->render('User/reset.html.twig', [
@ -116,32 +120,28 @@ class UserController extends AbstractController
private function createResetRequestForm(PasswordResetRequestData $formData): FormInterface private function createResetRequestForm(PasswordResetRequestData $formData): FormInterface
{ {
$form = $this->createForm(PasswordResetRequestType::class, $formData, [ return $this
'action' => $this->generateUrl('user_reset_request'), ->createForm(PasswordResetRequestType::class, $formData, [
]); 'action' => $this->generateUrl('user_reset_request'),
$form->add('submit', SubmitType::class); ])
->add('submit', SubmitType::class);
return $form;
} }
private function createResetForm(PasswordResetData $formData, string $code): FormInterface private function createPasswordResetForm(PasswordResetData $data, string $code): FormInterface
{ {
$form = $this->createForm(PasswordResetType::class, $formData, [ return $this
'action' => $this->generateUrl('user_reset', ['code' => $code]), ->createForm(PasswordResetType::class, $data, [
]); 'action' => $this->generateUrl('user_reset', ['code' => $code]),
$form->add('submit', SubmitType::class); ])
->add('submit', SubmitType::class);
return $form;
} }
private function createRegisterForm(RegisterData $formData, string $code): FormInterface private function createRegisterForm(RegisterData $formData, string $code): FormInterface
{ {
$form = $this->createForm(RegisterType::class, $formData, [ return $this
'action' => $this->generateUrl('user_register', ['code' => $code]), ->createForm(RegisterType::class, $formData, [
]); 'action' => $this->generateUrl('user_register', ['code' => $code]),
])
$form->add('submit', SubmitType::class); ->add('submit', SubmitType::class);
return $form;
} }
} }

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Form\Data;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Security\Core\Validator\Constraints as SecurityAssert;
class PasswordChangeData
{
#[Assert\NotBlank]
#[SecurityAssert\UserPassword(message: 'Wrong password')]
public string $currentPassword;
#[Assert\NotBlank]
#[Assert\Length(min: 8, max: 4096)]
#[Assert\NotCompromisedPassword(skipOnError: true)]
public string $newPassword;
}

View file

@ -9,7 +9,7 @@ use Symfony\Component\Form\FormBuilderInterface;
class LoginType extends AbstractType class LoginType extends AbstractType
{ {
public function buildForm(FormBuilderInterface $builder, array $options) public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$builder $builder
->add('_username', TextType::class, ['mapped' => false, 'required' => true]) ->add('_username', TextType::class, ['mapped' => false, 'required' => true])

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Form\Data\PasswordChangeData;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\{PasswordType, RepeatedType};
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class PasswordChangeType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('currentPassword', PasswordType::class, [
'label' => 'Current password',
'invalid_message' => 'Wrong password.',
'required' => true,
])
->add('newPassword', RepeatedType::class, [
'type' => PasswordType::class,
'invalid_message' => 'The password fields must match.',
'required' => true,
'first_options' => ['label' => 'New password'],
'second_options' => ['label' => 'Repeat'],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => PasswordChangeData::class,
]);
}
}

View file

@ -10,7 +10,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class PasswordResetRequestType extends AbstractType class PasswordResetRequestType extends AbstractType
{ {
public function buildForm(FormBuilderInterface $builder, array $options) public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$builder $builder
->add('email', EmailType::class, ['required' => true]) ->add('email', EmailType::class, ['required' => true])
@ -18,7 +18,7 @@ class PasswordResetRequestType extends AbstractType
; ;
} }
public function configureOptions(OptionsResolver $resolver) public function configureOptions(OptionsResolver $resolver): void
{ {
$resolver->setDefaults([ $resolver->setDefaults([
'data_class' => PasswordResetRequestData::class, 'data_class' => PasswordResetRequestData::class,

View file

@ -11,7 +11,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class PasswordResetType extends AbstractType class PasswordResetType extends AbstractType
{ {
public function buildForm(FormBuilderInterface $builder, array $options) public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$builder $builder
->add('password', RepeatedType::class, [ ->add('password', RepeatedType::class, [
@ -24,7 +24,7 @@ class PasswordResetType extends AbstractType
; ;
} }
public function configureOptions(OptionsResolver $resolver) public function configureOptions(OptionsResolver $resolver): void
{ {
$resolver->setDefaults([ $resolver->setDefaults([
'data_class' => PasswordResetData::class, 'data_class' => PasswordResetData::class,

View file

@ -11,7 +11,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class RegisterType extends AbstractType class RegisterType extends AbstractType
{ {
public function buildForm(FormBuilderInterface $builder, array $options) public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$builder $builder
->add('username', TextType::class, ['required' => true]) ->add('username', TextType::class, ['required' => true])
@ -21,7 +21,7 @@ class RegisterType extends AbstractType
; ;
} }
public function configureOptions(OptionsResolver $resolver) public function configureOptions(OptionsResolver $resolver): void
{ {
$resolver->setDefaults([ $resolver->setDefaults([
'data_class' => RegisterData::class, 'data_class' => RegisterData::class,

View file

@ -34,11 +34,6 @@ class UserManager
return $user; return $user;
} }
public function changePassword(User $user, string $rawPassword): void
{
$user->changePassword($this->hasherFactory->getPasswordHasher(User::class), $rawPassword);
}
public function createUserByInvite(string $username, string $password, string $email, Invite $invite, array $roles = self::DEFAULT_ROLES): User public function createUserByInvite(string $username, string $password, string $email, Invite $invite, array $roles = self::DEFAULT_ROLES): User
{ {
if (null !== $invite->getUsedBy()) { if (null !== $invite->getUsedBy()) {

View file

@ -0,0 +1,5 @@
{% extends 'base.html.twig' %}
{% block content %}
{{ form(form) }}
{% endblock %}

View file

@ -10,7 +10,7 @@
<tbody> <tbody>
<tr> <tr>
<th scope="row">Username</th> <th scope="row">Username</th>
<td>{{ user.username }}</td> <td>{{ user.userIdentifier }}</td>
<td></td> <td></td>
</tr> </tr>
<tr> <tr>
@ -20,5 +20,12 @@
<button type="button" class="btn disabled" title="Not implemented">Change</button> <button type="button" class="btn disabled" title="Not implemented">Change</button>
</td> </td>
</tr> </tr>
<tr>
<th scope="row">Password</th>
<td>********</td>
<td>
<a role="button" class="btn btn-primary" href="{{ path('user_account_password_change') }}">Change</a>
</td>
</tr>
</tbody> </tbody>
</table> </table>