Private
Public Access
1
0

chg: usr: add forgot password functionality #4

This commit is contained in:
2026-04-12 08:10:36 +02:00
parent c0dcc2896a
commit e2b227ed7a
8 changed files with 512 additions and 69 deletions

View File

@@ -11,6 +11,8 @@
namespace App\Controller;
use App\Entity\User;
use App\Repository\UserRepository;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -137,6 +139,99 @@ class SecurityController extends AbstractController
]);
}
#[Route('/forgot-password', name: 'MineSeekerBundle_forgot_password')]
public function forgotPassword(
Request $request,
UserRepository $userRepository,
EntityManagerInterface $em,
MailerInterface $mailer,
): Response {
if ($this->getUser()) {
return $this->redirectToRoute('MineSeekerBundle_homepage');
}
if ($request->isMethod('POST')) {
$email = trim((string) $request->request->get('_email', ''));
$user = $userRepository->findOneByEmail($email);
if ($user && $user->isVerified()) {
$token = bin2hex(random_bytes(32));
$user
->setResetToken($token)
->setResetTokenExpiresAt(new DateTime('+1 hour'));
$em->flush();
$resetUrl = $this->generateUrl(
'MineSeekerBundle_reset_password',
['token' => $token],
UrlGeneratorInterface::ABSOLUTE_URL,
);
$mailer->send(
new TemplatedEmail()
->from('noreply@mineseeker.ninja')
->to($email)
->subject('Reset your MineSeeker password')
->htmlTemplate('emails/reset_password.html.twig')
->context([
'username' => $user->getUsername(),
'reset_url' => $resetUrl,
])
);
}
// Always show the same flash to prevent email enumeration
$this->addFlash('reset_sent', $email);
return $this->redirectToRoute('MineSeekerBundle_forgot_password');
}
return $this->render('Security/forgot_password.html.twig');
}
#[Route('/reset-password/{token}', name: 'MineSeekerBundle_reset_password')]
public function resetPassword(
string $token,
Request $request,
UserRepository $userRepository,
EntityManagerInterface $em,
UserPasswordHasherInterface $hasher,
): Response {
$user = $userRepository->findOneByResetToken($token);
if (!$user || $user->getResetTokenExpiresAt() < new DateTime()) {
$this->addFlash('error', 'This password reset link is invalid or has expired.');
return $this->redirectToRoute('MineSeekerBundle_forgot_password');
}
$errors = [];
if ($request->isMethod('POST')) {
$password = (string) $request->request->get('_password', '');
$passwordConfirm = (string) $request->request->get('_password_confirm', '');
if (mb_strlen($password) < 6) {
$errors['password'] = 'Password must be at least 6 characters.';
} elseif ($password !== $passwordConfirm) {
$errors['password_confirm'] = 'Passwords do not match.';
}
if (empty($errors)) {
$user
->setPassword($hasher->hashPassword($user, $password))
->setResetToken(null)
->setResetTokenExpiresAt(null);
$em->flush();
$this->addFlash('success', 'Your password has been reset. You can now sign in.');
return $this->redirectToRoute('MineSeekerBundle_login');
}
}
return $this->render('Security/reset_password.html.twig', ['errors' => $errors]);
}
#[Route('/activate/{token}', name: 'MineSeekerBundle_activate')]
public function activate(string $token, EntityManagerInterface $em): Response
{

View File

@@ -11,6 +11,8 @@
namespace App\Entity;
use App\Repository\UserRepository;
use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
@@ -55,6 +57,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[Column(length: 64, nullable: true)]
private ?string $verificationToken = null;
#[Column(length: 64, nullable: true)]
private ?string $resetToken = null;
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?DateTime $resetTokenExpiresAt = null;
public function getId(): ?int
{
@@ -137,4 +145,26 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
$this->verificationToken = $verificationToken;
return $this;
}
public function getResetToken(): ?string
{
return $this->resetToken;
}
public function setResetToken(?string $resetToken): self
{
$this->resetToken = $resetToken;
return $this;
}
public function getResetTokenExpiresAt(): ?DateTime
{
return $this->resetTokenExpiresAt;
}
public function setResetTokenExpiresAt(?DateTime $resetTokenExpiresAt): self
{
$this->resetTokenExpiresAt = $resetTokenExpiresAt;
return $this;
}
}

View File

@@ -0,0 +1,44 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20260412000000
*
* @package App\Migrations
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 12.
*/
final class Version20260412000000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add password reset token fields to app_user';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE app_user ADD reset_token VARCHAR(64) DEFAULT NULL');
$this->addSql('ALTER TABLE app_user ADD reset_token_expires_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE app_user DROP reset_token');
$this->addSql('ALTER TABLE app_user DROP reset_token_expires_at');
}
}

View File

@@ -39,6 +39,38 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
parent::__construct($registry, User::class);
}
public function findOneByEmail(string $email): ?User
{
$qb = $this->createQueryBuilder('u');
try {
return $qb
->where($qb->expr()->eq('u.email', ':email'))
->setParameter('email', $email)
->getQuery()
->getOneOrNullResult();
} catch (NonUniqueResultException $e) {
$this->logger->error($e->getMessage());
throw new RuntimeException("Multiple users found with the same email: $email", 0, $e);
}
}
public function findOneByResetToken(string $token): ?User
{
$qb = $this->createQueryBuilder('u');
try {
return $qb
->where($qb->expr()->eq('u.resetToken', ':token'))
->setParameter('token', $token)
->getQuery()
->getOneOrNullResult();
} catch (NonUniqueResultException $e) {
$this->logger->error($e->getMessage());
throw new RuntimeException("Multiple users found with the same reset token", 0, $e);
}
}
public function findOneByUsername(string $username): ?User
{
$qb = $this->createQueryBuilder('u');