Skip to content

Commit ccb68bf

Browse files
committed
feature #359 Added a "Forgotten password" maker (romaricdrigon)
This PR was merged into the 1.0-dev branch. Discussion ---------- Added a "Forgotten password" maker Hello, Having a password reset feature ("forgotten password") is a common feature of a web application. Maker bundle already has a User registration maker, it makes sense imo to add this too. Otherwise users are left either to code it manually (long), or to ditch Maker commands for FOSUserBundle. I started implementing such a maker. It is heavily inspired from other Registration maker regarding style, and from FOSUserBundle process. Right now this is a work in progress, but I wanted to propose the idea & to discuss code style. To do: - [x] first working POC - [x] add (minimal) view - [x] add documentation describing the command - [x] add tests - I got those are functional tests over the generated code? - [x] squash & rebase everything Points to be discussed: - is the controller right now looking good? I'm trying to have something minimalistic doing the job right, and easily modifiable. Not having too many services/helpers. - ~~should the e-mail body be from a template, or does an heredoc looks sufficient to you?~~ - which options would make sense for the command? for instance, redirecting or authenticating the user after having changed his password? - how to handle missing password setter/User class without an e-mail field? Display a warning in console? Screenshots: ![image](https://user-images.githubusercontent.com/919405/52117115-37911580-2613-11e9-8680-ba0a4a52901b.png) ![image](https://user-images.githubusercontent.com/919405/52117157-51caf380-2613-11e9-809b-b437ca24cecd.png) ![image](https://user-images.githubusercontent.com/919405/52117173-5ee7e280-2613-11e9-8d17-130d38a24750.png) ![image](https://user-images.githubusercontent.com/919405/52117218-7921c080-2613-11e9-9828-6e14b4424d53.png) Thank you for this great bundle :) Commits ------- 542591c Added a make:forgotten-password maker
2 parents c38ce58 + 542591c commit ccb68bf

19 files changed

+1290
-2
lines changed

