Skip to content

Commit 4b6ac65

Browse files
authored
Added reset password support (#36)
1 parent 0550ff4 commit 4b6ac65

32 files changed

+744
-22
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"symfony/validator": "5.3.*",
4848
"symfony/web-link": "5.3.*",
4949
"symfony/yaml": "5.3.*",
50+
"symfonycasts/reset-password-bundle": "^1.9",
5051
"twig/cssinliner-extra": "^3.3",
5152
"twig/extra-bundle": "^2.12|^3.0",
5253
"twig/inky-extra": "^3.3",

composer.lock

Lines changed: 53 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/bundles.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,5 @@
1919
Nelmio\Alice\Bridge\Symfony\NelmioAliceBundle::class => ['dev' => true, 'test' => true],
2020
Fidry\AliceDataFixtures\Bridge\Symfony\FidryAliceDataFixturesBundle::class => ['dev' => true, 'test' => true],
2121
Hautelook\AliceBundle\HautelookAliceBundle::class => ['dev' => true, 'test' => true],
22+
SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true],
2223
];
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
symfonycasts_reset_password:
2+
request_password_repository: App\Repository\ResetPasswordRequestRepository
3+
lifetime: 3600
4+
throttle_limit: 3600
5+
enable_garbage_collection: true

config/packages/security.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,5 @@ security:
4646
# Note: Only the *first* access control that matches will be used
4747
access_control:
4848
- { path: ^/login, roles: PUBLIC_ACCESS }
49+
- { path: ^/reset-password, roles: PUBLIC_ACCESS }
4950
- { path: ^/, roles: ROLE_USER }

config/packages/twig.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
twig:
22
default_path: '%kernel.project_dir%/templates'
3+
form_themes:
4+
- 'bootstrap_5_layout.html.twig'
35

