feat: local password reset
This commit is contained in:
1 parent
fe562e5e73
commit
8bb7f08074
11 files changed
+688
-19
No files matched your search
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user