diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index 0e7723c..e0f1770 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -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 { diff --git a/src/Entity/User.php b/src/Entity/User.php index 4fab311..d7dfae0 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -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; + } } diff --git a/src/Migrations/2026/04/Version20260412000000.php b/src/Migrations/2026/04/Version20260412000000.php new file mode 100644 index 0000000..8bf176c --- /dev/null +++ b/src/Migrations/2026/04/Version20260412000000.php @@ -0,0 +1,44 @@ + + * @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'); + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 6b2a9de..3857c1e 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -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'); diff --git a/templates/Security/forgot_password.html.twig b/templates/Security/forgot_password.html.twig new file mode 100644 index 0000000..89052ca --- /dev/null +++ b/templates/Security/forgot_password.html.twig @@ -0,0 +1,60 @@ +{% extends 'Game/index.html.twig' %} + +{% block title %} - Forgot Password{% endblock %} + +{% block body %} +
If an account exists for that address, we sent a reset link to
+{{ email }}
+
+ Click the link in the email to reset your password.
+ The link expires in 1 hour.
+
Enter your email and we'll send you a reset link
+ + + ++ Remembered it? + Sign in +
+Welcome back, commander
+Welcome back, commander
- {% if error %} -- No account yet? - Create one -
++ No account yet? + Create one +
Choose a new password for your account
+ + +
+ This link expires in 1 hour
+
+ Hi {{ username }},
+ We received a request to reset the password for your MineSeeker account.
+ Click the button below to choose a new password.
+
+ Reset Password +
+
+ If the button doesn't work, copy and paste this link into your browser:
+ {{ reset_url }}
+
+ If you didn't request a password reset, you can safely ignore this email. + Your password will not change. +
+