46
when@test:
57
twig:
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DoctrineMigrations;
6+
7+
use Doctrine\DBAL\Schema\Schema;
8+
use Doctrine\Migrations\AbstractMigration;
9+
10+
final class Version20210903135827 extends AbstractMigration
11+
{
12+
public function getDescription(): string
13+
{
14+
return 'Add Reset Password support';
15+
}
16+
17+
public function up(Schema $schema): void
18+
{
19+
$this->addSql('CREATE TABLE reset_password_request (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, selector VARCHAR(20) NOT NULL, hashed_token VARCHAR(100) NOT NULL, requested_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', expires_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_7CE748AA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
20+
$this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES user (id)');
21+
}
22+
23+
public function down(Schema $schema): void
24+
{
25+
$this->addSql('DROP TABLE reset_password_request');
26+
}
27+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Controller;
6+
7+
use App\Entity\User;
8+
use App\Form\Model\ResetPasswordModel;
9+
use App\Form\Model\ResetPasswordRequestModel;
10+
use App\Form\Type\ResetPasswordRequestType;
11+
use App\Form\Type\ResetPasswordType;
12+
use Symfony\Bridge\Twig\Mime\NotificationEmail;
13+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
14+
use Symfony\Component\HttpFoundation\RedirectResponse;
15+
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\HttpFoundation\Response;
17+
use Symfony\Component\Mailer\MailerInterface;
18+
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
19+
use Symfony\Component\Routing\Annotation\Route;
20+
use Symfony\Component\Translation\TranslatableMessage;
21+
use Symfony\Contracts\Translation\TranslatorInterface;
22+
use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait;
23+
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
24+
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
25+
26+
#[Route('/reset-password')]
27+
final class ResetPasswordController extends AbstractController
28+
{
29+
use ResetPasswordControllerTrait;
30+
31+
public function __construct(
32+
private ResetPasswordHelperInterface $resetPasswordHelper,
33+
private TranslatorInterface $translator,
34+
) {
35+
}
36+
37+
/**
38+
* Display & process form to request a password reset.
39+
*/
40+
#[Route('', name: 'app_forgot_password_request')]
41+
public function request(Request $request, MailerInterface $mailer): Response
42+
{
43+
$resetPasswordRequest = new ResetPasswordRequestModel();
44+
$form = $this->createForm(ResetPasswordRequestType::class, $resetPasswordRequest);
45+
$form->handleRequest($request);
46+
47+
if ($form->isSubmitted() && $form->isValid()) {
48+
return $this->processSendingPasswordResetEmail(
49+
$resetPasswordRequest->email,
50+
$mailer
51+
);
52+
}
53+
54+
return $this->renderForm('reset_password/request.html.twig', [
55+
'requestForm' => $form,
56+
]);
57+
}
58+
59+
/**
60+
* Confirmation page after a user has requested a password reset.
61+
*/
62+
#[Route('/check-email', name: 'app_check_email')]
63+
public function checkEmail(): Response
64+
{
65+
// Generate a fake token if the user does not exist or someone hit this page directly.
66+
// This prevents exposing whether or not a user was found with the given email address or not
67+
if (null === ($resetToken = $this->getTokenObjectFromSession())) {
68+
$resetToken = $this->resetPasswordHelper->generateFakeResetToken();
69+
}
70+
71+
return $this->render('reset_password/check_email.html.twig', [
72+
'resetToken' => $resetToken,
73+
]);
74+
}
75+
76+
/**
77+
* Validates and process the reset URL that the user clicked in their email.
78+
*/
79+
#[Route('/reset/{token}', name: 'app_reset_password')]
80+
public function reset(Request $request, UserPasswordHasherInterface $hasher, string $token = null): Response
81+
{
82+
if ($token) {
83+
// We store the token in session and remove it from the URL, to avoid the URL being
84+
// loaded in a browser and potentially leaking the token to 3rd party JavaScript.
85+
$this->storeTokenInSession($token);
86+
87+
return $this->redirectToRoute('app_reset_password');
88+
}
89+
90+
$token = $this->getTokenFromSession();
91+
if (null === $token) {
92+
throw $this->createNotFoundException('No reset password token found in the URL or in the session.');
93+
}
94+
95+
try {
96+
/** @var User $user */
97+
$user = $this->resetPasswordHelper->validateTokenAndFetchUser($token);
98+
} catch (ResetPasswordExceptionInterface $e) {
99+
$this->addFlash('danger', new TranslatableMessage('reset_password.reset.flash_error', [
100+
'%message%' => $this->translator->trans(
101+
$e->getReason(),
102+
domain: 'reset_password'
103+
),
104+
]));
105+
106+
return $this->redirectToRoute('app_forgot_password_request');
107+
}
108+
109+
// The token is valid; allow the user to change their password.
110+
$resetPassword = new ResetPasswordModel();
111+
$form = $this->createForm(ResetPasswordType::class, $resetPassword);
112+
$form->handleRequest($request);
113+
114+
if ($form->isSubmitted() && $form->isValid()) {
115+
// A password reset token should be used only once, remove it.
116+
$this->resetPasswordHelper->removeResetRequest($token);
117+
118+
// Encode the plain password, and set it.
119+
$encodedPassword = $hasher->hashPassword(
120+
$user,
121+
$resetPassword->plainPassword
122+
);
123+
124+
$user->setPassword($encodedPassword);
125+
$this->getDoctrine()->getManager()->flush();
126+
127+
// The session is cleaned up after the password has been changed.
128+
$this->cleanSessionAfterReset();
129+
130+
$this->addFlash(
131+
'success',
132+
new TranslatableMessage('reset_password.reset.flash_success')
133+
);
134+
135+
return $this->redirectToRoute('app_login');
136+
}
137+
138+
return $this->renderForm('reset_password/reset.html.twig', [
139+
'resetForm' => $form,
140+
]);
141+
}
142+
143+
private function processSendingPasswordResetEmail(string $emailFormData, MailerInterface $mailer): RedirectResponse
144+
{
145+
/** @var ?User $user */
146+
$user = $this->getDoctrine()->getRepository(User::class)->findOneBy([
147+
'email' => $emailFormData,
148+
]);
149+
150+
// Do not reveal whether a user account was found or not.
151+
if (!$user) {
152+
return $this->redirectToRoute('app_check_email');
153+
}
154+
155+
try {
156+
$resetToken = $this->resetPasswordHelper->generateResetToken($user);
157+
} catch (ResetPasswordExceptionInterface) {
158+
return $this->redirectToRoute('app_check_email');
159+
}
160+
161+
$locale = $user->getLocale();
162+
$subject = $this->translator->trans('reset_password.subject', [], 'email', $locale);
163+
164+
$email = (new NotificationEmail())
165+
->to($user->getEmail())
166+
->subject($subject)
167+
->htmlTemplate('reset_password/email.html.twig')
168+
->markAsPublic()
169+
->context([
170+
'footer_text' => $this->translator->trans('reset_password.footer', [], 'email', $locale),
171+
'resetToken' => $resetToken,
172+
'locale' => $locale,
173+
])
174+
;
175+
176+
$mailer->send($email);
177+
178+
// Store the token object in session for retrieval in check-email route.
179+
$this->setTokenObjectInSession($resetToken);
180+
181+
return $this->redirectToRoute('app_check_email');
182+
}
183+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Entity;
6+
7+
use App\Entity\Traits\PrimaryKeyTrait;
8+
use App\Repository\ResetPasswordRequestRepository;
9+
use Doctrine\ORM\Mapping as ORM;
10+
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
11+
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestTrait;
12+
13+
#[ORM\Entity(repositoryClass: ResetPasswordRequestRepository::class)]
14+
class ResetPasswordRequest implements ResetPasswordRequestInterface
15+
{
16+
use PrimaryKeyTrait;
17+
use ResetPasswordRequestTrait;
18+
19+
#[ORM\ManyToOne(targetEntity: User::class)]
20+
#[ORM\JoinColumn(nullable: false)]
21+
private User $user;
22+
23+
public function __construct(User $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken)
24+
{
25+
$this->user = $user;
26+
$this->initialize($expiresAt, $selector, $hashedToken);
27+
}
28+
29+
public function getUser(): object
30+
{
31+
return $this->user;
32+
}
33+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Form\Model;
6+
7+
use Symfony\Component\Validator\Constraints as Assert;
8+
9+
class ResetPasswordModel
10+
{
11+
#[Assert\NotBlank()]
12+
public ?string $plainPassword = null;
13+
}

0 commit comments

Comments
 (0)