Skip to content

Commit a8ad835

Browse files
committed
Merge branch main into 2.x
* main: minor #319 [tests] test bundle against actual symfony app
2 parents ae5c659 + b64c0bb commit a8ad835

18 files changed

+672
-1
lines changed

.php-cs-fixer.dist.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313

1414
$finder = (new PhpCsFixer\Finder())
1515
->in([__DIR__.'/src', __DIR__.'/tests'])
16+
->exclude([
17+
__DIR__.'tests/tmp'
18+
])
1619
;
1720

1821
return (new PhpCsFixer\Config())

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"symfony/framework-bundle": "^6.4.5 | ^7.0",
1818
"symfony/phpunit-bridge": "^6.4.5 | ^7.0",
1919
"doctrine/doctrine-bundle": "^2.8",
20-
"phpstan/phpstan": "^1.11.x-dev"
20+
"phpstan/phpstan": "^1.11.x-dev",
21+
"symfony/process": "^6.4 | ^7.0 | ^7.1"
2122
},
2223
"autoload": {
2324
"psr-4": {

phpunit.xml.dist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
<testsuites>
2525
<testsuite name="all">
2626
<directory>./tests</directory>
27+
<exclude>tests/tmp</exclude>
28+
<exclude>tests/Fixtures</exclude>
2729
</testsuite>
2830
<testsuite name="unit">
2931
<directory>./tests/Unit</directory>

tests/Fixtures/App/.env

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
APP_ENV=dev
2+
APP_SECRET=7e6cd3398232b047dc249e51729039fa
3+
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
4+
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
5+
MAILER_DSN=null://null
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
symfonycasts_reset_password:
2+
request_password_repository: App\Repository\ResetPasswordRequestRepository

tests/Fixtures/App/config/routes.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
controllers:
2+
resource:
3+
path: ../src/Controller/
4+
namespace: App\Controller
5+
type: attribute
6+
7+
app_home:
8+
path: /
9+
controller: Symfony\Bundle\FrameworkBundle\Controller\TemplateController
10+
defaults:
11+
template: 'base.html.twig'
12+
statusCode: 200
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the SymfonyCasts ResetPasswordBundle package.
5+
* Copyright (c) SymfonyCasts <https://symfonycasts.com/>
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
namespace App\Controller;
11+
12+
use App\Entity\User;
13+
use App\Form\ChangePasswordFormType;
14+
use App\Form\ResetPasswordRequestFormType;
15+
use Doctrine\ORM\EntityManagerInterface;
16+
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
17+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
18+
use Symfony\Component\HttpFoundation\RedirectResponse;
19+
use Symfony\Component\HttpFoundation\Request;
20+
use Symfony\Component\HttpFoundation\Response;
21+
use Symfony\Component\Mailer\MailerInterface;
22+
use Symfony\Component\Mime\Address;
23+
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
24+
use Symfony\Component\Routing\Attribute\Route;
25+
use Symfony\Contracts\Translation\TranslatorInterface;
26+
use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait;
27+
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
28+
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
29+
30+
#[Route('/reset-password')]
31+
class ResetPasswordController extends AbstractController
32+
{
33+
use ResetPasswordControllerTrait;
34+
35+
public function __construct(
36+
private ResetPasswordHelperInterface $resetPasswordHelper,
37+
private EntityManagerInterface $entityManager
38+
) {
39+
}
40+
41+
/**
42+
* Display & process form to request a password reset.
43+
*/
44+
#[Route('', name: 'app_forgot_password_request')]
45+
public function request(Request $request, MailerInterface $mailer, TranslatorInterface $translator): Response
46+
{
47+
$form = $this->createForm(ResetPasswordRequestFormType::class);
48+
$form->handleRequest($request);
49+
50+
if ($form->isSubmitted() && $form->isValid()) {
51+
return $this->processSendingPasswordResetEmail(
52+
$form->get('email')->getData(),
53+
$mailer,
54+
$translator
55+
);
56+
}
57+
58+
return $this->render('reset_password/request.html.twig', [
59+
'requestForm' => $form,
60+
]);
61+
}
62+
63+
/**
64+
* Confirmation page after a user has requested a password reset.
65+
*/
66+
#[Route('/check-email', name: 'app_check_email')]
67+
public function checkEmail(): Response
68+
{
69+
// Generate a fake token if the user does not exist or someone hit this page directly.
70+
// This prevents exposing whether or not a user was found with the given email address or not
71+
if (null === ($resetToken = $this->getTokenObjectFromSession())) {
72+
$resetToken = $this->resetPasswordHelper->generateFakeResetToken();
73+
}
74+
75+
return $this->render('reset_password/check_email.html.twig', [
76+
'resetToken' => $resetToken,
77+
]);
78+
}
79+
80+
/**
81+
* Validates and process the reset URL that the user clicked in their email.
82+
*/
83+
#[Route('/reset/{token}', name: 'app_reset_password')]
84+
public function reset(Request $request, UserPasswordHasherInterface $passwordHasher, TranslatorInterface $translator, ?string $token = null): Response
85+
{
86+
if ($token) {
87+
// We store the token in session and remove it from the URL, to avoid the URL being
88+
// loaded in a browser and potentially leaking the token to 3rd party JavaScript.
89+
$this->storeTokenInSession($token);
90+
91+
return $this->redirectToRoute('app_reset_password');
92+
}
93+
94+
$token = $this->getTokenFromSession();
95+
96+
if (null === $token) {
97+
throw $this->createNotFoundException('No reset password token found in the URL or in the session.');
98+
}
99+
100+
try {
101+
/** @var User $user */
102+
$user = $this->resetPasswordHelper->validateTokenAndFetchUser($token);
103+
} catch (ResetPasswordExceptionInterface $e) {
104+
$this->addFlash('reset_password_error', sprintf(
105+
'%s - %s',
106+
$translator->trans(ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE, [], 'ResetPasswordBundle'),
107+
$translator->trans($e->getReason(), [], 'ResetPasswordBundle')
108+
));
109+
110+
return $this->redirectToRoute('app_forgot_password_request');
111+
}
112+
113+
// The token is valid; allow the user to change their password.
114+
$form = $this->createForm(ChangePasswordFormType::class);
115+
$form->handleRequest($request);
116+
117+
if ($form->isSubmitted() && $form->isValid()) {
118+
// A password reset token should be used only once, remove it.
119+
$this->resetPasswordHelper->removeResetRequest($token);
120+
121+
// Encode(hash) the plain password, and set it.
122+
$encodedPassword = $passwordHasher->hashPassword(
123+
$user,
124+
$form->get('plainPassword')->getData()
125+
);
126+
127+
$user->setPassword($encodedPassword);
128+
$this->entityManager->flush();
129+
130+
// The session is cleaned up after the password has been changed.
131+
$this->cleanSessionAfterReset();
132+
133+
return $this->redirectToRoute('app_home');
134+
}
135+
136+
return $this->render('reset_password/reset.html.twig', [
137+
'resetForm' => $form,
138+
]);
139+
}
140+
141+
private function processSendingPasswordResetEmail(string $emailFormData, MailerInterface $mailer, TranslatorInterface $translator): RedirectResponse
142+
{
143+
$user = $this->entityManager->getRepository(User::class)->findOneBy([
144+
'email' => $emailFormData,
145+
]);
146+
147+
// Do not reveal whether a user account was found or not.
148+
if (!$user) {
149+
return $this->redirectToRoute('app_check_email');
150+
}
151+
152+
try {
153+
$resetToken = $this->resetPasswordHelper->generateResetToken($user);
154+
} catch (ResetPasswordExceptionInterface $e) {
155+
// If you want to tell the user why a reset email was not sent, uncomment
156+
// the lines below and change the redirect to 'app_forgot_password_request'.
157+
// Caution: This may reveal if a user is registered or not.
158+
//
159+
// $this->addFlash('reset_password_error', sprintf(
160+
// '%s - %s',
161+
// $translator->trans(ResetPasswordExceptionInterface::MESSAGE_PROBLEM_HANDLE, [], 'ResetPasswordBundle'),
162+
// $translator->trans($e->getReason(), [], 'ResetPasswordBundle')
163+
// ));
164+
165+
return $this->redirectToRoute('app_check_email');
166+
}
167+
168+
$email = (new TemplatedEmail())
169+
->from(new Address('bot@example.com', 'SymfonyCasts'))
170+
->to($user->getEmail())
171+
->subject('Your password reset request')
172+
->htmlTemplate('reset_password/email.html.twig')
173+
->context([
174+
'resetToken' => $resetToken,
175+
])
176+
;
177+
178+
$mailer->send($email);
179+
180+
// Store the token object in session for retrieval in check-email route.
181+
$this->setTokenObjectInSession($resetToken);
182+
183+
return $this->redirectToRoute('app_check_email');
184+
}
185+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the SymfonyCasts ResetPasswordBundle package.
5+
* Copyright (c) SymfonyCasts <https://symfonycasts.com/>
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
namespace App\Entity;
11+
12+
use App\Repository\ResetPasswordRequestRepository;
13+
use Doctrine\ORM\Mapping as ORM;
14+
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
15+
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestTrait;
16+
17+
#[ORM\Entity(repositoryClass: ResetPasswordRequestRepository::class)]
18+
class ResetPasswordRequest implements ResetPasswordRequestInterface
19+
{
20+
use ResetPasswordRequestTrait;
21+
22+
#[ORM\Id]
23+
#[ORM\GeneratedValue]
24+
#[ORM\Column]
25+
private ?int $id = null;
26+
27+
#[ORM\ManyToOne]
28+
#[ORM\JoinColumn(nullable: false)]
29+
private ?User $user = null;
30+
31+
public function __construct(User $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken)
32+
{
33+
$this->user = $user;
34+
$this->initialize($expiresAt, $selector, $hashedToken);
35+
}
36+
37+
public function getId(): ?int
38+
{
39+
return $this->id;
40+
}
41+
42+
public function getUser(): User
43+
{
44+
return $this->user;
45+
}
46+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the SymfonyCasts ResetPasswordBundle package.
5+
* Copyright (c) SymfonyCasts <https://symfonycasts.com/>
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
namespace App\Form;
11+
12+
use Symfony\Component\Form\AbstractType;
13+
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
14+
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
15+
use Symfony\Component\Form\FormBuilderInterface;
16+
use Symfony\Component\OptionsResolver\OptionsResolver;
17+
use Symfony\Component\Validator\Constraints\Length;
18+
use Symfony\Component\Validator\Constraints\NotBlank;
19+
use Symfony\Component\Validator\Constraints\NotCompromisedPassword;
20+
use Symfony\Component\Validator\Constraints\PasswordStrength;
21+
22+
class ChangePasswordFormType extends AbstractType
23+
{
24+
public function buildForm(FormBuilderInterface $builder, array $options): void
25+
{
26+
$builder
27+
->add('plainPassword', RepeatedType::class, [
28+
'type' => PasswordType::class,
29+
'options' => [
30+
'attr' => [
31+
'autocomplete' => 'new-password',
32+
],
33+
],
34+
'first_options' => [
35+
'constraints' => [
36+
new NotBlank([
37+
'message' => 'Please enter a password',
38+
]),
39+
new Length([
40+
'min' => 12,
41+
'minMessage' => 'Your password should be at least {{ limit }} characters',
42+
// max length allowed by Symfony for security reasons
43+
'max' => 4096,
44+
]),
45+
new PasswordStrength(),
46+
new NotCompromisedPassword(),
47+
],
48+
'label' => 'New password',
49+
],
50+
'second_options' => [
51+
'label' => 'Repeat Password',
52+
],
53+
'invalid_message' => 'The password fields must match.',
54+
// Instead of being set onto the object directly,
55+
// this is read and encoded in the controller
56+
'mapped' => false,
57+
])
58+
;
59+
}
60+
61+
public function configureOptions(OptionsResolver $resolver): void
62+
{
63+
$resolver->setDefaults([]);
64+
}
65+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the SymfonyCasts ResetPasswordBundle package.
5+
* Copyright (c) SymfonyCasts <https://symfonycasts.com/>
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
namespace App\Form;
11+
12+
use Symfony\Component\Form\AbstractType;
13+
use Symfony\Component\Form\Extension\Core\Type\EmailType;
14+
use Symfony\Component\Form\FormBuilderInterface;
15+
use Symfony\Component\OptionsResolver\OptionsResolver;
16+
use Symfony\Component\Validator\Constraints\NotBlank;
17+
18+
class ResetPasswordRequestFormType extends AbstractType
19+
{
20+
public function buildForm(FormBuilderInterface $builder, array $options): void
21+
{
22+
$builder
23+
->add('email', EmailType::class, [
24+
'attr' => ['autocomplete' => 'email'],
25+
'constraints' => [
26+
new NotBlank([
27+
'message' => 'Please enter your email',
28+
]),
29+
],
30+
])
31+
;
32+
}
33+
34+
public function configureOptions(OptionsResolver $resolver): void
35+
{
36+
$resolver->setDefaults([]);
37+
}
38+
}

0 commit comments

Comments
 (0)