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

+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));
}
}