feat: local password reset

This commit is contained in:
Björn Fromme committed 2026-06-23 16:27:08 +02:00
1 parent fe562e5e73
commit 8bb7f08074
11 files changed
+688 -19

No files matched your search

+47 -17
View File
@@ -2,11 +2,11 @@
namespace App\Controller;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use Psr\Log\LoggerInterface;
use App\Service\PasswordResetManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -16,7 +16,7 @@ use Symfony\Component\Validator\Constraints\NotBlank;
class ResetPasswordController extends AbstractController
{
public function __construct(private readonly ApiClient $apiClient, private readonly LoggerInterface $logger)
public function __construct(private readonly PasswordResetManager $passwordResetManager)
{
}
@@ -45,19 +45,8 @@ class ResetPasswordController extends AbstractController
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->apiClient->resetPassword($form->get('email')->getData());
$this->addFlash('info', 'Du erhältst in Kürze eine E-Mail mit einem Link zum Zurücksetzen deines Passworts – vorausgesetzt, bei deiner Buchung wurde eine E-Mail-Adresse angegeben.');
$this->logger->info('Initiated password reset', [
'email' => $form->get('email')->getData(),
]);
} catch (ApiClientException $e) {
$this->addFlash('error', $e->getMessage());
$this->logger->error('Error initiating password reset', [
'email' => $form->get('email')->getData(),
'error' => $e->getMessage(),
]);
}
$this->passwordResetManager->requestPasswordReset($form->get('email')->getData());
$this->addFlash('info', 'Du erhältst in Kürze eine E-Mail mit einem Link zum Zurücksetzen deines Passworts, sofern zu deinem Konto eine E-Mail-Adresse hinterlegt ist.');
return $this->redirectToRoute('app_login');
}
@@ -66,4 +55,45 @@ class ResetPasswordController extends AbstractController
'form' => $form,
]);
}
#[Route('/reset-password/confirm', name: 'app_reset_password_confirm', methods: ['GET', 'POST'])]
#[IsGranted('PUBLIC_ACCESS')]
public function confirm(Request $request): Response
{
$user = $this->passwordResetManager->validateResetRequest($request);
$form = $this
->createFormBuilder(null, [
'action' => $request->getUri(),
])
->add('password', RepeatedType::class, [
'type' => PasswordType::class,
'first_options' => [
'label' => 'Neues Passwort',
],
'second_options' => [
'label' => 'Neues Passwort wiederholen',
],
'invalid_message' => 'Die beiden Passwortangaben müssen identisch sein.',
'constraints' => [
new NotBlank([
'message' => 'Bitte angeben',
]),
],
])
->getForm()
;
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->passwordResetManager->resetPassword($user, $form->get('password')->getData());
$this->addFlash('info', 'Dein Passwort wurde aktualisiert. Du kannst dich jetzt mit dem neuen Passwort anmelden.');
return $this->redirectToRoute('app_login');
}
return $this->render('reset_password/confirm.html.twig', [
'form' => $form,
]);
}
}
+15
View File
@@ -45,6 +45,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(type: 'boolean')]
private bool $profileComplete = false;
#[ORM\Column(type: 'string', length: 64, nullable: true)]
private ?string $passwordResetNonce = null;
public function __construct(string $email)
{
$this->email = $email;
@@ -172,6 +175,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function getPasswordResetNonce(): ?string
{
return $this->passwordResetNonce;
}
public function setPasswordResetNonce(?string $passwordResetNonce): static
{
$this->passwordResetNonce = $passwordResetNonce;
return $this;
}
public function eraseCredentials(): void
{
}
+9
View File
@@ -17,4 +17,13 @@ class UserRepository extends ServiceEntityRepository
{
parent::__construct($registry, User::class);
}
public function findOneByNormalizedEmail(string $email): ?User
{
return $this->createQueryBuilder('u')
->where('LOWER(u.email) = :email')
->setParameter('email', mb_strtolower(trim($email)))
->getQuery()
->getOneOrNullResult();
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Email\Mailer;
use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\UriSigner;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Psr\Log\LoggerInterface;
class PasswordResetManager
{
private const int RESET_TTL_SECONDS = 3600;
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly UriSigner $uriSigner,
private readonly Mailer $mailer,
private readonly LoggerInterface $logger,
) {
}
public function requestPasswordReset(string $email): bool
{
$normalizedEmail = $this->normalizeEmail($email);
$user = $this->userRepository->findOneByNormalizedEmail($normalizedEmail);
if (null === $user) {
return false;
}
$previousNonce = $user->getPasswordResetNonce();
$nonce = $this->generateNonce();
$user->setPasswordResetNonce($nonce);
$this->entityManager->flush();
$resetUrl = $this->buildResetUrl($normalizedEmail, $nonce);
try {
$this->mailer->createAndSendEmail(
[
'resetUrl' => $resetUrl,
],
[
'to' => $normalizedEmail,
'subject' => 'Dein MyE&P Passwort zurücksetzen',
'template' => 'email/password_reset.html.twig',
],
);
} catch (\Throwable $exception) {
$user->setPasswordResetNonce($previousNonce);
$this->entityManager->flush();
$this->logger->error('Could not send password reset email', [
'email' => $normalizedEmail,
'error' => $exception->getMessage(),
]);
return false;
}
$this->logger->info('Password reset requested', [
'email' => $normalizedEmail,
]);
return true;
}
public function validateResetRequest(Request $request): User
{
if (false === $this->uriSigner->checkRequest($request)) {
throw new NotFoundHttpException();
}
$expires = $request->query->getInt('expires');
if (0 >= $expires || $expires < time()) {
throw new NotFoundHttpException();
}
$email = $this->normalizeEmail($request->query->getString('email'));
$nonce = trim($request->query->getString('nonce'));
if ('' === $email || '' === $nonce) {
throw new NotFoundHttpException();
}
$user = $this->userRepository->findOneByNormalizedEmail($email);
if (null === $user || null === $user->getPasswordResetNonce() || false === hash_equals($user->getPasswordResetNonce(), $nonce)) {
throw new NotFoundHttpException();
}
return $user;
}
public function resetPassword(User $user, string $plainPassword): void
{
$user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword));
$user->setPasswordResetNonce($this->generateNonce());
$this->entityManager->flush();
$this->logger->info('Password reset completed', [
'email' => $user->getUserIdentifier(),
]);
}
private function buildResetUrl(string $normalizedEmail, string $nonce): string
{
$url = $this->urlGenerator->generate('app_reset_password_confirm', [
'email' => $normalizedEmail,
'nonce' => $nonce,
'expires' => time() + self::RESET_TTL_SECONDS,
], UrlGeneratorInterface::ABSOLUTE_URL);
return $this->uriSigner->sign($url);
}
private function normalizeEmail(string $email): string
{
return mb_strtolower(trim($email));
}
private function generateNonce(): string
{
return bin2hex(random_bytes(32));
}
}