Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[make:security:form-login] new maker to use built in FormLogin #1244

Merged
merged 23 commits into from
Nov 30, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions src/Maker/Security/MakeFormLogin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
<?php

/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Bundle\MakerBundle\Maker\Security;

use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
use Symfony\Bundle\MakerBundle\FileManager;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Maker\AbstractMaker;
use Symfony\Bundle\MakerBundle\Security\InteractiveSecurityHelper;
use Symfony\Bundle\MakerBundle\Security\SecurityConfigUpdater;
use Symfony\Bundle\MakerBundle\Security\SecurityControllerBuilder;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Bundle\MakerBundle\Util\ClassSourceManipulator;
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
use Symfony\Bundle\MakerBundle\Util\YamlSourceManipulator;
use Symfony\Bundle\MakerBundle\Validator;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Component\Yaml\Yaml;

/**
* Generate Form Login Security using SecurityBundle's Authenticator.
*
* @see https://symfony.com/doc/current/security.html#form-login
*
* @author Jesse Rushlow <jr@rushlow.dev>
*
* @internal
*/
jrushlow marked this conversation as resolved.
Show resolved Hide resolved
final class MakeFormLogin extends AbstractMaker
{
private const SECURITY_CONFIG_PATH = 'config/packages/security.yaml';
private YamlSourceManipulator $ysm;
private string $controllerName;
private string $firewallToUpdate;
private string $userNameField;
private bool $willLogout;

public function __construct(
private FileManager $fileManager,
private SecurityConfigUpdater $securityConfigUpdater,
private SecurityControllerBuilder $securityControllerBuilder,
) {
}

public static function getCommandName(): string
{
return 'make:security:form-login';
}

public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command->setHelp(file_get_contents(\dirname(__DIR__, 2).'/Resources/help/security/MakeFormLogin.txt'));
}

public static function getCommandDescription(): string
{
return 'Generate the code needed for the form_login authenticator';
}

public function configureDependencies(DependencyBuilder $dependencies): void
{
$dependencies->addClassDependency(
SecurityBundle::class,
'security'
);

$dependencies->addClassDependency(TwigBundle::class, 'twig');

// needed to update the YAML files
$dependencies->addClassDependency(
Yaml::class,
'yaml'
);

$dependencies->addClassDependency(DoctrineBundle::class, 'orm');
}

public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
if (!$this->fileManager->fileExists(self::SECURITY_CONFIG_PATH)) {
throw new RuntimeCommandException(sprintf('The file "%s" does not exist. PHP & XML configuration formats are currently not supported.', self::SECURITY_CONFIG_PATH));
}

$this->ysm = new YamlSourceManipulator($this->fileManager->getFileContents(self::SECURITY_CONFIG_PATH));
$securityData = $this->ysm->getData();

if (!isset($securityData['security']['providers']) || !$securityData['security']['providers']) {
throw new RuntimeCommandException('To generate a form login authentication, you must configure at least one entry under "providers" in "security.yaml".');
}

$this->controllerName = $io->ask(
'Choose a name for the controller class (e.g. <fg=yellow>SecurityController</>)',
'SecurityController',
[Validator::class, 'validateClassName']
);

$securityHelper = new InteractiveSecurityHelper();
$this->firewallToUpdate = $securityHelper->guessFirewallName($io, $securityData);
$userClass = $securityHelper->guessUserClass($io, $securityData['security']['providers']);
$this->userNameField = $securityHelper->guessUserNameField($io, $userClass, $securityData['security']['providers']);
$this->willLogout = $io->confirm('Do you want to generate a \'/logout\' URL?');
}

public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$useStatements = new UseStatementGenerator([
AbstractController::class,
Response::class,
Route::class,
AuthenticationUtils::class,
]);

$controllerNameDetails = $generator->createClassNameDetails($this->controllerName, 'Controller\\', 'Controller');
$templatePath = strtolower($controllerNameDetails->getRelativeNameWithoutSuffix());

$controllerPath = $generator->generateController(
$controllerNameDetails->getFullName(),
'security/formLogin/LoginController.tpl.php',
[
'use_statements' => $useStatements,
'controller_name' => $controllerNameDetails->getShortName(),
'template_path' => $templatePath,
]
);

if ($this->willLogout) {
$manipulator = new ClassSourceManipulator($generator->getFileContentsForPendingOperation($controllerPath));

$this->securityControllerBuilder->addLogoutMethod($manipulator);

$generator->dumpFile($controllerPath, $manipulator->getSourceCode());
}

$generator->generateTemplate(
sprintf('%s/login.html.twig', $templatePath),
'security/formLogin/login_form.tpl.php',
[
'logout_setup' => $this->willLogout,
'username_label' => Str::asHumanWords($this->userNameField),
'username_is_email' => false !== stripos($this->userNameField, 'email'),
]
);

$securityData = $this->securityConfigUpdater->updateForFormLogin($this->ysm->getContents(), $this->firewallToUpdate, 'app_login', 'app_login');

if ($this->willLogout) {
$securityData = $this->securityConfigUpdater->updateForLogout($securityData, $this->firewallToUpdate);
}

$generator->dumpFile(self::SECURITY_CONFIG_PATH, $securityData);

$generator->writeChanges();

$this->writeSuccessMessage($io);

$io->text([
sprintf('Next: Review and adapt the login template: <info>%s/login.html.twig</info> to suit your needs.', $templatePath),
]);
}
jrushlow marked this conversation as resolved.
Show resolved Hide resolved
}
7 changes: 7 additions & 0 deletions src/Resources/config/makers.xml
Original file line number Diff line number Diff line change
Expand Up @@ -139,5 +139,12 @@
<service id="maker.maker.make_stimulus_controller" class="Symfony\Bundle\MakerBundle\Maker\MakeStimulusController">
<tag name="maker.command" />
</service>