src/Maker/MakeForgottenPassword.php

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony MakerBundle package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bundle\MakerBundle\Maker;
13+
14+
use Symfony\Bundle\MakerBundle\ConsoleStyle;
15+
use Symfony\Bundle\MakerBundle\DependencyBuilder;
16+
use Symfony\Bundle\MakerBundle\Doctrine\ORMDependencyBuilder;
17+
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
18+
use Symfony\Bundle\MakerBundle\Generator;
19+
use Symfony\Bundle\MakerBundle\InputConfiguration;
20+
use Symfony\Bundle\MakerBundle\Security\InteractiveSecurityHelper;
21+
use Symfony\Bundle\MakerBundle\Util\YamlSourceManipulator;
22+
use Symfony\Bundle\SecurityBundle\SecurityBundle;
23+
use Symfony\Bundle\TwigBundle\TwigBundle;
24+
use Symfony\Component\Console\Command\Command;
25+
use Symfony\Component\Console\Input\InputInterface;
26+
use Symfony\Component\Form\AbstractType;
27+
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
28+
use Symfony\Component\Form\Extension\Core\Type\EmailType;
29+
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
30+
use Symfony\Component\Routing\RouterInterface;
31+
use Symfony\Bundle\MakerBundle\FileManager;
32+
use Symfony\Bundle\MakerBundle\Renderer\FormTypeRenderer;
33+
use Symfony\Component\Validator\Validation;
34+
use Symfony\Bundle\SwiftmailerBundle\SwiftmailerBundle;
35+
36+
/**
37+
* @author Romaric Drigon <romaric.drigon@gmail.com>
38+
*
39+
* @internal
40+
*/
41+
final class MakeForgottenPassword extends AbstractMaker
42+
{
43+
private $fileManager;
44+
45+
private $formTypeRenderer;
46+
47+
private $router;
48+
49+
public function __construct(FileManager $fileManager, FormTypeRenderer $formTypeRenderer, RouterInterface $router)
50+
{
51+
$this->fileManager = $fileManager;
52+
$this->formTypeRenderer = $formTypeRenderer;
53+
$this->router = $router;
54+
}
55+
56+
public static function getCommandName(): string
57+
{
58+
return 'make:forgotten-password';
59+
}
60+
61+
public function configureCommand(Command $command, InputConfiguration $inputConfig)
62+
{
63+
$command
64+
->setDescription('Creates a "forgotten password" mechanism')
65+
->setHelp(file_get_contents(__DIR__.'/../Resources/help/MakeForgottenPassword.txt'))
66+
;
67+
}
68+
69+
public function interact(InputInterface $input, ConsoleStyle $io, Command $command)
70+
{
71+
// initialize arguments & commands that are internal (i.e. meant only to be asked)
72+
$command
73+
->addArgument('user-class')
74+
->addArgument('email-field')
75+
->addArgument('email-getter')
76+
->addArgument('password-setter')
77+
;
78+
79+
$interactiveSecurityHelper = new InteractiveSecurityHelper();
80+
81+
if (!$this->fileManager->fileExists($path = 'config/packages/security.yaml')) {
82+
throw new RuntimeCommandException('The file "config/packages/security.yaml" does not exist. This command needs that file to accurately build the forgotten password form.');
83+
}
84+
85+
$manipulator = new YamlSourceManipulator($this->fileManager->getFileContents($path));
86+
$securityData = $manipulator->getData();
87+
$providersData = $securityData['security']['providers'] ?? [];
88+
89+
$input->setArgument(
90+
'user-class',
91+
$userClass = $interactiveSecurityHelper->guessUserClass(
92+
$io,
93+
$providersData,
94+
'Enter the User class that should be used with the "forgotten password" feature (e.g. <fg=yellow>App\\Entity\\User</>)'
95+
)
96+
);
97+
$io->text(sprintf('Implementing forgotten password for <info>%s</info>', $userClass));
98+
99+
$input->setArgument(
100+
'email-field',
101+
$interactiveSecurityHelper->guessEmailField($io, $userClass)
102+
);
103+
$input->setArgument(
104+
'email-getter',
105+
$interactiveSecurityHelper->guessEmailGetter($io, $userClass)
106+
);
107+
$input->setArgument(
108+
'password-setter',
109+
$interactiveSecurityHelper->guessPasswordSetter($io, $userClass)
110+
);
111+
}
112+
113+
public function configureDependencies(DependencyBuilder $dependencies)
114+
{
115+
// This recipe depends upon Doctrine ORM, to save the token and update the user
116+
ORMDependencyBuilder::buildDependencies($dependencies);
117+
118+
$dependencies->addClassDependency(
119+
AbstractType::class,
120+
'form'
121+
);
122+
$dependencies->addClassDependency(
123+
Validation::class,
124+
'validator'
125+
);
126+
$dependencies->addClassDependency(
127+
TwigBundle::class,
128+
'twig-bundle'
129+
);
130+
$dependencies->addClassDependency(
131+
SecurityBundle::class,
132+
'security'
133+
);
134+
$dependencies->addClassDependency(
135+
SwiftmailerBundle::class,
136+
'mail'
137+
);
138+
}
139+
140+
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator)
141+
{
142+
$userClass = $input->getArgument('user-class');
143+
$userClassNameDetails = $generator->createClassNameDetails(
144+
'\\'.$userClass,
145+
'Entity\\'
146+
);
147+
$tokenClassNameDetails = $generator->createClassNameDetails(
148+
'PasswordResetToken',
149+
'Entity\\'
150+
);
151+
$repositoryClassNameDetails = $generator->createClassNameDetails(
152+
'PasswordResetTokenRepository',
153+
'Repository\\'
154+
);
155+
156+
// 1) Create a new "PasswordResetToken" entity and its repository
157+
$generator->generateClass(
158+
$tokenClassNameDetails->getFullName(),
159+
'forgottenPassword/PasswordResetToken.tpl.php',
160+
[
161+
'repository_class_name' => $repositoryClassNameDetails->getFullName(),
162+
'user_class_name' => $userClassNameDetails->getShortName(),
163+
'user_full_class_name' => $userClassNameDetails->getFullName(),
164+
]
165+
);
166+
$generator->generateClass(
167+
$repositoryClassNameDetails->getFullName(),
168+
'forgottenPassword/PasswordResetTokenRepository.tpl.php',
169+
[
170+
'token_class_name' => $tokenClassNameDetails->getShortName(),
171+
'token_full_class_name' => $tokenClassNameDetails->getFullName(),
172+
'user_class_name' => $userClassNameDetails->getShortName(),
173+
'user_full_class_name' => $userClassNameDetails->getFullName(),
174+
]
175+
);
176+
177+
// 2) Generate the "request" (email) form class
178+
$emailField = $input->getArgument('email-field');
179+
$requestFormClassDetails = $this->generateRequestFormClass(
180+
$generator,
181+
$emailField
182+
);
183+
184+
// 3) Generate the "new password" form class
185+
$resettingFormClassDetails = $this->generateResettingFormClass($generator);
186+
187+
// 4) Generate the controller
188+
$controllerClassNameDetails = $generator->createClassNameDetails(
189+
'ForgottenPasswordController',
190+
'Controller\\'
191+
);
192+
193+
$generator->generateController(
194+
$controllerClassNameDetails->getFullName(),
195+
'forgottenPassword/ForgottenPasswordController.tpl.php',
196+
[
197+
'request_form_class_name' => $requestFormClassDetails->getShortName(),
198+
'request_form_full_class_name' => $requestFormClassDetails->getFullName(),
199+
'resetting_form_class_name' => $resettingFormClassDetails->getShortName(),
200+
'resetting_form_full_class_name' => $resettingFormClassDetails->getFullName(),
201+
'user_class_name' => $userClassNameDetails->getShortName(),
202+
'user_full_class_name' => $userClassNameDetails->getFullName(),
203+
'email_field' => $emailField,
204+
'email_getter' => $input->getArgument('email-getter'),
205+
'password_setter' => $input->getArgument('password-setter'),
206+
'login_route' => 'app_login',
207+
'token_class_name' => $tokenClassNameDetails->getShortName(),
208+
'token_full_class_name' => $tokenClassNameDetails->getFullName(),
209+
]
210+
);
211+
212+
// 5) Generate the "request" template
213+
$generator->generateFile(
214+
'templates/forgotten_password/request.html.twig',
215+
'forgottenPassword/twig_request.tpl.php',
216+
[
217+
'email_field' => $emailField,
218+
]
219+
);
220+
221+
// 6) Generate the reset e-mail template
222+
$generator->generateFile(
223+
'templates/forgotten_password/email.txt.twig',
224+
'forgottenPassword/twig_email.tpl.php',
225+
[]
226+
);
227+
228+
// 7) Generate the "checkEmail" template
229+
$generator->generateFile(
230+
'templates/forgotten_password/check_email.html.twig',
231+
'forgottenPassword/twig_check_email.tpl.php',
232+
[]
233+
);
234+
235+
// 8) Generate the "reset" template
236+
$generator->generateFile(
237+
'templates/forgotten_password/reset.html.twig',
238+
'forgottenPassword/twig_reset.tpl.php',
239+
[]
240+
);
241+
242+
$generator->writeChanges();
243+
$this->writeSuccessMessage($io);
244+
245+
$io->text('Done! A new entity was added: PasswordResetToken. You should now generate a migration (make:migration) and run it to update your database.');
246+
$io->text('Next: Please review ForgottenPasswordController. Then you can add a link to "app_forgotten_password_request" path anywhere you like, typically below your login form!');
247+
}
248+
249+
private function generateRequestFormClass(Generator $generator, string $emailField)
250+
{
251+
$formClassDetails = $generator->createClassNameDetails(
252+
'PasswordRequestFormType',
253+
'Form\\'
254+
);
255+
256+
$formFields = [
257+
$emailField => [
258+
'type' => EmailType::class,
259+
'options_code' => <<<EOF
260+
'constraints' => [
261+
new NotBlank([
262+
'message' => 'Please enter your $emailField',
263+
]),
264+
],
265+
EOF
266+
],
267+
];
268+
269+
$this->formTypeRenderer->render(
270+
$formClassDetails,
271+
$formFields,
272+
null,
273+
[
274+
'Symfony\Component\Validator\Constraints\NotBlank',
275+
]
276+
);
277+
278+
return $formClassDetails;
279+
}
280+
281+
private function generateResettingFormClass(Generator $generator)
282+
{
283+
$formClassDetails = $generator->createClassNameDetails(
284+
'PasswordResettingFormType',
285+
'Form\\'
286+
);
287+
288+
$formFields = [
289+
'plainPassword' => [
290+
'type' => RepeatedType::class,
291+
'options_code' => <<<EOF
292+
'type' => PasswordType::class,
293+
'first_options' => [
294+
'constraints' => [
295+
new NotBlank([
296+
'message' => 'Please enter a password',
297+
]),
298+
new Length([
299+
'min' => 6,
300+
'minMessage' => 'Your password should be at least {{ limit }} characters',
301+
// max length allowed by Symfony for security reasons
302+
'max' => 4096,
303+
]),
304+
],
305+
'label' => 'New password',
306+
],
307+
'second_options' => [
308+
'label' => 'Repeat Password',
309+
],
310+
'invalid_message' => 'The password fields must match.',
311+
// Instead of being set onto the object directly,
312+
// this is read and encoded in the controller
313+
'mapped' => false,
314+
EOF
315+
],
316+
];
317+
318+
$this->formTypeRenderer->render(
319+
$formClassDetails,
320+
$formFields,
321+
null,
322+
[
323+
'Symfony\Component\Validator\Constraints\Length',
324+
'Symfony\Component\Validator\Constraints\NotBlank',
325+
],
326+
[
327+
PasswordType::class,
328+
]
329+
);
330+
331+
return $formClassDetails;
332+
}
333+
}

