chg: usr: add forgot password functionality #4
This commit is contained in:
@@ -11,6 +11,8 @@
|
|||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use DateTime;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
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')]
|
#[Route('/activate/{token}', name: 'MineSeekerBundle_activate')]
|
||||||
public function activate(string $token, EntityManagerInterface $em): Response
|
public function activate(string $token, EntityManagerInterface $em): Response
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
use App\Repository\UserRepository;
|
use App\Repository\UserRepository;
|
||||||
|
use DateTime;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping\Column;
|
use Doctrine\ORM\Mapping\Column;
|
||||||
use Doctrine\ORM\Mapping\Entity;
|
use Doctrine\ORM\Mapping\Entity;
|
||||||
use Doctrine\ORM\Mapping\GeneratedValue;
|
use Doctrine\ORM\Mapping\GeneratedValue;
|
||||||
@@ -55,6 +57,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
#[Column(length: 64, nullable: true)]
|
#[Column(length: 64, nullable: true)]
|
||||||
private ?string $verificationToken = null;
|
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
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
@@ -137,4 +145,26 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
$this->verificationToken = $verificationToken;
|
$this->verificationToken = $verificationToken;
|
||||||
return $this;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
src/Migrations/2026/04/Version20260412000000.php
Normal file
44
src/Migrations/2026/04/Version20260412000000.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,38 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
|
|||||||
parent::__construct($registry, User::class);
|
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
|
public function findOneByUsername(string $username): ?User
|
||||||
{
|
{
|
||||||
$qb = $this->createQueryBuilder('u');
|
$qb = $this->createQueryBuilder('u');
|
||||||
|
|||||||
60
templates/Security/forgot_password.html.twig
Normal file
60
templates/Security/forgot_password.html.twig
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{% extends 'Game/index.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %} - Forgot Password{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="auth-page">
|
||||||
|
|
||||||
|
{% for email in app.flashes('reset_sent') %}
|
||||||
|
<div class="auth-card auth-card--sent">
|
||||||
|
<div class="auth-sent-icon"><i class="fa fa-envelope-o"></i></div>
|
||||||
|
<h2 class="auth-title">Check your inbox</h2>
|
||||||
|
<p class="auth-sub">If an account exists for that address, we sent a reset link to</p>
|
||||||
|
<p class="auth-sent-email">{{ email }}</p>
|
||||||
|
<p class="auth-sent-note">
|
||||||
|
Click the link in the email to reset your password.<br>
|
||||||
|
The link expires in <strong>1 hour</strong>.
|
||||||
|
</p>
|
||||||
|
<a href="{{ path('MineSeekerBundle_login') }}" class="auth-submit"
|
||||||
|
style="text-decoration:none; margin-top:16px;">
|
||||||
|
Go to Sign In
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<div class="auth-card">
|
||||||
|
<h2 class="auth-title">Forgot Password</h2>
|
||||||
|
<p class="auth-sub">Enter your email and we'll send you a reset link</p>
|
||||||
|
|
||||||
|
<form class="auth-form" method="post" action="{{ path('MineSeekerBundle_forgot_password') }}">
|
||||||
|
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="email" class="auth-label">Email</label>
|
||||||
|
<div class="auth-input-wrap">
|
||||||
|
<i class="fa fa-envelope auth-input-icon"></i>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="_email"
|
||||||
|
class="auth-input"
|
||||||
|
autocomplete="email"
|
||||||
|
autofocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="auth-submit">
|
||||||
|
<i class="fa fa-paper-plane"></i> Send Reset Link
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="auth-switch">
|
||||||
|
Remembered it?
|
||||||
|
<a href="{{ path('MineSeekerBundle_login') }}">Sign in</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -73,6 +73,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<p class="auth-switch">
|
||||||
|
<a href="{{ path('MineSeekerBundle_forgot_password') }}">Forgot your password?</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p class="auth-switch">
|
<p class="auth-switch">
|
||||||
No account yet?
|
No account yet?
|
||||||
<a href="{{ path('MineSeekerBundle_register') }}">Create one</a>
|
<a href="{{ path('MineSeekerBundle_register') }}">Create one</a>
|
||||||
|
|||||||
57
templates/Security/reset_password.html.twig
Normal file
57
templates/Security/reset_password.html.twig
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{% extends 'Game/index.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %} - Reset Password{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="auth-page">
|
||||||
|
<div class="auth-card">
|
||||||
|
<h2 class="auth-title">Reset Password</h2>
|
||||||
|
<p class="auth-sub">Choose a new password for your account</p>
|
||||||
|
|
||||||
|
<form class="auth-form" method="post">
|
||||||
|
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="password" class="auth-label">New Password</label>
|
||||||
|
<div class="auth-input-wrap">
|
||||||
|
<i class="fa fa-lock auth-input-icon"></i>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="_password"
|
||||||
|
class="auth-input{% if errors.password is defined %} auth-input--error{% endif %}"
|
||||||
|
autocomplete="new-password"
|
||||||
|
autofocus
|
||||||
|
required
|
||||||
|
minlength="6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{% if errors.password is defined %}
|
||||||
|
<p class="auth-field-error"><i class="fa fa-exclamation-circle"></i> {{ errors.password }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="password_confirm" class="auth-label">Confirm New Password</label>
|
||||||
|
<div class="auth-input-wrap">
|
||||||
|
<i class="fa fa-lock auth-input-icon"></i>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password_confirm"
|
||||||
|
name="_password_confirm"
|
||||||
|
class="auth-input{% if errors.password_confirm is defined %} auth-input--error{% endif %}"
|
||||||
|
autocomplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{% if errors.password_confirm is defined %}
|
||||||
|
<p class="auth-field-error"><i class="fa fa-exclamation-circle"></i> {{ errors.password_confirm }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="auth-submit">
|
||||||
|
<i class="fa fa-key"></i> Set New Password
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
121
templates/emails/reset_password.html.twig
Normal file
121
templates/emails/reset_password.html.twig
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>Reset your MineSeeker password</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: #07090d;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo img {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #0f1923;
|
||||||
|
border: 1px solid rgba(35, 111, 135, 0.25);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 40px 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(149, 207, 245, 0.7);
|
||||||
|
margin: 0 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 15px;
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
line-height: 1.7;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(to bottom, #ad0a05, #f67d52);
|
||||||
|
color: #ffffff !important;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 16px 48px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallback {
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallback a {
|
||||||
|
color: rgba(149, 207, 245, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 32px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrapper">
|
||||||
|
<div class="logo">
|
||||||
|
<img src="{{ absolute_url(asset('images/mine-logo-txt.png')) }}" alt="MineSeeker"/>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Reset your password</h1>
|
||||||
|
<p class="sub">This link expires in 1 hour</p>
|
||||||
|
<p>
|
||||||
|
Hi <strong>{{ username }}</strong>,<br>
|
||||||
|
We received a request to reset the password for your MineSeeker account.
|
||||||
|
Click the button below to choose a new password.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a class="btn" href="{{ reset_url }}">Reset Password</a>
|
||||||
|
</p>
|
||||||
|
<p class="fallback">
|
||||||
|
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||||
|
<a href="{{ reset_url }}">{{ reset_url }}</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:24px; font-size:13px; color:rgba(255,255,255,0.35);">
|
||||||
|
If you didn't request a password reset, you can safely ignore this email.
|
||||||
|
Your password will not change.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
© {{ "now"|date("Y") }} MineSeeker • This link expires in 1 hour.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user