Skip to content

Commit a5d8b54

Browse files
jrushlowweaverryan
andauthored
[make:security:form-login] new maker to use built in FormLogin (#1244)
Co-authored-by: Ryan Weaver <[email protected]>
1 parent fd6b328 commit a5d8b54

File tree

14 files changed

+697
-21
lines changed

14 files changed

+697
-21
lines changed

src/Maker/Security/MakeFormLogin.php

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony MakerBundle package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\Security;
13+
14+
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
15+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
16+
use Symfony\Bundle\MakerBundle\ConsoleStyle;
17+
use Symfony\Bundle\MakerBundle\DependencyBuilder;
18+
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
19+
use Symfony\Bundle\MakerBundle\FileManager;
20+
use Symfony\Bundle\MakerBundle\Generator;
21+
use Symfony\Bundle\MakerBundle\InputConfiguration;
22+
use Symfony\Bundle\MakerBundle\Maker\AbstractMaker;
23+
use Symfony\Bundle\MakerBundle\Security\InteractiveSecurityHelper;
24+
use Symfony\Bundle\MakerBundle\Security\SecurityConfigUpdater;
25+
use Symfony\Bundle\MakerBundle\Security\SecurityControllerBuilder;
26+
use Symfony\Bundle\MakerBundle\Str;
27+
use Symfony\Bundle\MakerBundle\Util\ClassSourceManipulator;
28+
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
29+
use Symfony\Bundle\MakerBundle\Util\YamlSourceManipulator;
30+
use Symfony\Bundle\MakerBundle\Validator;
31+
use Symfony\Bundle\SecurityBundle\SecurityBundle;
32+
use Symfony\Bundle\TwigBundle\TwigBundle;
33+
use Symfony\Component\Console\Command\Command;
34+
use Symfony\Component\Console\Input\InputInterface;
35+
use Symfony\Component\HttpFoundation\Response;
36+
use Symfony\Component\Routing\Annotation\Route;
37+
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
38+
use Symfony\Component\Yaml\Yaml;
39+
40+
/**
41+
* Generate Form Login Security using SecurityBundle's Authenticator.
42+
*
43+
* @see https://symfony.com/doc/current/security.html#form-login
44+
*
45+
* @author Jesse Rushlow <[email protected]>
46+
*
47+
* @internal
48+
*/
49+
final class MakeFormLogin extends AbstractMaker
50+
{
51+
private const SECURITY_CONFIG_PATH = 'config/packages/security.yaml';
52+
private YamlSourceManipulator $ysm;
53+
private string $controllerName;
54+
private string $firewallToUpdate;
55+
private string $userNameField;
56+
private bool $willLogout;
57+
58+
public function __construct(
59+
private FileManager $fileManager,
60+
private SecurityConfigUpdater $securityConfigUpdater,
61+
private SecurityControllerBuilder $securityControllerBuilder,
62+
) {
63+
}
64+
65+
public static function getCommandName(): string
66+
{
67+
return 'make:security:form-login';
68+
}
69+
70+
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
71+
{
72+
$command->setHelp(file_get_contents(\dirname(__DIR__, 2).'/Resources/help/security/MakeFormLogin.txt'));
73+
}
74+
75+
public static function getCommandDescription(): string
76+
{
77+
return 'Generate the code needed for the form_login authenticator';
78+
}
79+
80+
public function configureDependencies(DependencyBuilder $dependencies): void
81+
{
82+
$dependencies->addClassDependency(
83+
SecurityBundle::class,
84+
'security'
85+
);
86+
87+
$dependencies->addClassDependency(TwigBundle::class, 'twig');
88+
89+
// needed to update the YAML files
90+
$dependencies->addClassDependency(
91+
Yaml::class,
92+
'yaml'
93+
);
94+
95+
$dependencies->addClassDependency(DoctrineBundle::class, 'orm');
96+
}
97+
98+
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
99+
{
100+
if (!$this->fileManager->fileExists(self::SECURITY_CONFIG_PATH)) {
101+
throw new RuntimeCommandException(sprintf('The file "%s" does not exist. PHP & XML configuration formats are currently not supported.', self::SECURITY_CONFIG_PATH));
102+
}
103+
104+
$this->ysm = new YamlSourceManipulator($this->fileManager->getFileContents(self::SECURITY_CONFIG_PATH));
105+
$securityData = $this->ysm->getData();
106+
107+
if (!isset($securityData['security']['providers']) || !$securityData['security']['providers']) {
108+
throw new RuntimeCommandException('To generate a form login authentication, you must configure at least one entry under "providers" in "security.yaml".');
109+
}
110+
111+
$this->controllerName = $io->ask(
112+
'Choose a name for the controller class (e.g. <fg=yellow>SecurityController</>)',
113+
'SecurityController',
114+
[Validator::class, 'validateClassName']
115+
);
116+
117+
$securityHelper = new InteractiveSecurityHelper();
118+
$this->firewallToUpdate = $securityHelper->guessFirewallName($io, $securityData);
119+
$userClass = $securityHelper->guessUserClass($io, $securityData['security']['providers']);
120+
$this->userNameField = $securityHelper->guessUserNameField($io, $userClass, $securityData['security']['providers']);
121+
$this->willLogout = $io->confirm('Do you want to generate a \'/logout\' URL?');
122+
}
123+
124+
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
125+
{
126+
$useStatements = new UseStatementGenerator([
127+
AbstractController::class,
128+
Response::class,
129+
Route::class,
130+
AuthenticationUtils::class,
131+
]);
132+
133+
$controllerNameDetails = $generator->createClassNameDetails($this->controllerName, 'Controller\\', 'Controller');
134+
$templatePath = strtolower($controllerNameDetails->getRelativeNameWithoutSuffix());
135+
136+
$controllerPath = $generator->generateController(
137+
$controllerNameDetails->getFullName(),
138+
'security/formLogin/LoginController.tpl.php',
139+
[
140+
'use_statements' => $useStatements,
141+
'controller_name' => $controllerNameDetails->getShortName(),
142+
'template_path' => $templatePath,
143+
]
144+
);
145+
146+
if ($this->willLogout) {
147+
$manipulator = new ClassSourceManipulator($generator->getFileContentsForPendingOperation($controllerPath));
148+
149+
$this->securityControllerBuilder->addLogoutMethod($manipulator);
150+
151+
$generator->dumpFile($controllerPath, $manipulator->getSourceCode());
152+
}
153+
154+
$generator->generateTemplate(
155+
sprintf('%s/login.html.twig', $templatePath),
156+
'security/formLogin/login_form.tpl.php',
157+
[
158+
'logout_setup' => $this->willLogout,
159+
'username_label' => Str::asHumanWords($this->userNameField),
160+
'username_is_email' => false !== stripos($this->userNameField, 'email'),
161+
]
162+
);
163+
164+
$securityData = $this->securityConfigUpdater->updateForFormLogin($this->ysm->getContents(), $this->firewallToUpdate, 'app_login', 'app_login');
165+
166+
if ($this->willLogout) {
167+
$securityData = $this->securityConfigUpdater->updateForLogout($securityData, $this->firewallToUpdate);
168+
}
169+
170+
$generator->dumpFile(self::SECURITY_CONFIG_PATH, $securityData);
171+
172+
$generator->writeChanges();
173+
174+
$this->writeSuccessMessage($io);
175+
176+
$io->text([
177+
sprintf('Next: Review and adapt the login template: <info>%s/login.html.twig</info> to suit your needs.', $templatePath),
178+
]);
179+
}
180+
}

src/Resources/config/makers.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,5 +139,12 @@
139139
<service id="maker.maker.make_stimulus_controller" class="Symfony\Bundle\MakerBundle\Maker\MakeStimulusController">
140140
<tag name="maker.command" />
141141
</service>
142+
143+
<service id="maker.maker.make_form_login" class="Symfony\Bundle\MakerBundle\Maker\Security\MakeFormLogin">
144+
<argument type="service" id="maker.file_manager" />
145+
<argument type="service" id="maker.security_config_updater" />
146+
<argument type="service" id="maker.security_controller_builder" />
147+
<tag name="maker.command" />
148+
</service>
142149
</services>
143150
</container>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
The <info>%command.name%</info> command generates a controller and twig template
2+
to allow users to login using the form_login authenticator.
3+
4+
The controller name, and logout ability can be customized by answering the
5+
questions asked when running <info>%command.name%</info>.
6+
7+
This will also update your <info>security.yaml</info> for the new authenticator.
8+
9+
<info>php %command.full_name%</info>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?= "<?php\n" ?>
2+
3+
namespace <?= $namespace; ?>;
4+
5+
<?= $use_statements; ?>
6+
7+
class <?= $controller_name ?> extends AbstractController
8+
{
9+
#[Route(path: '/login', name: 'app_login')]
10+
public function login(AuthenticationUtils $authenticationUtils): Response
11+
{
12+
// get the login error if there is one
13+
$error = $authenticationUtils->getLastAuthenticationError();
14+
15+
// last username entered by the user
16+
$lastUsername = $authenticationUtils->getLastUsername();
17+
18+
return $this->render('<?= $template_path ?>/login.html.twig', [
19+
'last_username' => $lastUsername,
20+
'error' => $error,
21+
]);
22+
}
23+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{% extends 'base.html.twig' %}
2+
3+
{% block title %}Log in!{% endblock %}
4+
5+
{% block body %}
6+
<form method="post">
7+
{% if error %}
8+
<div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
9+
{% endif %}
10+
11+
<?php if ($logout_setup): ?>
12+
{% if app.user %}
13+
<div class="mb-3">
14+
You are logged in as {{ app.user.userIdentifier }}, <a href="{{ path('app_logout') }}">Logout</a>
15+
</div>
16+
{% endif %}
17+
18+
<?php endif; ?>
19+
<h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>
20+
<label for="username"><?= $username_label; ?></label>
21+
<input type="<?= $username_is_email ? 'email' : 'text'; ?>" value="{{ last_username }}" name="_username" id="username" class="form-control" autocomplete="<?= $username_is_email ? 'email' : 'username'; ?>" required autofocus>
22+
<label for="password">Password</label>
23+
<input type="password" name="_password" id="password" class="form-control" autocomplete="current-password" required>
24+
25+
<input type="hidden" name="_csrf_token"
26+
value="{{ csrf_token('authenticate') }}"
27+
>
28+
29+
{#
30+
Uncomment this section and add a remember_me option below your firewall to activate remember me functionality.
31+
See https://symfony.com/doc/current/security/remember_me.html
32+
33+
<div class="checkbox mb-3">
34+
<label>
35+
<input type="checkbox" name="_remember_me"> Remember me
36+
</label>
37+
</div>
38+
#}
39+
40+
<button class="btn btn-lg btn-primary" type="submit">
41+
Sign in
42+
</button>
43+
</form>
44+
{% endblock %}

src/Security/SecurityConfigUpdater.php

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,7 @@ public function __construct(
3030
) {
3131
}
3232

33-
/**
34-
* Updates security.yaml contents based on a new User class.
35-
*/
36-
public function updateForUserClass(string $yamlSource, UserClassConfiguration $userConfig, string $userClass): string
33+
public function updateForFormLogin(string $yamlSource, string $firewallToUpdate, string $loginPath, string $checkPath): string
3734
{
3835
$this->manipulator = new YamlSourceManipulator($yamlSource);
3936

@@ -43,6 +40,24 @@ public function updateForUserClass(string $yamlSource, UserClassConfiguration $u
4340

4441
$this->normalizeSecurityYamlFile();
4542

43+
$newData = $this->manipulator->getData();
44+
45+
$newData['security']['firewalls'][$firewallToUpdate]['form_login']['login_path'] = $loginPath;
46+
$newData['security']['firewalls'][$firewallToUpdate]['form_login']['check_path'] = $checkPath;
47+
$newData['security']['firewalls'][$firewallToUpdate]['form_login']['enable_csrf'] = true;
48+
49+
$this->manipulator->setData($newData);
50+
51+
return $this->manipulator->getContents();
52+
}
53+
54+
/**
55+
* Updates security.yaml contents based on a new User class.
56+
*/
57+
public function updateForUserClass(string $yamlSource, UserClassConfiguration $userConfig, string $userClass): string
58+
{
59+
$this->createYamlSourceManipulator($yamlSource);
60+
4661
$this->updateProviders($userConfig, $userClass);
4762

4863
if ($userConfig->hasPassword()) {
@@ -57,13 +72,7 @@ public function updateForUserClass(string $yamlSource, UserClassConfiguration $u
5772

5873
public function updateForAuthenticator(string $yamlSource, string $firewallName, $chosenEntryPoint, string $authenticatorClass, bool $logoutSetup): string
5974
{
60-
$this->manipulator = new YamlSourceManipulator($yamlSource);
61-
62-
if (null !== $this->ysmLogger) {
63-
$this->manipulator->setLogger($this->ysmLogger);
64-
}
65-
66-
$this->normalizeSecurityYamlFile();
75+
$this->createYamlSourceManipulator($yamlSource);
6776

6877
$newData = $this->manipulator->getData();
6978

@@ -102,23 +111,55 @@ public function updateForAuthenticator(string $yamlSource, string $firewallName,
102111
$firewall['entry_point'] = $authenticatorClass;
103112
}
104113

114+
$newData['security']['firewalls'][$firewallName] = $firewall;
115+
105116
if (!isset($firewall['logout']) && $logoutSetup) {
106-
$firewall['logout'] = ['path' => 'app_logout'];
107-
$firewall['logout'][] = $this->manipulator->createCommentLine(
108-
' where to redirect after logout'
109-
);
110-
$firewall['logout'][] = $this->manipulator->createCommentLine(
111-
' target: app_any_route'
112-
);
113-
}
117+
$this->configureLogout($newData, $firewallName);
114118

115-
$newData['security']['firewalls'][$firewallName] = $firewall;
119+
return $this->manipulator->getContents();
120+
}
116121

117122
$this->manipulator->setData($newData);
118123

119124
return $this->manipulator->getContents();
120125
}
121126

127+
public function updateForLogout(string $yamlSource, string $firewallName): string
128+
{
129+
$this->createYamlSourceManipulator($yamlSource);
130+
131+
$this->configureLogout($this->manipulator->getData(), $firewallName);
132+
133+
return $this->manipulator->getContents();
134+
}
135+
136+
/**
137+
* @legacy This can be removed once we deprecate/remove `make:auth`
138+
*/
139+
private function configureLogout(array $securityData, string $firewallName): void
140+
{
141+
$securityData['security']['firewalls'][$firewallName]['logout'] = ['path' => 'app_logout'];
142+
$securityData['security']['firewalls'][$firewallName]['logout'][] = $this->manipulator->createCommentLine(
143+
' where to redirect after logout'
144+
);
145+
$securityData['security']['firewalls'][$firewallName]['logout'][] = $this->manipulator->createCommentLine(
146+
' target: app_any_route'
147+
);
148+
149+
$this->manipulator->setData($securityData);
150+
}
151+
152+
private function createYamlSourceManipulator(string $yamlSource): void
153+
{
154+
$this->manipulator = new YamlSourceManipulator($yamlSource);
155+
156+
if (null !== $this->ysmLogger) {
157+
$this->manipulator->setLogger($this->ysmLogger);
158+
}
159+
160+
$this->normalizeSecurityYamlFile();
161+
}
162+
122163
private function normalizeSecurityYamlFile(): void
123164
{
124165
if (!isset($this->manipulator->getData()['security'])) {

0 commit comments

Comments
 (0)