src/Renderer/FormTypeRenderer.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public function __construct(Generator $generator)
2727
$this->generator = $generator;
2828
}
2929

30-
public function render(ClassNameDetails $formClassDetails, array $formFields, ClassNameDetails $boundClassDetails = null, array $constraintClasses = [])
30+
public function render(ClassNameDetails $formClassDetails, array $formFields, ClassNameDetails $boundClassDetails = null, array $constraintClasses = [], array $extraUseClasses = [])
3131
{
3232
$fieldTypeUseStatements = [];
3333
$fields = [];
@@ -49,7 +49,7 @@ public function render(ClassNameDetails $formClassDetails, array $formFields, Cl
4949
'bounded_full_class_name' => $boundClassDetails ? $boundClassDetails->getFullName() : null,
5050
'bounded_class_name' => $boundClassDetails ? $boundClassDetails->getShortName() : null,
5151
'form_fields' => $fields,
52-
'field_type_use_statements' => $fieldTypeUseStatements,
52+
'field_type_use_statements' => array_merge($fieldTypeUseStatements, $extraUseClasses),
5353
'constraint_use_statements' => $constraintClasses,
5454
]
5555
);

src/Resources/config/makers.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@
4242
<tag name="maker.command" />
4343
</service>
4444

45+
<service id="maker.maker.make_forgotten_password" class="Symfony\Bundle\MakerBundle\Maker\MakeForgottenPassword">
46+
<argument type="service" id="maker.file_manager" />
47+
<argument type="service" id="maker.renderer.form_type_renderer" />
48+
<argument type="service" id="router" />
49+
<tag name="maker.command" />
50+
</service>
51+
4552
<service id="maker.maker.make_form" class="Symfony\Bundle\MakerBundle\Maker\MakeForm">
4653
<argument type="service" id="maker.doctrine_helper" />
4754
<argument type="service" id="maker.renderer.form_type_renderer" />
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
The <info>%command.name%</info> command generates a complete reset password process, including forms, controllers & templates.
2+
3+
<info>php %command.full_name%</info>
4+
5+
The command will ask for several pieces of information to build your process.

0 commit comments

Comments
 (0)