From e2b227ed7a0912493768eb69ab9b165dc4c6a60f Mon Sep 17 00:00:00 2001 From: Lang <7system7@gmail.com> Date: Sun, 12 Apr 2026 08:10:36 +0200 Subject: [PATCH] chg: usr: add forgot password functionality #4 --- src/Controller/SecurityController.php | 95 ++++++++++++ src/Entity/User.php | 30 ++++ .../2026/04/Version20260412000000.php | 44 ++++++ src/Repository/UserRepository.php | 32 ++++ templates/Security/forgot_password.html.twig | 60 ++++++++ templates/Security/login.html.twig | 142 +++++++++--------- templates/Security/reset_password.html.twig | 57 +++++++ templates/emails/reset_password.html.twig | 121 +++++++++++++++ 8 files changed, 512 insertions(+), 69 deletions(-) create mode 100644 src/Migrations/2026/04/Version20260412000000.php create mode 100644 templates/Security/forgot_password.html.twig create mode 100644 templates/Security/reset_password.html.twig create mode 100644 templates/emails/reset_password.html.twig 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 %} +
+ + {% for email in app.flashes('reset_sent') %} +
+
+

Check your inbox

+

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. +

+ + Go to Sign In + +
+ {% else %} + +
+

Forgot Password

+

Enter your email and we'll send you a reset link

+ +
+ +
+ +
+ + +
+
+ + +
+ +

+ Remembered it? + Sign in +

+
+ + {% endfor %} +
+{% endblock %} diff --git a/templates/Security/login.html.twig b/templates/Security/login.html.twig index d5e0451..b3b9b71 100644 --- a/templates/Security/login.html.twig +++ b/templates/Security/login.html.twig @@ -3,81 +3,85 @@ {% block title %} - Sign In{% endblock %} {% block body %} -
+
- {% for message in app.flashes('success') %} -
- {{ message }} -
- {% endfor %} + {% for message in app.flashes('success') %} +
+ {{ message }} +
+ {% endfor %} - {% for message in app.flashes('error') %} -
- {{ message }} -
- {% endfor %} + {% for message in app.flashes('error') %} +
+ {{ message }} +
+ {% endfor %} -
-

Sign In

-

Welcome back, commander

+
+

Sign In

+

Welcome back, commander

- {% if error %} -
- - {{ error.messageKey|trans(error.messageData, 'security') }} -
- {% endif %} + {% if error %} +
+ + {{ error.messageKey|trans(error.messageData, 'security') }} +
+ {% endif %} -
- + + -
- -
- - -
-
- -
- -
- - -
-
- - - - -
- -

- No account yet? - Create one -

+
+ +
+ + +
+
+ +
+ + +
+
+ + + + + + +

+ Forgot your password? +

+ +

+ No account yet? + Create one +

-{% endblock %} \ No newline at end of file + +
+{% endblock %} diff --git a/templates/Security/reset_password.html.twig b/templates/Security/reset_password.html.twig new file mode 100644 index 0000000..cdc7582 --- /dev/null +++ b/templates/Security/reset_password.html.twig @@ -0,0 +1,57 @@ +{% extends 'Game/index.html.twig' %} + +{% block title %} - Reset Password{% endblock %} + +{% block body %} +
+
+

Reset Password

+

Choose a new password for your account

+ +
+ +
+ +
+ + +
+ {% if errors.password is defined %} +

{{ errors.password }}

+ {% endif %} +
+ +
+ +
+ + +
+ {% if errors.password_confirm is defined %} +

{{ errors.password_confirm }}

+ {% endif %} +
+ + +
+
+
+{% endblock %} diff --git a/templates/emails/reset_password.html.twig b/templates/emails/reset_password.html.twig new file mode 100644 index 0000000..380fe38 --- /dev/null +++ b/templates/emails/reset_password.html.twig @@ -0,0 +1,121 @@ + + + + + + Reset your MineSeeker password + + + +
+ +
+

Reset your password

+

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. +

+
+ +
+ +