feat: backup previous password, change password via personal data form
This commit is contained in:
1 parent
c3508403c0
commit
e4430ac5c5
9 files changed
+285
-13
No files matched your search
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260528120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add previous password backup to users';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE user ADD previous_password LONGTEXT DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE user DROP previous_password');
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use App\Entity\User;
|
||||
use App\Exception\NewsletterProviderException;
|
||||
use App\Form\PersonalDataType;
|
||||
use App\Form\AccountPersonalDataType;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Model\NewsletterSubscriptionRequestResult;
|
||||
use App\Service\BookingEditDataLoader;
|
||||
@@ -20,6 +20,7 @@ use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
@@ -43,6 +44,7 @@ class PersonalDataController extends AbstractController
|
||||
private readonly ValidatorInterface $validator,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly NewsletterManager $newsletterManager,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
@@ -71,9 +73,8 @@ class PersonalDataController extends AbstractController
|
||||
$newsletterSubscribed = $this->newsletterManager->hasConfirmedOptIn($email);
|
||||
$newsletterPendingConfirmation = $this->newsletterManager->hasPendingConfirmation($email);
|
||||
|
||||
$personalDataForm = $this->createForm(PersonalDataType::class, $personalData, [
|
||||
$personalDataForm = $this->createForm(AccountPersonalDataType::class, $personalData, [
|
||||
'attr' => ['novalidate' => 'novalidate'],
|
||||
'validation_groups' => ['personal_data'],
|
||||
]);
|
||||
$personalDataForm->handleRequest($request);
|
||||
|
||||
@@ -81,6 +82,11 @@ class PersonalDataController extends AbstractController
|
||||
try {
|
||||
$this->apiClient->updatePersonalData($email, $personalData);
|
||||
|
||||
$newPassword = (string) $personalDataForm->get('newPassword')->getData();
|
||||
if ('' !== $newPassword) {
|
||||
$user->setPassword($this->passwordHasher->hashPassword($user, $newPassword));
|
||||
}
|
||||
|
||||
// Invalidate cached bookings to ensure edit mode shows updated applicant data
|
||||
$this->dataLoader->invalidateUserBookingCaches($user);
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[ORM\Column(type: 'text')]
|
||||
private ?string $password = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $previousPassword = null;
|
||||
|
||||
#[ORM\Column(type: 'integer', nullable: true)]
|
||||
private ?int $personId = null;
|
||||
|
||||
@@ -106,6 +109,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPreviousPassword(): ?string
|
||||
{
|
||||
return $this->previousPassword;
|
||||
}
|
||||
|
||||
public function setPreviousPassword(?string $previousPassword): static
|
||||
{
|
||||
$this->previousPassword = $previousPassword;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasLocalPasswordHash(): bool
|
||||
{
|
||||
if (null === $this->password || '' === $this->password) {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Security\Core\Validator\Constraints\UserPassword;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Constraints\Sequentially;
|
||||
|
||||
/** @extends AbstractType<PersonalData> */
|
||||
class AccountPersonalDataType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('personalData', PersonalDataType::class, [
|
||||
'label' => false,
|
||||
'inherit_data' => true,
|
||||
])
|
||||
->add('currentPassword', PasswordType::class, [
|
||||
'label' => 'Aktuelles Passwort',
|
||||
'mapped' => false,
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'autocomplete' => 'current-password',
|
||||
],
|
||||
'constraints' => [
|
||||
new Sequentially([
|
||||
new NotBlank([
|
||||
'message' => 'Bitte angeben',
|
||||
]),
|
||||
new UserPassword([
|
||||
'message' => 'Das aktuelle Passwort ist nicht korrekt.',
|
||||
]),
|
||||
], groups: ['password_change']),
|
||||
],
|
||||
])
|
||||
->add('newPassword', RepeatedType::class, [
|
||||
'type' => PasswordType::class,
|
||||
'mapped' => false,
|
||||
'required' => false,
|
||||
'invalid_message' => 'Die beiden Passwortangaben müssen identisch sein.',
|
||||
'first_options' => [
|
||||
'label' => 'Neues Passwort',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'autocomplete' => 'new-password',
|
||||
],
|
||||
'constraints' => [
|
||||
new Sequentially([
|
||||
new NotBlank([
|
||||
'message' => 'Bitte angeben',
|
||||
]),
|
||||
new Length(
|
||||
min: 8,
|
||||
minMessage: 'Das Passwort muss mindestens {{ limit }} Zeichen lang sein.',
|
||||
),
|
||||
], groups: ['password_change']),
|
||||
],
|
||||
],
|
||||
'second_options' => [
|
||||
'label' => 'Neues Passwort wiederholen',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'autocomplete' => 'new-password',
|
||||
],
|
||||
'constraints' => [
|
||||
new NotBlank([
|
||||
'message' => 'Bitte angeben',
|
||||
'groups' => ['password_change'],
|
||||
]),
|
||||
],
|
||||
],
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => PersonalData::class,
|
||||
'validation_groups' => function (FormInterface $form): array {
|
||||
$groups = ['Default', 'personal_data'];
|
||||
|
||||
if ($this->isPasswordSectionUsed($form)) {
|
||||
$groups[] = 'password_change';
|
||||
}
|
||||
|
||||
return $groups;
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
private function isPasswordSectionUsed(FormInterface $form): bool
|
||||
{
|
||||
return '' !== (string) $form->get('currentPassword')->getData()
|
||||
|| '' !== (string) $form->get('newPassword')->getData();
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,9 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
||||
throw new CustomUserMessageAuthenticationException('Benutzername oder Passwort ist nicht korrekt.');
|
||||
}
|
||||
|
||||
if (null === $user->getPreviousPassword()) {
|
||||
$user->setPreviousPassword($user->getPassword());
|
||||
}
|
||||
$user->setPassword($this->passwordHasher->hashPassword($user, $passwordPlain));
|
||||
}
|
||||
|
||||
|
||||
@@ -44,29 +44,37 @@
|
||||
</div>
|
||||
<div class="pt-8">
|
||||
{{ form_start(personalDataForm) }}
|
||||
{% from '_partials/_validation_errors.html.twig' import validation_alert %}
|
||||
{{ validation_alert(personalDataForm, 'Bitte prüfe deine Angaben und versuche es erneut', false) }}
|
||||
<div class="grid md:grid-cols-2 gap-x-8 gap-y-4 pb-4">
|
||||
<div>
|
||||
<h3 class="text-white">
|
||||
Persönliches
|
||||
</h3>
|
||||
{{ form_row(personalDataForm.gender, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{{ form_row(personalDataForm.dateOfBirth, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{{ form_row(personalDataForm.nationality, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{{ form_row(personalDataForm.personalData.gender, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{{ form_row(personalDataForm.personalData.dateOfBirth, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{{ form_row(personalDataForm.personalData.nationality, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
<h3 class="text-white">
|
||||
Kontakt
|
||||
</h3>
|
||||
{{ form_row(personalDataForm.email, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{{ form_row(personalDataForm.mobile, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{{ form_row(personalDataForm.phone, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{{ form_row(personalDataForm.personalData.email, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{{ form_row(personalDataForm.personalData.mobile, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{{ form_row(personalDataForm.personalData.phone, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-white">
|
||||
Anschrift
|
||||
</h3>
|
||||
{{ form_row(personalDataForm.street, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{{ form_row(personalDataForm.postCode, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{{ form_row(personalDataForm.city, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{{ form_row(personalDataForm.country, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{{ form_row(personalDataForm.personalData.street, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{{ form_row(personalDataForm.personalData.postCode, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{{ form_row(personalDataForm.personalData.city, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{{ form_row(personalDataForm.personalData.country, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
<h3 class="text-white">
|
||||
Passwort ändern
|
||||
</h3>
|
||||
{{ form_row(personalDataForm.currentPassword, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{{ form_row(personalDataForm.newPassword.first, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{{ form_row(personalDataForm.newPassword.second, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
|
||||
@@ -21,6 +21,7 @@ use Symfony\Component\HttpFoundation\Session\Session;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidatorFactory;
|
||||
use Symfony\Component\Validator\ConstraintValidatorInterface;
|
||||
@@ -189,6 +190,7 @@ class PersonalDataControllerTest extends TestCase
|
||||
$controller->entityManager
|
||||
->expects(self::once())
|
||||
->method('flush');
|
||||
$controller->passwordHasher->expects(self::never())->method('hashPassword');
|
||||
|
||||
$request = Request::create('/personal-data', 'POST');
|
||||
$request->setSession(new Session(new MockArraySessionStorage()));
|
||||
@@ -196,18 +198,108 @@ class PersonalDataControllerTest extends TestCase
|
||||
|
||||
self::assertSame(302, $response->getStatusCode());
|
||||
self::assertTrue($controller->user->isProfileComplete());
|
||||
self::assertSame('secret', $controller->user->getPassword());
|
||||
self::assertSame('legacy-backup', $controller->user->getPreviousPassword());
|
||||
self::assertSame([
|
||||
['success', 'Deine persönlichen Daten wurden aktualisiert'],
|
||||
], $controller->flashes);
|
||||
}
|
||||
|
||||
public function testIndexHashesPasswordWhenNewPasswordWasProvided(): void
|
||||
{
|
||||
$personalData = $this->createCompletePersonalData();
|
||||
$controller = $this->createController(true, true, $this->validator(), 'NewPassw0rd!');
|
||||
|
||||
$controller->apiClient
|
||||
->expects(self::once())
|
||||
->method('getPersonalData')
|
||||
->with('[email protected]', null, null)
|
||||
->willReturn($personalData);
|
||||
$controller->apiClient
|
||||
->expects(self::once())
|
||||
->method('updatePersonalData')
|
||||
->with('[email protected]', $personalData)
|
||||
->willReturn($personalData);
|
||||
$controller->newsletterManager
|
||||
->expects(self::once())
|
||||
->method('hasConfirmedOptIn')
|
||||
->with('[email protected]')
|
||||
->willReturn(false);
|
||||
$controller->newsletterManager
|
||||
->expects(self::once())
|
||||
->method('hasPendingConfirmation')
|
||||
->with('[email protected]')
|
||||
->willReturn(false);
|
||||
$controller->dataLoader
|
||||
->expects(self::once())
|
||||
->method('invalidateUserBookingCaches')
|
||||
->with($controller->user);
|
||||
$controller->entityManager
|
||||
->expects(self::once())
|
||||
->method('flush');
|
||||
$controller->passwordHasher
|
||||
->expects(self::once())
|
||||
->method('hashPassword')
|
||||
->with($controller->user, 'NewPassw0rd!')
|
||||
->willReturn('new-local-hash');
|
||||
|
||||
$request = Request::create('/personal-data', 'POST');
|
||||
$request->setSession(new Session(new MockArraySessionStorage()));
|
||||
$response = $controller->index($request);
|
||||
|
||||
self::assertSame(302, $response->getStatusCode());
|
||||
self::assertTrue($controller->user->isProfileComplete());
|
||||
self::assertSame('new-local-hash', $controller->user->getPassword());
|
||||
self::assertSame('legacy-backup', $controller->user->getPreviousPassword());
|
||||
self::assertSame([
|
||||
['success', 'Deine persönlichen Daten wurden aktualisiert'],
|
||||
], $controller->flashes);
|
||||
}
|
||||
|
||||
public function testIndexDoesNotPersistInvalidFormSubmission(): void
|
||||
{
|
||||
$controller = $this->createController(true, false, $this->validator(), 'NewPassw0rd!');
|
||||
|
||||
$controller->apiClient
|
||||
->expects(self::once())
|
||||
->method('getPersonalData')
|
||||
->with('[email protected]', null, null)
|
||||
->willReturn($this->createCompletePersonalData());
|
||||
$controller->apiClient->expects(self::never())->method('updatePersonalData');
|
||||
$controller->newsletterManager
|
||||
->expects(self::once())
|
||||
->method('hasConfirmedOptIn')
|
||||
->with('[email protected]')
|
||||
->willReturn(false);
|
||||
$controller->newsletterManager
|
||||
->expects(self::once())
|
||||
->method('hasPendingConfirmation')
|
||||
->with('[email protected]')
|
||||
->willReturn(false);
|
||||
$controller->dataLoader->expects(self::never())->method('invalidateUserBookingCaches');
|
||||
$controller->entityManager->expects(self::never())->method('flush');
|
||||
$controller->passwordHasher->expects(self::never())->method('hashPassword');
|
||||
|
||||
$request = Request::create('/personal-data', 'POST');
|
||||
$request->setSession(new Session(new MockArraySessionStorage()));
|
||||
$response = $controller->index($request);
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertSame('account/personal_data.html.twig', $controller->renderedView);
|
||||
self::assertSame([], $controller->flashes);
|
||||
self::assertSame('secret', $controller->user->getPassword());
|
||||
self::assertSame('legacy-backup', $controller->user->getPreviousPassword());
|
||||
}
|
||||
|
||||
private function createController(
|
||||
bool $submitted = false,
|
||||
bool $valid = false,
|
||||
?ValidatorInterface $validator = null,
|
||||
string $newPassword = '',
|
||||
): TestablePersonalDataController {
|
||||
$user = new User('[email protected]');
|
||||
$user->setPassword('secret');
|
||||
$user->setPreviousPassword('legacy-backup');
|
||||
|
||||
$apiClient = $this->createMock(ApiClient::class);
|
||||
|
||||
@@ -221,6 +313,14 @@ class PersonalDataControllerTest extends TestCase
|
||||
$form
|
||||
->method('isValid')
|
||||
->willReturn($valid);
|
||||
$newPasswordField = $this->createMock(FormInterface::class);
|
||||
$newPasswordField
|
||||
->method('getData')
|
||||
->willReturn($newPassword);
|
||||
$form
|
||||
->method('get')
|
||||
->with('newPassword')
|
||||
->willReturn($newPasswordField);
|
||||
|
||||
$validator ??= $this->createMock(ValidatorInterface::class);
|
||||
|
||||
@@ -230,6 +330,7 @@ class PersonalDataControllerTest extends TestCase
|
||||
$validator,
|
||||
$this->createMock(EntityManagerInterface::class),
|
||||
$this->createMock(NewsletterManager::class),
|
||||
$this->createMock(UserPasswordHasherInterface::class),
|
||||
$this->createMock(LoggerInterface::class),
|
||||
$user,
|
||||
$form,
|
||||
@@ -301,6 +402,7 @@ final class TestablePersonalDataController extends PersonalDataController
|
||||
public readonly ValidatorInterface $validator,
|
||||
public readonly EntityManagerInterface $entityManager,
|
||||
public readonly NewsletterManager $newsletterManager,
|
||||
public readonly UserPasswordHasherInterface $passwordHasher,
|
||||
LoggerInterface $logger,
|
||||
public readonly User $user,
|
||||
private readonly FormInterface $form,
|
||||
@@ -311,6 +413,7 @@ final class TestablePersonalDataController extends PersonalDataController
|
||||
$validator,
|
||||
$entityManager,
|
||||
$newsletterManager,
|
||||
$passwordHasher,
|
||||
$logger,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ class BpnAuthenticatorTest extends TestCase
|
||||
$passport = $authenticator->authenticate($this->createRequest('[email protected]', 'secret'));
|
||||
|
||||
self::assertInstanceOf(SelfValidatingPassport::class, $passport);
|
||||
self::assertSame(base64_encode('legacy-ciphertext'), $user->getPreviousPassword());
|
||||
self::assertSame('local-hash', $user->getPassword());
|
||||
self::assertSame(123, $user->getAddressId());
|
||||
self::assertSame(456, $user->getPersonId());
|
||||
|
||||
@@ -145,6 +145,7 @@ class PasswordResetManagerTest extends TestCase
|
||||
{
|
||||
$user = new User('[email protected]');
|
||||
$user->setPasswordResetNonce('current-nonce');
|
||||
$user->setPreviousPassword('legacy-backup');
|
||||
|
||||
$userRepository = $this->createMock(UserRepository::class);
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
@@ -167,6 +168,7 @@ class PasswordResetManagerTest extends TestCase
|
||||
$manager->resetPassword($user, 'new-password');
|
||||
|
||||
self::assertSame('hashed-password', $user->getPassword());
|
||||
self::assertSame('legacy-backup', $user->getPreviousPassword());
|
||||
self::assertNotSame('current-nonce', $user->getPasswordResetNonce());
|
||||
self::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', (string) $user->getPasswordResetNonce());
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user