diff --git a/src/Doctrine/DoctrineHelper.php b/src/Doctrine/DoctrineHelper.php index 125e38ef2..70101ac81 100644 --- a/src/Doctrine/DoctrineHelper.php +++ b/src/Doctrine/DoctrineHelper.php @@ -177,4 +177,13 @@ public function createDoctrineDetails(string $entityClassName) return null; } + + public function isClassAMappedEntity(string $className): bool + { + if (!$this->isDoctrineInstalled()) { + return false; + } + + return (bool) $this->getMetadata($className); + } } diff --git a/src/Generator.php b/src/Generator.php index 9f73c7b98..bf2ab0d3c 100644 --- a/src/Generator.php +++ b/src/Generator.php @@ -11,6 +11,7 @@ namespace Symfony\Bundle\MakerBundle; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException; use Symfony\Bundle\MakerBundle\Util\ClassNameDetails; @@ -84,6 +85,22 @@ public function dumpFile(string $targetPath, string $contents) ]; } + public function getFileContentsForPendingOperation(string $targetPath): string + { + if (!isset($this->pendingOperations[$targetPath])) { + throw new RuntimeCommandException(sprintf('File "%s" is not in the Generator\'s pending operations', $targetPath)); + } + + $templatePath = $this->pendingOperations[$targetPath]['template']; + $parameters = $this->pendingOperations[$targetPath]['variables']; + + $templateParameters = array_merge($parameters, [ + 'relative_path' => $this->fileManager->relativizePath($targetPath), + ]); + + return $this->fileManager->parseTemplate($templatePath, $templateParameters); + } + /** * Creates a helper object to get data about a class name. * @@ -178,15 +195,10 @@ public function writeChanges() continue; } - $templatePath = $templateData['template']; - $parameters = $templateData['variables']; - - $templateParameters = array_merge($parameters, [ - 'relative_path' => $this->fileManager->relativizePath($targetPath), - ]); - - $fileContents = $this->fileManager->parseTemplate($templatePath, $templateParameters); - $this->fileManager->dumpFile($targetPath, $fileContents); + $this->fileManager->dumpFile( + $targetPath, + $this->getFileContentsForPendingOperation($targetPath, $templateData) + ); } $this->pendingOperations = []; @@ -196,4 +208,16 @@ public function getRootNamespace(): string { return $this->namespacePrefix; } + + public function generateController(string $controllerClassName, string $controllerTemplatePath, array $parameters = []): string + { + return $this->generateClass( + $controllerClassName, + $controllerTemplatePath, + $parameters + + [ + 'parent_class_name' => \method_exists(AbstractController::class, 'getParameter') ? 'AbstractController' : 'Controller', + ] + ); + } } diff --git a/src/Maker/AbstractMaker.php b/src/Maker/AbstractMaker.php index 7df2efc30..3d5a84841 100644 --- a/src/Maker/AbstractMaker.php +++ b/src/Maker/AbstractMaker.php @@ -12,6 +12,7 @@ namespace Symfony\Bundle\MakerBundle\Maker; use Symfony\Bundle\MakerBundle\ConsoleStyle; +use Symfony\Bundle\MakerBundle\DependencyBuilder; use Symfony\Bundle\MakerBundle\MakerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -33,4 +34,18 @@ protected function writeSuccessMessage(ConsoleStyle $io) $io->writeln(' '); $io->newLine(); } + + protected function addDependencies(array $dependencies, string $message = null): string + { + $dependencyBuilder = new DependencyBuilder(); + + foreach ($dependencies as $class => $name) { + $dependencyBuilder->addClassDependency($class, $name); + } + + return $dependencyBuilder->getMissingPackagesMessage( + $this->getCommandName(), + $message + ); + } } diff --git a/src/Maker/MakeAuthenticator.php b/src/Maker/MakeAuthenticator.php index 55169666b..d55d4ddf2 100644 --- a/src/Maker/MakeAuthenticator.php +++ b/src/Maker/MakeAuthenticator.php @@ -13,18 +13,28 @@ use Symfony\Bundle\MakerBundle\ConsoleStyle; use Symfony\Bundle\MakerBundle\DependencyBuilder; +use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper; +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\Security\InteractiveSecurityHelper; use Symfony\Bundle\MakerBundle\Security\SecurityConfigUpdater; +use Symfony\Bundle\MakerBundle\Security\SecurityControllerBuilder; +use Symfony\Bundle\MakerBundle\Util\ClassSourceManipulator; use Symfony\Bundle\MakerBundle\Util\YamlManipulationFailedException; 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\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Question\Question; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\Component\Yaml\Yaml; +use Symfony\Component\Form\Form; /** * @author Ryan Weaver @@ -33,17 +43,23 @@ */ final class MakeAuthenticator extends AbstractMaker { + const AUTH_TYPE_EMPTY_AUTHENTICATOR = 'empty-authenticator'; + const AUTH_TYPE_FORM_LOGIN = 'form-login'; + private $fileManager; private $configUpdater; private $generator; - public function __construct(FileManager $fileManager, SecurityConfigUpdater $configUpdater, Generator $generator) + private $doctrineHelper; + + public function __construct(FileManager $fileManager, SecurityConfigUpdater $configUpdater, Generator $generator, DoctrineHelper $doctrineHelper) { $this->fileManager = $fileManager; $this->configUpdater = $configUpdater; $this->generator = $generator; + $this->doctrineHelper = $doctrineHelper; } public static function getCommandName(): string @@ -55,88 +71,278 @@ public function configureCommand(Command $command, InputConfiguration $inputConf { $command ->setDescription('Creates an empty Guard authenticator') - ->addArgument('authenticator-class', InputArgument::OPTIONAL, 'The class name of the authenticator to create (e.g. AppCustomAuthenticator)') ->setHelp(file_get_contents(__DIR__.'/../Resources/help/MakeAuth.txt')); } public function interact(InputInterface $input, ConsoleStyle $io, Command $command) { if (!$this->fileManager->fileExists($path = 'config/packages/security.yaml')) { - return; + throw new RuntimeCommandException('The file "config/packages/security.yaml" does not exist. This command requires that file to exist so that it can be updated.'); } - $manipulator = new YamlSourceManipulator($this->fileManager->getFileContents($path)); $securityData = $manipulator->getData(); - $interactiveSecurityHelper = new InteractiveSecurityHelper(); + // authenticator type + $authenticatorTypeValues = [ + 'Empty authenticator' => self::AUTH_TYPE_EMPTY_AUTHENTICATOR, + 'Login form authenticator' => self::AUTH_TYPE_FORM_LOGIN, + ]; + $command->addArgument('authenticator-type', InputArgument::REQUIRED); + $authenticatorType = $io->choice( + 'What style of authentication do you want?', + array_keys($authenticatorTypeValues), + key($authenticatorTypeValues) + ); + $input->setArgument( + 'authenticator-type', + $authenticatorTypeValues[$authenticatorType] + ); - $command->addOption('firewall-name', null, InputOption::VALUE_OPTIONAL, ''); - $input->setOption('firewall-name', $firewallName = $interactiveSecurityHelper->guessFirewallName($io, $securityData)); + if (self::AUTH_TYPE_FORM_LOGIN === $input->getArgument('authenticator-type')) { + $neededDependencies = [TwigBundle::class => 'twig']; + $missingPackagesMessage = 'Twig must be installed to display the login form.'; - $command->addOption('entry-point', null, InputOption::VALUE_OPTIONAL); + if (Kernel::VERSION_ID < 40100) { + $neededDependencies[Form::class] = 'symfony/form'; + $missingPackagesMessage = 'Twig and symfony/form must be installed to display the login form'; + } - $authenticatorClassNameDetails = $this->generator->createClassNameDetails( - $input->getArgument('authenticator-class'), - 'Security\\' + $missingPackagesMessage = $this->addDependencies($neededDependencies, $missingPackagesMessage); + if ($missingPackagesMessage) { + throw new RuntimeCommandException($missingPackagesMessage); + } + + 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".'); + } + } + + // authenticator class + $command->addArgument('authenticator-class', InputArgument::REQUIRED); + $questionAuthenticatorClass = new Question('The class name of the authenticator to create (e.g. AppCustomAuthenticator)'); + $questionAuthenticatorClass->setValidator( + function ($answer) { + Validator::notBlank($answer); + + return Validator::classDoesNotExist( + $this->generator->createClassNameDetails($answer, 'Security\\', 'Authenticator')->getFullName() + ); + } ); + $input->setArgument('authenticator-class', $io->askQuestion($questionAuthenticatorClass)); + + $interactiveSecurityHelper = new InteractiveSecurityHelper(); + $command->addOption('firewall-name', null, InputOption::VALUE_OPTIONAL); + $input->setOption('firewall-name', $firewallName = $interactiveSecurityHelper->guessFirewallName($io, $securityData)); + $command->addOption('entry-point', null, InputOption::VALUE_OPTIONAL); $input->setOption( 'entry-point', - $interactiveSecurityHelper->guessEntryPoint($io, $securityData, $authenticatorClassNameDetails->getFullName(), $firewallName) + $interactiveSecurityHelper->guessEntryPoint($io, $securityData, $input->getArgument('authenticator-class'), $firewallName) ); + + if (self::AUTH_TYPE_FORM_LOGIN === $input->getArgument('authenticator-type')) { + $command->addArgument('controller-class', InputArgument::REQUIRED); + $input->setArgument( + 'controller-class', + $io->ask( + 'Choose a name for the controller class (e.g. SecurityController)', + 'SecurityController', + [Validator::class, 'validateClassName'] + ) + ); + + $command->addArgument('user-class', InputArgument::REQUIRED); + $input->setArgument( + 'user-class', + $userClass = $interactiveSecurityHelper->guessUserClass($io, $securityData['security']['providers']) + ); + + $command->addArgument('username-field', InputArgument::REQUIRED); + $input->setArgument( + 'username-field', + $interactiveSecurityHelper->guessUserNameField($io, $userClass, $securityData['security']['providers']) + ); + } } public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator) { - $classNameDetails = $generator->createClassNameDetails( - $input->getArgument('authenticator-class'), - 'Security\\' - ); + $manipulator = new YamlSourceManipulator($this->fileManager->getFileContents('config/packages/security.yaml')); + $securityData = $manipulator->getData(); - $generator->generateClass( - $classNameDetails->getFullName(), - 'authenticator/Empty.tpl.php', - [] + $this->generateAuthenticatorClass( + $securityData, + $input->getArgument('authenticator-type'), + $input->getArgument('authenticator-class'), + $input->hasArgument('user-class') ? $input->getArgument('user-class') : null, + $input->hasArgument('username-field') ? $input->getArgument('username-field') : null ); + // update security.yaml with guard config $securityYamlUpdated = false; - $path = 'config/packages/security.yaml'; - if ($this->fileManager->fileExists($path)) { - try { - $newYaml = $this->configUpdater->updateForAuthenticator( - $this->fileManager->getFileContents($path), - $input->getOption('firewall-name'), - $input->getOption('entry-point'), - $classNameDetails->getFullName() - ); - $generator->dumpFile($path, $newYaml); - $securityYamlUpdated = true; - } catch (YamlManipulationFailedException $e) { - } + try { + $newYaml = $this->configUpdater->updateForAuthenticator( + $this->fileManager->getFileContents($path = 'config/packages/security.yaml'), + $input->getOption('firewall-name'), + $input->getOption('entry-point'), + $input->getArgument('authenticator-class') + ); + $generator->dumpFile($path, $newYaml); + $securityYamlUpdated = true; + } catch (YamlManipulationFailedException $e) { + } + + if (self::AUTH_TYPE_FORM_LOGIN === $input->getArgument('authenticator-type')) { + $this->generateFormLoginFiles($input->getArgument('controller-class'), $input->getArgument('username-field')); } $generator->writeChanges(); $this->writeSuccessMessage($io); - $text = ['Next: Customize your new authenticator.']; + $io->text( + $this->generateNextMessage( + $securityYamlUpdated, + $input->getArgument('authenticator-type'), + $input->getArgument('authenticator-class'), + $securityData, + $input->hasArgument('user-class') ? $input->getArgument('user-class') : null + ) + ); + } + + private function generateAuthenticatorClass(array $securityData, string $authenticatorType, string $authenticatorClass, $userClass, $userNameField) + { + // generate authenticator class + if (self::AUTH_TYPE_EMPTY_AUTHENTICATOR === $authenticatorType) { + $this->generator->generateClass( + $authenticatorClass, + 'authenticator/EmptyAuthenticator.tpl.php', + [] + ); + + return; + } + + $userClassNameDetails = $this->generator->createClassNameDetails( + '\\'.$userClass, + 'Entity\\' + ); + + $this->generator->generateClass( + $authenticatorClass, + 'authenticator/LoginFormAuthenticator.tpl.php', + [ + 'user_fully_qualified_class_name' => trim($userClassNameDetails->getFullName(), '\\'), + 'user_class_name' => $userClassNameDetails->getShortName(), + 'username_field' => $userNameField, + 'user_needs_encoder' => $this->userClassHasEncoder($securityData, $userClass), + 'user_is_entity' => $this->doctrineHelper->isClassAMappedEntity($userClass), + ] + ); + } + + private function generateFormLoginFiles(string $controllerClass, string $userNameField) + { + $controllerClassNameDetails = $this->generator->createClassNameDetails( + $controllerClass, + 'Controller\\', + 'Controller' + ); + + if (!class_exists($controllerClassNameDetails->getFullName())) { + $controllerPath = $this->generator->generateController( + $controllerClassNameDetails->getFullName(), + 'authenticator/EmptySecurityController.tpl.php' + ); + + $controllerSourceCode = $this->generator->getFileContentsForPendingOperation($controllerPath); + } else { + $controllerPath = $this->fileManager->getRelativePathForFutureClass($controllerClassNameDetails->getFullName()); + $controllerSourceCode = $this->fileManager->getFileContents($controllerPath); + } + + if (method_exists($controllerClassNameDetails->getFullName(), 'login')) { + throw new RuntimeCommandException(sprintf('Method "login" already exists on class %s', $controllerClassNameDetails->getFullName())); + } + + $manipulator = new ClassSourceManipulator($controllerSourceCode, true); + + $securityControllerBuilder = new SecurityControllerBuilder(); + $securityControllerBuilder->addLoginMethod($manipulator); + + $this->generator->dumpFile($controllerPath, $manipulator->getSourceCode()); + + // create login form template + $this->generator->generateFile( + 'templates/security/login.html.twig', + 'authenticator/login_form.tpl.php', + [ + 'username_field' => $userNameField, + 'username_is_email' => false !== stripos($userNameField, 'email'), + 'username_label' => ucfirst(implode(' ', preg_split('/(?=[A-Z])/', 'oneTwoThree'))), + ] + ); + } + + private function generateNextMessage(bool $securityYamlUpdated, string $authenticatorType, string $authenticatorClass, array $securityData, $userClass): array + { + $nextTexts = ['Next:']; + $nextTexts[] = '- Customize your new authenticator.'; + if (!$securityYamlUpdated) { $yamlExample = $this->configUpdater->updateForAuthenticator( 'security: {}', 'main', null, - $classNameDetails->getFullName() + $authenticatorClass ); - $text[] = "Your security.yaml could not be updated automatically. You'll need to add the following config manually:\n\n".$yamlExample; + $nextTexts[] = '- Your security.yaml could not be updated automatically. You\'ll need to add the following config manually:\n\n'.$yamlExample; + } + + if (self::AUTH_TYPE_FORM_LOGIN === $authenticatorType) { + $nextTexts[] = sprintf('- Finish the redirect "TODO" in the %s::onAuthenticationSuccess() method.', $authenticatorClass); + + if (!$this->doctrineHelper->isClassAMappedEntity($userClass)) { + $nextTexts[] = sprintf('- Review %s::getUser() to make sure it matches your needs.', $authenticatorClass); + } + + if (!$this->userClassHasEncoder($securityData, $userClass)) { + $nextTexts[] = sprintf('- Check the user\'s password in %s::checkCredentials().', $authenticatorClass); + } + + $nextTexts[] = '- Review & adapt the login template: templates/security/login.html.twig.'; } - $io->text($text); + + return $nextTexts; + } + + private function userClassHasEncoder(array $securityData, string $userClass): bool + { + $userNeedsEncoder = false; + if (isset($securityData['security']['encoders']) && $securityData['security']['encoders']) { + foreach ($securityData['security']['encoders'] as $userClassWithEncoder => $encoder) { + if ($userClass === $userClassWithEncoder || is_subclass_of($userClass, $userClassWithEncoder)) { + $userNeedsEncoder = true; + } + } + } + + return $userNeedsEncoder; } - public function configureDependencies(DependencyBuilder $dependencies) + public function configureDependencies(DependencyBuilder $dependencies, InputInterface $input = null) { $dependencies->addClassDependency( SecurityBundle::class, 'security' ); + + // needed to update the YAML files + $dependencies->addClassDependency( + Yaml::class, + 'yaml' + ); } } diff --git a/src/Maker/MakeController.php b/src/Maker/MakeController.php index 4606fb557..757e98754 100644 --- a/src/Maker/MakeController.php +++ b/src/Maker/MakeController.php @@ -12,7 +12,6 @@ namespace Symfony\Bundle\MakerBundle\Maker; use Doctrine\Common\Annotations\Annotation; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\MakerBundle\ConsoleStyle; use Symfony\Bundle\MakerBundle\DependencyBuilder; use Symfony\Bundle\MakerBundle\Generator; @@ -52,11 +51,10 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen ); $templateName = Str::asFilePath($controllerClassNameDetails->getRelativeNameWithoutSuffix()).'/index.html.twig'; - $controllerPath = $generator->generateClass( + $controllerPath = $generator->generateController( $controllerClassNameDetails->getFullName(), 'controller/Controller.tpl.php', [ - 'parent_class_name' => \method_exists(AbstractController::class, 'getParameter') ? 'AbstractController' : 'Controller', 'route_path' => Str::asRoutePath($controllerClassNameDetails->getRelativeNameWithoutSuffix()), 'route_name' => Str::asRouteName($controllerClassNameDetails->getRelativeNameWithoutSuffix()), 'twig_installed' => $this->isTwigInstalled(), diff --git a/src/Maker/MakeCrud.php b/src/Maker/MakeCrud.php index 4c9d4da6d..ffa5bb40f 100644 --- a/src/Maker/MakeCrud.php +++ b/src/Maker/MakeCrud.php @@ -14,7 +14,6 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Doctrine\Common\Inflector\Inflector; use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\MakerBundle\ConsoleStyle; use Symfony\Bundle\MakerBundle\DependencyBuilder; use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper; @@ -128,11 +127,10 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen $routeName = Str::asRouteName($controllerClassDetails->getRelativeNameWithoutSuffix()); - $generator->generateClass( + $generator->generateController( $controllerClassDetails->getFullName(), 'crud/controller/Controller.tpl.php', array_merge([ - 'parent_class_name' => \method_exists(AbstractController::class, 'getParameter') ? 'AbstractController' : 'Controller', 'entity_full_class_name' => $entityClassDetails->getFullName(), 'entity_class_name' => $entityClassDetails->getShortName(), 'form_full_class_name' => $formClassDetails->getFullName(), diff --git a/src/Resources/config/makers.xml b/src/Resources/config/makers.xml index 3037ce68e..1a972a8bd 100644 --- a/src/Resources/config/makers.xml +++ b/src/Resources/config/makers.xml @@ -11,6 +11,7 @@ + diff --git a/src/Resources/help/MakeAuth.txt b/src/Resources/help/MakeAuth.txt index 7dfc130c7..579be4e1b 100644 --- a/src/Resources/help/MakeAuth.txt +++ b/src/Resources/help/MakeAuth.txt @@ -1,6 +1,8 @@ -The %command.name% command generates an empty -Guard Authenticator class. +The %command.name% command generates various authentication systems, +by asking questions. -php %command.full_name% AppCustomAuthenticator +It can provide an empty authenticator, or a full login form authentication process. +In both cases it also updates your security.yaml. +For the login form, it also generates a controller and the twig template. -If the argument is missing, the command will ask for the class name interactively. +php %command.full_name% diff --git a/src/Resources/skeleton/authenticator/Empty.tpl.php b/src/Resources/skeleton/authenticator/EmptyAuthenticator.tpl.php similarity index 100% rename from src/Resources/skeleton/authenticator/Empty.tpl.php rename to src/Resources/skeleton/authenticator/EmptyAuthenticator.tpl.php diff --git a/src/Resources/skeleton/authenticator/EmptySecurityController.tpl.php b/src/Resources/skeleton/authenticator/EmptySecurityController.tpl.php new file mode 100644 index 000000000..082766bd3 --- /dev/null +++ b/src/Resources/skeleton/authenticator/EmptySecurityController.tpl.php @@ -0,0 +1,11 @@ + + +namespace App\Controller; + +use Symfony\Bundle\FrameworkBundle\Controller\; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; + +class extends +{ +} diff --git a/src/Resources/skeleton/authenticator/LoginFormAuthenticator.tpl.php b/src/Resources/skeleton/authenticator/LoginFormAuthenticator.tpl.php new file mode 100644 index 000000000..1d451a0cf --- /dev/null +++ b/src/Resources/skeleton/authenticator/LoginFormAuthenticator.tpl.php @@ -0,0 +1,94 @@ + + +namespace ; + + + +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\RouterInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; + +use Symfony\Component\Security\Core\Exception\InvalidCsrfTokenException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Csrf\CsrfToken; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Symfony\Component\Security\Guard\Authenticator\AbstractFormLoginAuthenticator; +use Symfony\Component\Security\Http\Util\TargetPathTrait; + +class extends AbstractFormLoginAuthenticator +{ + use TargetPathTrait; + + + private $router; + private $csrfTokenManager; + + + public function __construct(RouterInterface $router, CsrfTokenManagerInterface $csrfTokenManager) + { +entityManager = \$entityManager;\n" : null ?> + $this->router = $router; + $this->csrfTokenManager = $csrfTokenManager; +passwordEncoder = \$passwordEncoder;\n" : null ?> + } + + public function supports(Request $request) + { + return 'app_login' === $request->attributes->get('_route') + && $request->isMethod('POST'); + } + + public function getCredentials(Request $request) + { + $credentials = [ + '' => $request->request->get(''), + 'password' => $request->request->get('password'), + 'csrf_token' => $request->request->get('_csrf_token'), + ]; + $request->getSession()->set( + Security::LAST_USERNAME, + $credentials[''] + ); + + return $credentials; + } + + public function getUser($credentials, UserProviderInterface $userProvider) + { + $token = new CsrfToken('authenticate', $credentials['csrf_token']); + if (!$this->csrfTokenManager->isTokenValid($token)) { + throw new InvalidCsrfTokenException(); + } + + entityManager->getRepository($user_class_name::class)->findOneBy(['$username_field' => \$credentials['$username_field']]);\n" + : "// Load / create our user however you need. + // You can do this by calling the user provider, or with custom logic here. + return \$userProvider->loadUserByUsername(\$credentials['$username_field']);\n"; ?> + } + + public function checkCredentials($credentials, UserInterface $user) + { + passwordEncoder->isPasswordValid(\$user, \$credentials['password']);\n" + : "// Check the user's password or other credentials and return true or false + // If there are no credentials to check, you can just return true + throw new \Exception('TODO: check the credentials inside '.__FILE__);\n" ?> + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey) + { + if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) { + return new RedirectResponse($targetPath); + } + + // For example : return new RedirectResponse($this->router->generate('some_route')); + throw new \Exception('TODO: provide a valid redirect inside '.__FILE__); + } + + protected function getLoginUrl() + { + return $this->router->generate('app_login'); + } +} diff --git a/src/Resources/skeleton/authenticator/login_form.tpl.php b/src/Resources/skeleton/authenticator/login_form.tpl.php new file mode 100644 index 000000000..9c4c70af3 --- /dev/null +++ b/src/Resources/skeleton/authenticator/login_form.tpl.php @@ -0,0 +1,36 @@ +{% extends 'base.html.twig' %} + +{% block title %}Log in!{% endblock %} + +{% block body %} +
+ {% if error %} +
{{ error.messageKey|trans(error.messageData, 'security') }}
+ {% endif %} + +

Please sign in

+ + + + + + + + {# + 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 + +
+ +
+ #} + + +
+{% endblock %} diff --git a/src/Security/InteractiveSecurityHelper.php b/src/Security/InteractiveSecurityHelper.php index 346fd0148..289e9af8a 100644 --- a/src/Security/InteractiveSecurityHelper.php +++ b/src/Security/InteractiveSecurityHelper.php @@ -12,7 +12,9 @@ namespace Symfony\Bundle\MakerBundle\Security; use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException; +use Symfony\Bundle\MakerBundle\Validator; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Security\Core\User\UserInterface; /** * @internal @@ -75,4 +77,63 @@ public function guessEntryPoint(SymfonyStyle $io, array $securityData, string $a current($authenticators) ); } + + public function guessUserClass(SymfonyStyle $io, array $providers): string + { + if (1 === \count($providers) && isset(current($providers)['entity'])) { + $entityProvider = current($providers); + + return $entityProvider['entity']['class']; + } + + $userClass = $io->ask( + 'Enter the User class that you want to authenticate (e.g. App\\Entity\\User)', + $this->guessUserClassDefault(), + [Validator::class, 'classIsUserInterface'] + ); + + return $userClass; + } + + private function guessUserClassDefault() + { + if (class_exists('App\\Entity\\User') && isset(class_implements('App\\Entity\\User')[UserInterface::class])) { + return 'App\\Entity\\User'; + } + + if (class_exists('App\\Security\\User') && isset(class_implements('App\\Security\\User')[UserInterface::class])) { + return 'App\\Security\\User'; + } + + return null; + } + + public function guessUserNameField(SymfonyStyle $io, string $userClass, array $providers): string + { + if (1 === \count($providers) && isset(current($providers)['entity'])) { + $entityProvider = current($providers); + + return $entityProvider['entity']['property']; + } + + if (property_exists($userClass, 'email') && !property_exists($userClass, 'username')) { + return 'email'; + } + + if (!property_exists($userClass, 'email') && property_exists($userClass, 'username')) { + return 'username'; + } + + $classProperties = []; + $reflectionClass = new \ReflectionClass($userClass); + foreach ($reflectionClass->getProperties() as $property) { + $classProperties[] = $property->name; + } + + return $io->choice( + sprintf('Which field on your %s class will people enter when logging in?', $userClass), + $classProperties, + property_exists($userClass, 'username') ? 'username' : (property_exists($userClass, 'email') ? 'email' : null) + ); + } } diff --git a/src/Security/SecurityControllerBuilder.php b/src/Security/SecurityControllerBuilder.php new file mode 100644 index 000000000..d06dcb042 --- /dev/null +++ b/src/Security/SecurityControllerBuilder.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Security; + +use Symfony\Bundle\MakerBundle\Util\ClassSourceManipulator; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; + +/** + * @internal + */ +final class SecurityControllerBuilder +{ + public function addLoginMethod(ClassSourceManipulator $manipulator) + { + $loginMethodBuilder = $manipulator->createMethodBuilder('login', 'Response', false, ['@Route("/login", name="app_login")']); + + $manipulator->addUseStatementIfNecessary(Response::class); + $manipulator->addUseStatementIfNecessary(Route::class); + $manipulator->addUseStatementIfNecessary(AuthenticationUtils::class); + + $loginMethodBuilder->addParam( + (new \PhpParser\Builder\Param('authenticationUtils'))->setTypeHint('AuthenticationUtils') + ); + + $manipulator->addMethodBody($loginMethodBuilder, <<<'CODE' +getLastAuthenticationError(); +// last username entered by the user +$lastUsername = $authenticationUtils->getLastUsername(); +CODE + ); + $loginMethodBuilder->addStmt($manipulator->createMethodLevelBlankLine()); + $manipulator->addMethodBody($loginMethodBuilder, <<<'CODE' +render( + 'security/login.html.twig', + [ + 'last_username' => $lastUsername, + 'error' => $error, + ] +); +CODE + ); + $manipulator->addMethodBuilder($loginMethodBuilder); + } +} diff --git a/src/Util/ClassSourceManipulator.php b/src/Util/ClassSourceManipulator.php index b6ce1a0a3..05fde0b51 100644 --- a/src/Util/ClassSourceManipulator.php +++ b/src/Util/ClassSourceManipulator.php @@ -217,6 +217,12 @@ public function addMethodBuilder(Builder\Method $methodBuilder) $this->addMethod($methodBuilder->getNode()); } + public function addMethodBody(Builder\Method $methodBuilder, string $methodBody) + { + $nodes = $this->parser->parse($methodBody); + $methodBuilder->addStmts($nodes); + } + public function createMethodBuilder(string $methodName, $returnType, bool $isReturnTypeNullable, array $commentLines = []): Builder\Method { $methodNodeBuilder = (new Builder\Method($methodName)) @@ -662,7 +668,7 @@ private function getConstructorNode() * * @return string The alias to use when referencing this class */ - private function addUseStatementIfNecessary(string $class): string + public function addUseStatementIfNecessary(string $class): string { $shortClassName = Str::getShortClassName($class); if ($this->isInSameNamespace($class)) { diff --git a/src/Validator.php b/src/Validator.php index d058a3e1a..2e1d11b9c 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -13,6 +13,7 @@ use Doctrine\Common\Persistence\ManagerRegistry; use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException; +use Symfony\Component\Security\Core\User\UserInterface; /** * @author Javier Eguiluz @@ -184,4 +185,26 @@ public static function entityExists(string $className = null, array $entities = return $className; } + + public static function classDoesNotExist($className): string + { + self::notBlank($className); + + if (class_exists($className)) { + throw new RuntimeCommandException(sprintf('Class "%s" already exists', $className)); + } + + return $className; + } + + public static function classIsUserInterface($userClassName): string + { + self::classExists($userClassName); + + if (!isset(class_implements($userClassName)[UserInterface::class])) { + throw new RuntimeCommandException(sprintf('The class "%s" must implement "%s".', $userClassName, UserInterface::class)); + } + + return $userClassName; + } } diff --git a/tests/GeneratorTest.php b/tests/GeneratorTest.php index e09dfac02..d0ebc5c2a 100644 --- a/tests/GeneratorTest.php +++ b/tests/GeneratorTest.php @@ -57,5 +57,13 @@ public function getClassNameDetailsTests() 'Foo\Bar\Baz', 'Bar\Baz' ]; + + yield 'enty_fqcn' => [ + '\\App\\Entity\\User', + 'Entity\\', + '', + 'App\\Entity\\User', + 'User' + ]; } } diff --git a/tests/Maker/FunctionalTest.php b/tests/Maker/FunctionalTest.php index aed931bbf..9628f943b 100644 --- a/tests/Maker/FunctionalTest.php +++ b/tests/Maker/FunctionalTest.php @@ -288,11 +288,12 @@ public function getCommandTests() MakerTestDetails::createTest( $this->getMakerInstance(MakeAuthenticator::class), [ - // class name + // authenticator type => empty-auth + 0, + // authenticator class name 'AppCustomAuthenticator', ] ) - ->addExtraDependencies('security') ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeAuthenticator') ->assert( function (string $output, string $directory) { @@ -314,13 +315,14 @@ function (string $output, string $directory) { MakerTestDetails::createTest( $this->getMakerInstance(MakeAuthenticator::class), [ + // authenticator type => empty-auth + 0, // class name 'AppCustomAuthenticator', // firewall name - 1 + 1, ] ) - ->addExtraDependencies('security') ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeAuthenticatorMultipleFirewalls') ->assert( function (string $output, string $directory) { @@ -339,13 +341,14 @@ function (string $output, string $directory) { MakerTestDetails::createTest( $this->getMakerInstance(MakeAuthenticator::class), [ + // authenticator type => empty-auth + 0, // class name 'AppCustomAuthenticator', // firewall name - 1 + 1, ] ) - ->addExtraDependencies('security') ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeAuthenticatorExistingAuthenticator') ->assert( function (string $output, string $directory) { @@ -364,15 +367,16 @@ function (string $output, string $directory) { MakerTestDetails::createTest( $this->getMakerInstance(MakeAuthenticator::class), [ + // authenticator type => empty-auth + 0, // class name 'AppCustomAuthenticator', // firewall name 1, // entry point - 1 + 1, ] ) - ->addExtraDependencies('security') ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeAuthenticatorMultipleFirewallsExistingAuthenticator') ->assert( function (string $output, string $directory) { @@ -387,6 +391,138 @@ function (string $output, string $directory) { ), ]; + yield 'auth_login_form_user_entity_with_encoder' => [ + MakerTestDetails::createTest( + $this->getMakerInstance(MakeAuthenticator::class), + [ + // authenticator type => login-form + 1, + // class name + 'AppCustomAuthenticator', + // controller name + 'SecurityController', + ] + ) + ->addExtraDependencies('doctrine') + ->addExtraDependencies('twig') + ->addExtraDependencies('symfony/form') + ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeAuthenticatorLoginFormUserEntity') + ->configureDatabase() + ->updateSchemaAfterCommand() + ->assert( + function (string $output, string $directory) { + $this->assertContains('Success', $output); + + $fs = new Filesystem(); + $this->assertTrue($fs->exists(sprintf('%s/src/Controller/SecurityController.php', $directory))); + $this->assertTrue($fs->exists(sprintf('%s/templates/security/login.html.twig', $directory))); + $this->assertTrue($fs->exists(sprintf('%s/src/Security/AppCustomAuthenticator.php', $directory))); + } + ), + ]; + + yield 'auth_login_form_custom_username_field' => [ + MakerTestDetails::createTest( + $this->getMakerInstance(MakeAuthenticator::class), + [ + // authenticator type => login-form + 1, + // class name + 'AppCustomAuthenticator', + // controller name + 'SecurityController', + // user class + 'App\\Security\\User', + // username field => userEmail + 0 + ] + ) + ->addExtraDependencies('doctrine/annotations') + ->addExtraDependencies('twig') + ->addExtraDependencies('symfony/form') + ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeAuthenticatorLoginFormCustomUsernameField') + ]; + + yield 'auth_login_form_user_entity_no_encoder' => [ + MakerTestDetails::createTest( + $this->getMakerInstance(MakeAuthenticator::class), + [ + // authenticator type => login-form + 1, + // class name + 'AppCustomAuthenticator', + // controller name + 'SecurityController', + ] + ) + ->addExtraDependencies('doctrine') + ->addExtraDependencies('twig') + ->addExtraDependencies('symfony/form') + ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeAuthenticatorLoginFormUserEntityNoEncoder') + ->configureDatabase() + ->updateSchemaAfterCommand(), + ]; + + yield 'auth_login_form_user_not_entity_with_encoder' => [ + MakerTestDetails::createTest( + $this->getMakerInstance(MakeAuthenticator::class), + [ + // authenticator type => login-form + 1, + // class name + 'AppCustomAuthenticator', + // controller name + 'SecurityController', + // user class + 'App\Security\User', + ] + ) + ->addExtraDependencies('twig') + ->addExtraDependencies('doctrine/annotations') + ->addExtraDependencies('symfony/form') + ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeAuthenticatorLoginFormUserNotEntity'), + ]; + + yield 'auth_login_form_user_not_entity_no_encoder' => [ + MakerTestDetails::createTest( + $this->getMakerInstance(MakeAuthenticator::class), + [ + // authenticator type => login-form + 1, + // class name + 'AppCustomAuthenticator', + // controller name + 'SecurityController', + // user class + 'App\Security\User', + ] + ) + ->addExtraDependencies('twig') + ->addExtraDependencies('doctrine/annotations') + ->addExtraDependencies('symfony/form') + ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeAuthenticatorLoginFormUserNotEntityNoEncoder'), + ]; + + yield 'auth_login_form_existing_controller' => [ + MakerTestDetails::createTest( + $this->getMakerInstance(MakeAuthenticator::class), + [ + // authenticator type => login-form + 1, + // class name + 'AppCustomAuthenticator', + // controller name + 'SecurityController', + ] + ) + ->addExtraDependencies('doctrine') + ->addExtraDependencies('twig') + ->addExtraDependencies('symfony/form') + ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeAuthenticatorLoginFormExistingController') + ->configureDatabase() + ->updateSchemaAfterCommand(), + ]; + yield 'user_security_entity_with_password' => [MakerTestDetails::createTest( $this->getMakerInstance(MakeUser::class), [ diff --git a/tests/Security/InteractiveSecurityHelperTest.php b/tests/Security/InteractiveSecurityHelperTest.php index 180a21d34..e690a9585 100644 --- a/tests/Security/InteractiveSecurityHelperTest.php +++ b/tests/Security/InteractiveSecurityHelperTest.php @@ -120,4 +120,109 @@ public function getEntryPointTests() 'main', ]; } + + /** + * @dataProvider getUserClassTests + */ + public function testGuessUserClass(array $securityData, string $expectedUserClass, bool $userClassAutomaticallyGuessed) + { + /** @var SymfonyStyle|\PHPUnit_Framework_MockObject_MockObject $io */ + $io = $this->createMock(SymfonyStyle::class); + $io->expects($this->exactly(true === $userClassAutomaticallyGuessed ? 0 : 1)) + ->method('ask') + ->willReturn($expectedUserClass); + + $helper = new InteractiveSecurityHelper(); + $this->assertEquals( + $expectedUserClass, + $helper->guessUserClass($io, $securityData) + ); + } + + public function getUserClassTests() + { + yield 'user_from_provider' => [ + ['app_provider' => ['entity' => ['class' => 'App\\Entity\\User']]], + 'App\\Entity\\User', + true, + ]; + + yield 'multiple_providers' => [ + ['provider_1' => ['id' => 'app.provider_1'], 'provider_2' => ['id' => 'app.provider_2']], + 'App\\Entity\\User', + false, + ]; + + yield 'no_provider' => [ + [[]], + 'App\\Entity\\User', + false, + ]; + } + + /** + * @dataProvider getUsernameFieldsTest + */ + public function testGuessUserNameField(array $providers, string $expectedUsernameField, bool $fieldAutomaticallyGuessed, string $class = '', array $choices = []) + { + /** @var SymfonyStyle|\PHPUnit_Framework_MockObject_MockObject $io */ + $io = $this->createMock(SymfonyStyle::class); + $io->expects($this->exactly(true === $fieldAutomaticallyGuessed ? 0 : 1)) + ->method('choice') + ->with(sprintf('Which field on your %s class will people enter when logging in?', $class), $choices, 'username') + ->willReturn($expectedUsernameField); + + $interactiveSecurityHelper = new InteractiveSecurityHelper(); + $this->assertEquals( + $expectedUsernameField, + $interactiveSecurityHelper->guessUserNameField($io, $class, $providers) + ); + } + + public function getUsernameFieldsTest() + { + yield 'guess_with_providers' => [ + 'providers' => ['app_provider' => ['entity' => ['property' => 'userEmail']]], + 'expectedUsernameField' => 'userEmail', + true + ]; + + yield 'guess_fixture_class' => [ + 'providers' => [], + 'expectedUsernameField' => 'email', + true, + FixtureClass::class + ]; + + yield 'guess_fixture_class_2' => [ + 'providers' => [], + 'expectedUsernameField' => 'username', + true, + FixtureClass2::class + ]; + + yield 'guess_fixture_class_3' => [ + 'providers' => [], + 'expectedUsernameField' => 'username', + false, + FixtureClass3::class, + ['username', 'email'] + ]; + } +} + +class FixtureClass +{ + private $email; +} + +class FixtureClass2 +{ + private $username; +} + +class FixtureClass3 +{ + private $username; + private $email; } \ No newline at end of file diff --git a/tests/Security/SecurityControllerBuilderTest.php b/tests/Security/SecurityControllerBuilderTest.php new file mode 100644 index 000000000..f0a7ccd6d --- /dev/null +++ b/tests/Security/SecurityControllerBuilderTest.php @@ -0,0 +1,23 @@ +addLoginMethod($manipulator); + + $this->assertSame($expectedSource, $manipulator->getSourceCode()); + } +} \ No newline at end of file diff --git a/tests/Security/fixtures/expected/SecurityController_login.php b/tests/Security/fixtures/expected/SecurityController_login.php new file mode 100644 index 000000000..fa6ec34e1 --- /dev/null +++ b/tests/Security/fixtures/expected/SecurityController_login.php @@ -0,0 +1,24 @@ +getLastAuthenticationError(); + // last username entered by the user + $lastUsername = $authenticationUtils->getLastUsername(); + + return $this->render('security/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]); + } +} diff --git a/tests/Security/fixtures/source/SecurityController.php b/tests/Security/fixtures/source/SecurityController.php new file mode 100644 index 000000000..6108c0aae --- /dev/null +++ b/tests/Security/fixtures/source/SecurityController.php @@ -0,0 +1,9 @@ +assertSame($expectedSource, $manipulator->getSourceCode()); } + + public function testAddMethodWithBody() + { + $source = file_get_contents(__DIR__.'/fixtures/source/EmptyController.php'); + $expectedSource = file_get_contents(__DIR__.'/fixtures/add_method/Controller_with_action.php'); + + $manipulator = new ClassSourceManipulator($source); + + $methodBuilder = $manipulator->createMethodBuilder('action', 'JsonResponse', false, ['@Route("/action", name="app_action")']); + $methodBuilder->addParam( + (new \PhpParser\Builder\Param('param'))->setTypeHint('string') + ); + $manipulator->addMethodBody($methodBuilder, <<<'CODE' + $param]); +CODE + ); + $manipulator->addMethodBuilder($methodBuilder); + $manipulator->addUseStatementIfNecessary('Symfony\\Component\\HttpFoundation\\JsonResponse'); + $manipulator->addUseStatementIfNecessary('Symfony\\Component\\Routing\\Annotation\\Route'); + + $this->assertSame($expectedSource, $manipulator->getSourceCode()); + } } diff --git a/tests/Util/fixtures/add_method/Controller_with_action.php b/tests/Util/fixtures/add_method/Controller_with_action.php new file mode 100644 index 000000000..e321f2732 --- /dev/null +++ b/tests/Util/fixtures/add_method/Controller_with_action.php @@ -0,0 +1,17 @@ + $param]); + } +} diff --git a/tests/Util/fixtures/source/EmptyController.php b/tests/Util/fixtures/source/EmptyController.php new file mode 100644 index 000000000..9ec2dc898 --- /dev/null +++ b/tests/Util/fixtures/source/EmptyController.php @@ -0,0 +1,7 @@ +userEmail; + } + + public function setUserEmail(string $userEmail): self + { + $this->userEmail = $userEmail; + + return $this; + } + + /** + * A visual identifier that represents this user. + * + * @see UserInterface + */ + public function getUsername(): string + { + return (string) $this->userEmail; + } + + /** + * @see UserInterface + */ + public function getRoles(): array + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + + return array_unique($roles); + } + + public function setRoles(array $roles): self + { + $this->roles = $roles; + + return $this; + } + + /** + * @see UserInterface + */ + public function getPassword(): string + { + return (string) $this->password; + } + + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } + + /** + * @see UserInterface + */ + public function getSalt() + { + // not needed when using the "bcrypt" algorithm in security.yaml + } + + /** + * @see UserInterface + */ + public function eraseCredentials() + { + // If you store any temporary, sensitive data on the user, clear it here + // $this->plainPassword = null; + } +} diff --git a/tests/fixtures/MakeAuthenticatorLoginFormCustomUsernameField/src/Security/UserProvider.php b/tests/fixtures/MakeAuthenticatorLoginFormCustomUsernameField/src/Security/UserProvider.php new file mode 100644 index 000000000..2d853f3f4 --- /dev/null +++ b/tests/fixtures/MakeAuthenticatorLoginFormCustomUsernameField/src/Security/UserProvider.php @@ -0,0 +1,33 @@ +setUserEmail($username) + ->setPassword($username); + } + + /** + * {@inheritdoc} + */ + public function refreshUser(UserInterface $user) + { + } + + /** + * {@inheritdoc} + */ + public function supportsClass($class) + { + } +} diff --git a/tests/fixtures/MakeAuthenticatorLoginFormCustomUsernameField/tests/SecurityControllerTest.php b/tests/fixtures/MakeAuthenticatorLoginFormCustomUsernameField/tests/SecurityControllerTest.php new file mode 100644 index 000000000..7bbd8d765 --- /dev/null +++ b/tests/fixtures/MakeAuthenticatorLoginFormCustomUsernameField/tests/SecurityControllerTest.php @@ -0,0 +1,46 @@ +getConstructor()->getParameters(); + $this->assertSame('router', $constructorParameters[0]->getName()); + + // assert authenticator is injected + $this->assertEquals(3, \count($constructorParameters)); + + $client = self::createClient(); + $crawler = $client->request('GET', '/login'); + + $this->assertEquals(200, $client->getResponse()->getStatusCode()); + + $form = $crawler->filter('form')->form(); + $form->setValues( + [ + 'userEmail' => 'bar', + 'password' => 'foo', + ] + ); + $client->submit($form); + + $this->assertEquals(302, $client->getResponse()->getStatusCode()); + $client->followRedirect(); + $this->assertContains('Invalid credentials.', $client->getResponse()->getContent()); + $form->setValues( + [ + 'userEmail' => 'test@symfony.com', + 'password' => 'test@symfony.com', + ] + ); + $client->submit($form); + + $this->assertContains('TODO: provide a valid redirect', $client->getResponse()->getContent()); + } +} diff --git a/tests/fixtures/MakeAuthenticatorLoginFormExistingController/config/packages/security.yaml b/tests/fixtures/MakeAuthenticatorLoginFormExistingController/config/packages/security.yaml new file mode 100644 index 000000000..e23084340 --- /dev/null +++ b/tests/fixtures/MakeAuthenticatorLoginFormExistingController/config/packages/security.yaml @@ -0,0 +1,31 @@ +security: + encoders: + App\Entity\User: plaintext + + # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers + providers: + app_user_provider: + entity: + class: App\Entity\User + property: email + + firewalls: + dev: + pattern: ^/(_(profiler|wdt)|css|images|js)/ + security: false + main: + anonymous: true + + # activate different ways to authenticate + + # http_basic: true + # https://symfony.com/doc/current/security.html#a-configuring-how-your-users-will-authenticate + + # form_login: true + # https://symfony.com/doc/current/security/form_login_setup.html + + # Easy way to control access for large sections of your site + # Note: Only the *first* access control that matches will be used + access_control: + # - { path: ^/admin, roles: ROLE_ADMIN } + # - { path: ^/profile, roles: ROLE_USER } diff --git a/tests/fixtures/MakeAuthenticatorLoginFormExistingController/src/Controller/SecurityController.php b/tests/fixtures/MakeAuthenticatorLoginFormExistingController/src/Controller/SecurityController.php new file mode 100644 index 000000000..6108c0aae --- /dev/null +++ b/tests/fixtures/MakeAuthenticatorLoginFormExistingController/src/Controller/SecurityController.php @@ -0,0 +1,9 @@ +id; + } + + public function getEmail() + { + return $this->email; + } + + public function setEmail(string $email): self + { + $this->email = $email; + + return $this; + } + + /** + * A visual identifier that represents this user. + * + * @see UserInterface + */ + public function getUsername(): string + { + return (string) $this->email; + } + + /** + * @see UserInterface + */ + public function getRoles(): array + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + + return array_unique($roles); + } + + public function setRoles(array $roles): self + { + $this->roles = $roles; + + return $this; + } + + /** + * @see UserInterface + */ + public function getPassword(): string + { + return (string) $this->password; + } + + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } + + /** + * @see UserInterface + */ + public function getSalt() + { + // not needed when using the "bcrypt" algorithm in security.yaml + } + + /** + * @see UserInterface + */ + public function eraseCredentials() + { + // If you store any temporary, sensitive data on the user, clear it here + // $this->plainPassword = null; + } +} diff --git a/tests/fixtures/MakeAuthenticatorLoginFormExistingController/tests/SecurityControllerTest.php b/tests/fixtures/MakeAuthenticatorLoginFormExistingController/tests/SecurityControllerTest.php new file mode 100644 index 000000000..93e60d559 --- /dev/null +++ b/tests/fixtures/MakeAuthenticatorLoginFormExistingController/tests/SecurityControllerTest.php @@ -0,0 +1,55 @@ +assertTrue(method_exists(SecurityController::class, 'login')); + + $client = self::createClient(); + $crawler = $client->request('GET', '/login'); + + $this->assertEquals(200, $client->getResponse()->getStatusCode()); + + /** @var EntityManagerInterface $em */ + $em = self::$kernel->getContainer() + ->get('doctrine') + ->getManager(); + + $user = (new User())->setEmail('test@symfony.com') + ->setPassword('password'); + $em->persist($user); + $em->flush(); + + $form = $crawler->filter('form')->form(); + $form->setValues( + [ + 'email' => 'test@symfony.com', + 'password' => 'foo', + ] + ); + $client->submit($form); + + $this->assertEquals(302, $client->getResponse()->getStatusCode()); + $client->followRedirect(); + $this->assertEquals(200, $client->getResponse()->getStatusCode()); + $this->assertContains('Invalid credentials.', $client->getResponse()->getContent()); + + $form->setValues( + [ + 'email' => 'test@symfony.com', + 'password' => 'password', + ] + ); + $client->submit($form); + + $this->assertContains('TODO: provide a valid redirect', $client->getResponse()->getContent()); + } +} diff --git a/tests/fixtures/MakeAuthenticatorLoginFormUserEntity/config/packages/security.yaml b/tests/fixtures/MakeAuthenticatorLoginFormUserEntity/config/packages/security.yaml new file mode 100644 index 000000000..287d8fdb1 --- /dev/null +++ b/tests/fixtures/MakeAuthenticatorLoginFormUserEntity/config/packages/security.yaml @@ -0,0 +1,31 @@ +security: + encoders: + App\Entity\User: plaintext + + # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers + providers: + app_user_provider: + entity: + class: App\Entity\User + property: userEmail + + firewalls: + dev: + pattern: ^/(_(profiler|wdt)|css|images|js)/ + security: false + main: + anonymous: true + + # activate different ways to authenticate + + # http_basic: true + # https://symfony.com/doc/current/security.html#a-configuring-how-your-users-will-authenticate + + # form_login: true + # https://symfony.com/doc/current/security/form_login_setup.html + + # Easy way to control access for large sections of your site + # Note: Only the *first* access control that matches will be used + access_control: + # - { path: ^/admin, roles: ROLE_ADMIN } + # - { path: ^/profile, roles: ROLE_USER } diff --git a/tests/fixtures/MakeAuthenticatorLoginFormUserEntity/src/Entity/User.php b/tests/fixtures/MakeAuthenticatorLoginFormUserEntity/src/Entity/User.php new file mode 100644 index 000000000..244bc94eb --- /dev/null +++ b/tests/fixtures/MakeAuthenticatorLoginFormUserEntity/src/Entity/User.php @@ -0,0 +1,113 @@ +id; + } + + public function getUserEmail() + { + return $this->userEmail; + } + + public function setUserEmail(string $userEmail): self + { + $this->userEmail = $userEmail; + + return $this; + } + + /** + * A visual identifier that represents this user. + * + * @see UserInterface + */ + public function getUsername(): string + { + return (string) $this->userEmail; + } + + /** + * @see UserInterface + */ + public function getRoles(): array + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + + return array_unique($roles); + } + + public function setRoles(array $roles): self + { + $this->roles = $roles; + + return $this; + } + + /** + * @see UserInterface + */ + public function getPassword(): string + { + return (string) $this->password; + } + + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } + + /** + * @see UserInterface + */ + public function getSalt() + { + // not needed when using the "bcrypt" algorithm in security.yaml + } + + /** + * @see UserInterface + */ + public function eraseCredentials() + { + // If you store any temporary, sensitive data on the user, clear it here + // $this->plainPassword = null; + } +} diff --git a/tests/fixtures/MakeAuthenticatorLoginFormUserEntity/tests/SecurityControllerTest.php b/tests/fixtures/MakeAuthenticatorLoginFormUserEntity/tests/SecurityControllerTest.php new file mode 100644 index 000000000..737c80af6 --- /dev/null +++ b/tests/fixtures/MakeAuthenticatorLoginFormUserEntity/tests/SecurityControllerTest.php @@ -0,0 +1,66 @@ +getConstructor()->getParameters(); + $this->assertSame('entityManager', $constructorParameters[0]->getName()); + + // assert authenticator is injected + $this->assertEquals(4, \count($constructorParameters)); + + $client = self::createClient(); + $crawler = $client->request('GET', '/login'); + + $this->assertEquals(200, $client->getResponse()->getStatusCode()); + + /** @var EntityManagerInterface $em */ + $em = self::$kernel->getContainer() + ->get('doctrine') + ->getManager(); + + $user = (new User())->setUserEmail('test@symfony.com') + ->setPassword('password'); + $em->persist($user); + $em->flush(); + + $form = $crawler->filter('form')->form(); + $form->setValues( + [ + 'userEmail' => 'test@symfony.com', + 'password' => 'foo', + ] + ); + $crawler = $client->submit($form); + + if ($client->getResponse()->getStatusCode() === 500) { + $this->assertEquals('', $crawler->filter('h1.exception-message')->text()); + } + + $this->assertEquals(302, $client->getResponse()->getStatusCode()); + $client->followRedirect(); + $this->assertEquals(200, $client->getResponse()->getStatusCode()); + $this->assertContains('Invalid credentials.', $client->getResponse()->getContent()); + + $form->setValues( + [ + 'userEmail' => 'test@symfony.com', + 'password' => 'password', + ] + ); + $client->submit($form); + + $this->assertContains('TODO: provide a valid redirect', $client->getResponse()->getContent()); + $this->assertNotNull($token = $client->getContainer()->get('security.token_storage')->getToken()); + $this->assertInstanceOf(User::class, $token->getUser()); + } +} diff --git a/tests/fixtures/MakeAuthenticatorLoginFormUserEntityNoEncoder/config/packages/security.yaml b/tests/fixtures/MakeAuthenticatorLoginFormUserEntityNoEncoder/config/packages/security.yaml new file mode 100644 index 000000000..78ec73799 --- /dev/null +++ b/tests/fixtures/MakeAuthenticatorLoginFormUserEntityNoEncoder/config/packages/security.yaml @@ -0,0 +1,28 @@ +security: + # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers + providers: + app_user_provider: + entity: + class: App\Entity\User + property: email + + firewalls: + dev: + pattern: ^/(_(profiler|wdt)|css|images|js)/ + security: false + main: + anonymous: true + + # activate different ways to authenticate + + # http_basic: true + # https://symfony.com/doc/current/security.html#a-configuring-how-your-users-will-authenticate + + # form_login: true + # https://symfony.com/doc/current/security/form_login_setup.html + + # Easy way to control access for large sections of your site + # Note: Only the *first* access control that matches will be used + access_control: + # - { path: ^/admin, roles: ROLE_ADMIN } + # - { path: ^/profile, roles: ROLE_USER } diff --git a/tests/fixtures/MakeAuthenticatorLoginFormUserEntityNoEncoder/src/Entity/User.php b/tests/fixtures/MakeAuthenticatorLoginFormUserEntityNoEncoder/src/Entity/User.php new file mode 100644 index 000000000..c8ab9ec81 --- /dev/null +++ b/tests/fixtures/MakeAuthenticatorLoginFormUserEntityNoEncoder/src/Entity/User.php @@ -0,0 +1,113 @@ +id; + } + + public function getEmail() + { + return $this->email; + } + + public function setEmail(string $email): self + { + $this->email = $email; + + return $this; + } + + /** + * A visual identifier that represents this user. + * + * @see UserInterface + */ + public function getUsername(): string + { + return (string) $this->email; + } + + /** + * @see UserInterface + */ + public function getRoles(): array + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + + return array_unique($roles); + } + + public function setRoles(array $roles): self + { + $this->roles = $roles; + + return $this; + } + + /** + * @see UserInterface + */ + public function getPassword(): string + { + return (string) $this->password; + } + + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } + + /** + * @see UserInterface + */ + public function getSalt() + { + // not needed when using the "bcrypt" algorithm in security.yaml + } + + /** + * @see UserInterface + */ + public function eraseCredentials() + { + // If you store any temporary, sensitive data on the user, clear it here + // $this->plainPassword = null; + } +} diff --git a/tests/fixtures/MakeAuthenticatorLoginFormUserEntityNoEncoder/tests/SecurityControllerTest.php b/tests/fixtures/MakeAuthenticatorLoginFormUserEntityNoEncoder/tests/SecurityControllerTest.php new file mode 100644 index 000000000..ddad58dcc --- /dev/null +++ b/tests/fixtures/MakeAuthenticatorLoginFormUserEntityNoEncoder/tests/SecurityControllerTest.php @@ -0,0 +1,48 @@ +getConstructor()->getParameters(); + $this->assertSame('entityManager', $constructorParameters[0]->getName()); + + // assert authenticator is *not* injected + $this->assertEquals(3, \count($constructorParameters)); + + $client = self::createClient(); + $crawler = $client->request('GET', '/login'); + + $this->assertEquals(200, $client->getResponse()->getStatusCode()); + + /** @var EntityManagerInterface $em */ + $em = self::$kernel->getContainer() + ->get('doctrine') + ->getManager(); + + $user = (new User())->setEmail('test@symfony.com') + ->setPassword('password'); + $em->persist($user); + $em->flush(); + + $form = $crawler->filter('form')->form(); + $form->setValues( + [ + 'email' => 'test@symfony.com', + 'password' => 'foo', + ] + ); + + $client->submit($form); + + $this->assertContains('TODO: check the credentials', $client->getResponse()->getContent()); + } +} diff --git a/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntity/config/packages/security.yaml b/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntity/config/packages/security.yaml new file mode 100644 index 000000000..6d3260fb6 --- /dev/null +++ b/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntity/config/packages/security.yaml @@ -0,0 +1,29 @@ +security: + encoders: + App\Security\User: plaintext + + # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers + providers: + app_user_provider: + id: App\Security\UserProvider + + firewalls: + dev: + pattern: ^/(_(profiler|wdt)|css|images|js)/ + security: false + main: + anonymous: true + + # activate different ways to authenticate + + # http_basic: true + # https://symfony.com/doc/current/security.html#a-configuring-how-your-users-will-authenticate + + # form_login: true + # https://symfony.com/doc/current/security/form_login_setup.html + + # Easy way to control access for large sections of your site + # Note: Only the *first* access control that matches will be used + access_control: + # - { path: ^/admin, roles: ROLE_ADMIN } + # - { path: ^/profile, roles: ROLE_USER } diff --git a/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntity/src/Security/User.php b/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntity/src/Security/User.php new file mode 100644 index 000000000..e7bd9b1e3 --- /dev/null +++ b/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntity/src/Security/User.php @@ -0,0 +1,90 @@ +email; + } + + public function setEmail(string $email): self + { + $this->email = $email; + + return $this; + } + + /** + * A visual identifier that represents this user. + * + * @see UserInterface + */ + public function getUsername(): string + { + return (string) $this->email; + } + + /** + * @see UserInterface + */ + public function getRoles(): array + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + + return array_unique($roles); + } + + public function setRoles(array $roles): self + { + $this->roles = $roles; + + return $this; + } + + /** + * @see UserInterface + */ + public function getPassword(): string + { + return (string) $this->password; + } + + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } + + /** + * @see UserInterface + */ + public function getSalt() + { + // not needed when using the "bcrypt" algorithm in security.yaml + } + + /** + * @see UserInterface + */ + public function eraseCredentials() + { + // If you store any temporary, sensitive data on the user, clear it here + // $this->plainPassword = null; + } +} diff --git a/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntity/src/Security/UserProvider.php b/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntity/src/Security/UserProvider.php new file mode 100644 index 000000000..565d84e9b --- /dev/null +++ b/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntity/src/Security/UserProvider.php @@ -0,0 +1,33 @@ +setEmail($username) + ->setPassword($username); + } + + /** + * {@inheritdoc} + */ + public function refreshUser(UserInterface $user) + { + } + + /** + * {@inheritdoc} + */ + public function supportsClass($class) + { + } +} diff --git a/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntity/tests/SecurityControllerTest.php b/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntity/tests/SecurityControllerTest.php new file mode 100644 index 000000000..facc7b7a5 --- /dev/null +++ b/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntity/tests/SecurityControllerTest.php @@ -0,0 +1,46 @@ +getConstructor()->getParameters(); + $this->assertSame('router', $constructorParameters[0]->getName()); + + // assert authenticator is injected + $this->assertEquals(3, \count($constructorParameters)); + + $client = self::createClient(); + $crawler = $client->request('GET', '/login'); + + $this->assertEquals(200, $client->getResponse()->getStatusCode()); + + $form = $crawler->filter('form')->form(); + $form->setValues( + [ + 'email' => 'bar', + 'password' => 'foo', + ] + ); + $client->submit($form); + + $this->assertEquals(302, $client->getResponse()->getStatusCode()); + $client->followRedirect(); + $this->assertContains('Invalid credentials.', $client->getResponse()->getContent()); + $form->setValues( + [ + 'email' => 'test@symfony.com', + 'password' => 'test@symfony.com', + ] + ); + $client->submit($form); + + $this->assertContains('TODO: provide a valid redirect', $client->getResponse()->getContent()); + } +} diff --git a/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntityNoEncoder/config/packages/security.yaml b/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntityNoEncoder/config/packages/security.yaml new file mode 100644 index 000000000..930ad4e89 --- /dev/null +++ b/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntityNoEncoder/config/packages/security.yaml @@ -0,0 +1,26 @@ +security: + # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers + providers: + app_user_provider: + id: App\Security\UserProvider + + firewalls: + dev: + pattern: ^/(_(profiler|wdt)|css|images|js)/ + security: false + main: + anonymous: true + + # activate different ways to authenticate + + # http_basic: true + # https://symfony.com/doc/current/security.html#a-configuring-how-your-users-will-authenticate + + # form_login: true + # https://symfony.com/doc/current/security/form_login_setup.html + + # Easy way to control access for large sections of your site + # Note: Only the *first* access control that matches will be used + access_control: + # - { path: ^/admin, roles: ROLE_ADMIN } + # - { path: ^/profile, roles: ROLE_USER } diff --git a/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntityNoEncoder/src/Security/User.php b/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntityNoEncoder/src/Security/User.php new file mode 100644 index 000000000..e7bd9b1e3 --- /dev/null +++ b/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntityNoEncoder/src/Security/User.php @@ -0,0 +1,90 @@ +email; + } + + public function setEmail(string $email): self + { + $this->email = $email; + + return $this; + } + + /** + * A visual identifier that represents this user. + * + * @see UserInterface + */ + public function getUsername(): string + { + return (string) $this->email; + } + + /** + * @see UserInterface + */ + public function getRoles(): array + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + + return array_unique($roles); + } + + public function setRoles(array $roles): self + { + $this->roles = $roles; + + return $this; + } + + /** + * @see UserInterface + */ + public function getPassword(): string + { + return (string) $this->password; + } + + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } + + /** + * @see UserInterface + */ + public function getSalt() + { + // not needed when using the "bcrypt" algorithm in security.yaml + } + + /** + * @see UserInterface + */ + public function eraseCredentials() + { + // If you store any temporary, sensitive data on the user, clear it here + // $this->plainPassword = null; + } +} diff --git a/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntityNoEncoder/src/Security/UserProvider.php b/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntityNoEncoder/src/Security/UserProvider.php new file mode 100644 index 000000000..565d84e9b --- /dev/null +++ b/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntityNoEncoder/src/Security/UserProvider.php @@ -0,0 +1,33 @@ +setEmail($username) + ->setPassword($username); + } + + /** + * {@inheritdoc} + */ + public function refreshUser(UserInterface $user) + { + } + + /** + * {@inheritdoc} + */ + public function supportsClass($class) + { + } +} diff --git a/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntityNoEncoder/tests/SecurityControllerTest.php b/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntityNoEncoder/tests/SecurityControllerTest.php new file mode 100644 index 000000000..29362bf0d --- /dev/null +++ b/tests/fixtures/MakeAuthenticatorLoginFormUserNotEntityNoEncoder/tests/SecurityControllerTest.php @@ -0,0 +1,35 @@ +getConstructor()->getParameters(); + $this->assertSame('router', $constructorParameters[0]->getName()); + + // assert authenticator is *not* injected + $this->assertEquals(2, \count($constructorParameters)); + + $client = self::createClient(); + $crawler = $client->request('GET', '/login'); + + $this->assertEquals(200, $client->getResponse()->getStatusCode()); + + $form = $crawler->filter('form')->form(); + $form->setValues( + [ + 'email' => 'bar', + 'password' => 'foo', + ] + ); + $client->submit($form); + + $this->assertContains('TODO: check the credentials', $client->getResponse()->getContent()); + } +}