<service id="maker.maker.make_form_login" class="Symfony\Bundle\MakerBundle\Maker\Security\MakeFormLogin">
<argument type="service" id="maker.file_manager" />
<argument type="service" id="maker.security_config_updater" />
<argument type="service" id="maker.security_controller_builder" />
<tag name="maker.command" />
</service>
</services>
</container>
9 changes: 9 additions & 0 deletions src/Resources/help/security/MakeFormLogin.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
The <info>%command.name%</info> command generates a controller and twig template
to allow users to login using the form_login authenticator.

The controller name, and logout ability can be customized by answering the
questions asked when running <info>%command.name%</info>.

This will also update your <info>security.yaml</info> for the new authenticator.

<info>php %command.full_name%</info>
23 changes: 23 additions & 0 deletions src/Resources/skeleton/security/formLogin/LoginController.tpl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?= "<?php\n" ?>

namespace <?= $namespace; ?>;

<?= $use_statements; ?>

class <?= $controller_name ?> extends AbstractController
{
#[Route(path: '/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
// get the login error if there is one
$error = $authenticationUtils->getLastAuthenticationError();

// last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername();

return $this->render('<?= $template_path ?>/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
]);
}
}
44 changes: 44 additions & 0 deletions src/Resources/skeleton/security/formLogin/login_form.tpl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{% extends 'base.html.twig' %}

{% block title %}Log in!{% endblock %}

{% block body %}
<form method="post">
{% if error %}
<div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
{% endif %}

<?php if ($logout_setup): ?>
{% if app.user %}
<div class="mb-3">
You are logged in as {{ app.user.userIdentifier }}, <a href="{{ path('app_logout') }}">Logout</a>
</div>
{% endif %}

<?php endif; ?>
<h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>
<label for="username"><?= $username_label; ?></label>
<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>
<label for="password">Password</label>
<input type="password" name="_password" id="password" class="form-control" autocomplete="current-password" required>

<input type="hidden" name="_csrf_token"
value="{{ csrf_token('authenticate') }}"
>

{#
Uncomment this section and add a remember_me option below your firewall to activate remember me functionality.
See https://symfony.com/doc/current/security/remember_me.html

<div class="checkbox mb-3">
<label>
<input type="checkbox" name="_remember_me"> Remember me
</label>
</div>
#}

<button class="btn btn-lg btn-primary" type="submit">
Sign in
</button>
</form>
{% endblock %}
81 changes: 61 additions & 20 deletions src/Security/SecurityConfigUpdater.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,7 @@ public function __construct(
) {
}

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

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

$this->normalizeSecurityYamlFile();

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

$newData['security']['firewalls'][$firewallToUpdate]['form_login']['login_path'] = $loginPath;
$newData['security']['firewalls'][$firewallToUpdate]['form_login']['check_path'] = $checkPath;
$newData['security']['firewalls'][$firewallToUpdate]['form_login']['enable_csrf'] = true;

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

return $this->manipulator->getContents();
}

/**
* Updates security.yaml contents based on a new User class.
*/
public function updateForUserClass(string $yamlSource, UserClassConfiguration $userConfig, string $userClass): string
{
$this->createYamlSourceManipulator($yamlSource);

$this->updateProviders($userConfig, $userClass);

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

public function updateForAuthenticator(string $yamlSource, string $firewallName, $chosenEntryPoint, string $authenticatorClass, bool $logoutSetup): string
{
$this->manipulator = new YamlSourceManipulator($yamlSource);

if (null !== $this->ysmLogger) {
$this->manipulator->setLogger($this->ysmLogger);
}

$this->normalizeSecurityYamlFile();
$this->createYamlSourceManipulator($yamlSource);

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

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

$newData['security']['firewalls'][$firewallName] = $firewall;

if (!isset($firewall['logout']) && $logoutSetup) {
$firewall['logout'] = ['path' => 'app_logout'];
$firewall['logout'][] = $this->manipulator->createCommentLine(
' where to redirect after logout'
);
$firewall['logout'][] = $this->manipulator->createCommentLine(
' target: app_any_route'
);
}
$this->configureLogout($newData, $firewallName);

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

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

return $this->manipulator->getContents();
}

public function updateForLogout(string $yamlSource, string $firewallName): string
{
$this->createYamlSourceManipulator($yamlSource);

$this->configureLogout($this->manipulator->getData(), $firewallName);

return $this->manipulator->getContents();
}

/**
* @legacy This can be removed once we deprecate/remove `make:auth`
*/
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's not technically true. It's just that once we remove make:auth, this won't NEED to be in a private method anymore. I'd remove this.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The private method shouldn't be needed anymore here. We have a public updateForLogout() that this logic will be moved to.

private function configureLogout(array $securityData, string $firewallName): void
{
$securityData['security']['firewalls'][$firewallName]['logout'] = ['path' => 'app_logout'];
$securityData['security']['firewalls'][$firewallName]['logout'][] = $this->manipulator->createCommentLine(
' where to redirect after logout'
);
$securityData['security']['firewalls'][$firewallName]['logout'][] = $this->manipulator->createCommentLine(
' target: app_any_route'
);
jrushlow marked this conversation as resolved.
Show resolved Hide resolved

$this->manipulator->setData($securityData);
}

private function createYamlSourceManipulator(string $yamlSource): void
{
$this->manipulator = new YamlSourceManipulator($yamlSource);

if (null !== $this->ysmLogger) {
$this->manipulator->setLogger($this->ysmLogger);
}

$this->normalizeSecurityYamlFile();
}

private function normalizeSecurityYamlFile(): void
{
if (!isset($this->manipulator->getData()['security'])) {
Expand Down
Loading