feat: backup previous password, change password via personal data form

This commit is contained in:
Björn Fromme committed 2026-06-23 16:27:09 +02:00
1 parent c3508403c0
commit e4430ac5c5
9 files changed
+285 -13

No files matched your search

+26
View File
@@ -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);
+15
View File
@@ -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) {
+108
View File
@@ -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();
}
}
+3
View File
@@ -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));
}
+18 -10
View File
@@ -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,
);
}
+1
View File
@@ -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());
}