diff --git a/src/Maker/Security/AbstractSecurityMaker.php b/src/Maker/Security/AbstractSecurityMaker.php new file mode 100644 index 000000000..6d9268eac --- /dev/null +++ b/src/Maker/Security/AbstractSecurityMaker.php @@ -0,0 +1,83 @@ + + * + * 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\MakerBundle\ConsoleStyle; +use Symfony\Bundle\MakerBundle\DependencyBuilder; +use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException; +use Symfony\Bundle\MakerBundle\FileManager; +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\Util\YamlSourceManipulator; +use Symfony\Bundle\MakerBundle\Validator; +use Symfony\Bundle\SecurityBundle\SecurityBundle; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Process\Process; +use Symfony\Component\Yaml\Yaml; + +/** + * @author Jesse Rushlow + * + * @internal + */ +abstract class AbstractSecurityMaker extends AbstractMaker +{ + protected const SECURITY_CONFIG_PATH = 'config/packages/security.yaml'; + + protected YamlSourceManipulator $ysm; + protected string $securityControllerName; + protected string $firewallToUpdate; + protected string $userClass; + protected string $userNameField; + protected bool $willLogout; + + public function __construct( + protected FileManager $fileManager, + protected SecurityConfigUpdater $securityConfigUpdater, + protected SecurityControllerBuilder $securityControllerBuilder, + ) { + } + + public function configureDependencies(DependencyBuilder $dependencies): void + { + $dependencies->addClassDependency(SecurityBundle::class, 'security'); + $dependencies->addClassDependency(Process::class, 'process'); + $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->securityControllerName = $io->ask( + 'Choose a name for the controller class (e.g. ApiLoginController)', + 'ApiLoginController', + [Validator::class, 'validateClassName'] + ); + + $this->ysm = new YamlSourceManipulator($this->fileManager->getFileContents(self::SECURITY_CONFIG_PATH)); + $securityData = $this->ysm->getData(); + + $securityHelper = new InteractiveSecurityHelper(); + $this->firewallToUpdate = $securityHelper->guessFirewallName($io, $securityData); + $this->userClass = $securityHelper->guessUserClass($io, $securityData['security']['providers']); + $this->userNameField = $securityHelper->guessUserNameField($io, $this->userClass, $securityData['security']['providers']); + $this->willLogout = $io->confirm('Do you want to generate a \'/logout\' URL?'); + } +} diff --git a/src/Maker/Security/MakeFormLogin.php b/src/Maker/Security/MakeFormLogin.php index b20a62d1d..bb2e2a866 100644 --- a/src/Maker/Security/MakeFormLogin.php +++ b/src/Maker/Security/MakeFormLogin.php @@ -11,23 +11,15 @@ 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; @@ -35,7 +27,6 @@ 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. @@ -46,22 +37,8 @@ * * @internal */ -final class MakeFormLogin extends AbstractMaker +final class MakeFormLogin extends AbstractSecurityMaker { - 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'; @@ -79,46 +56,20 @@ public static function getCommandDescription(): string 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'); + parent::configureDependencies($dependencies); } 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)); - } + parent::interact($input, $io, $command); - $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. 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 @@ -130,25 +81,36 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen AuthenticationUtils::class, ]); - $controllerNameDetails = $generator->createClassNameDetails($this->controllerName, 'Controller\\', 'Controller'); + $controllerNameDetails = $generator->createClassNameDetails($this->securityControllerName, '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, - ] - ); + $controllerPath = $this->fileManager->getRelativePathForFutureClass($controllerNameDetails->getFullName()); - if ($this->willLogout) { - $manipulator = new ClassSourceManipulator($generator->getFileContentsForPendingOperation($controllerPath)); + $controllerExists = $this->fileManager->fileExists($controllerPath); + if (!$controllerExists) { + $generator->generateController( + $controllerNameDetails->getFullName(), + 'EmptyController.tpl.php', + [ + 'use_statements' => $useStatements, + 'controller_name' => $controllerNameDetails->getShortName(), + ] + ); + } + + $controllerSource = $controllerExists ? file_get_contents($controllerPath) : $generator->getFileContentsForPendingOperation($controllerPath); + + $manipulator = new ClassSourceManipulator($controllerSource); + + $this->securityControllerBuilder->addFormLoginMethod($manipulator, $templatePath); + + $securityData = $this->securityConfigUpdater->updateForFormLogin($this->ysm->getContents(), $this->firewallToUpdate, 'app_login', 'app_login'); + + if ($this->willLogout) { $this->securityControllerBuilder->addLogoutMethod($manipulator); - $generator->dumpFile($controllerPath, $manipulator->getSourceCode()); + $securityData = $this->securityConfigUpdater->updateForLogout($securityData, $this->firewallToUpdate); } $generator->generateTemplate( @@ -161,13 +123,8 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen ] ); - $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->dumpFile($controllerPath, $manipulator->getSourceCode()); $generator->writeChanges(); diff --git a/src/Maker/Security/MakeJsonLogin.php b/src/Maker/Security/MakeJsonLogin.php new file mode 100644 index 000000000..72a0a5ebe --- /dev/null +++ b/src/Maker/Security/MakeJsonLogin.php @@ -0,0 +1,111 @@ + + * + * 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 Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Bundle\MakerBundle\ConsoleStyle; +use Symfony\Bundle\MakerBundle\Generator; +use Symfony\Bundle\MakerBundle\InputConfiguration; +use Symfony\Bundle\MakerBundle\Util\ClassNameDetails; +use Symfony\Bundle\MakerBundle\Util\ClassSourceManipulator; +use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Http\Attribute\CurrentUser; + +/** + * Generate Form Login Security using SecurityBundle's Authenticator. + * + * @see https://symfony.com/doc/current/security.html#form-login + * + * @author Jesse Rushlow + * + * @internal + */ +final class MakeJsonLogin extends AbstractSecurityMaker +{ + public static function getCommandName(): string + { + return 'make:security:json-login'; + } + + public function configureCommand(Command $command, InputConfiguration $inputConfig): void + { + $command->setHelp(file_get_contents(\dirname(__DIR__, 2).'/Resources/help/security/MakeJsonLogin.txt')); + } + + public static function getCommandDescription(): string + { + return 'Generate the code needed for the json_login authenticator'; + } + + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void + { + $userClassDetails = new ClassNameDetails($this->userClass, ''); + + $useStatements = new UseStatementGenerator([ + $userClassDetails->getFullName(), + AbstractController::class, + JsonResponse::class, + Response::class, + Route::class, + CurrentUser::class, + ]); + + $controllerNameDetails = $generator->createClassNameDetails($this->securityControllerName, 'Controller\\', 'Controller'); + + $controllerPath = $this->fileManager->getRelativePathForFutureClass($controllerNameDetails->getFullName()); + + $controllerExists = $this->fileManager->fileExists($controllerPath); + + if (!$controllerExists) { + $generator->generateController( + $controllerNameDetails->getFullName(), + 'EmptyController.tpl.php', + [ + 'use_statements' => $useStatements, + 'controller_name' => $controllerNameDetails->getShortName(), + ] + ); + } + + $controllerSource = $controllerExists ? file_get_contents($controllerPath) : $generator->getFileContentsForPendingOperation($controllerPath); + + $manipulator = new ClassSourceManipulator($controllerSource); + + $this->securityControllerBuilder->addJsonLoginMethod($manipulator, $userClassDetails); + + $securityData = $this->securityConfigUpdater->updateForJsonLogin($this->ysm->getContents(), $this->firewallToUpdate, 'app_api_login'); + + if ($this->willLogout) { + $this->securityControllerBuilder->addLogoutMethod($manipulator); + + $securityData = $this->securityConfigUpdater->updateForLogout($securityData, $this->firewallToUpdate); + } + + $generator->dumpFile(self::SECURITY_CONFIG_PATH, $securityData); + $generator->dumpFile($controllerPath, $manipulator->getSourceCode()); + + $generator->writeChanges(); + + $this->writeSuccessMessage($io); + + $io->text([ + 'Next: Make a POST request to /api/login with a username and password to login.', + 'Then: The security system intercepts the requests and authenticates the user.', + sprintf('And Finally: The %s::apiLogin method creates and returns a JsonResponse.', $controllerNameDetails->getShortName()), + ]); + } +} diff --git a/src/Resources/config/makers.xml b/src/Resources/config/makers.xml index e3d785cb0..b25429ea7 100644 --- a/src/Resources/config/makers.xml +++ b/src/Resources/config/makers.xml @@ -147,5 +147,12 @@ + + + + + + + diff --git a/src/Resources/help/security/MakeJsonLogin.txt b/src/Resources/help/security/MakeJsonLogin.txt new file mode 100644 index 000000000..50c5f1852 --- /dev/null +++ b/src/Resources/help/security/MakeJsonLogin.txt @@ -0,0 +1,9 @@ +The %command.name% command generates a controller to allow users to +login using the json_login authenticator. + +The controller name, and logout ability can be customized by answering the +questions asked when running %command.name%. + +This will also update your security.yaml for the new authenticator. + +php %command.full_name% diff --git a/src/Resources/skeleton/EmptyController.tpl.php b/src/Resources/skeleton/EmptyController.tpl.php new file mode 100644 index 000000000..5ef48f740 --- /dev/null +++ b/src/Resources/skeleton/EmptyController.tpl.php @@ -0,0 +1,9 @@ + + +namespace ; + + + +class extends AbstractController +{ +} diff --git a/src/Resources/skeleton/security/formLogin/_LoginMethodBody.tpl.php b/src/Resources/skeleton/security/formLogin/_LoginMethodBody.tpl.php new file mode 100644 index 000000000..d68f25219 --- /dev/null +++ b/src/Resources/skeleton/security/formLogin/_LoginMethodBody.tpl.php @@ -0,0 +1,11 @@ +getLastAuthenticationError(); + +/** last username entered by the user */ +$lastUsername = $authenticationUtils->getLastUsername(); + +return $this->render($tpl_template_path.'/login.html.twig', [ + 'last_username' => $lastUsername, + 'error' => $error, +]); diff --git a/src/Resources/skeleton/security/jsonLogin/_ApiLoginMethodBody.tpl.php b/src/Resources/skeleton/security/jsonLogin/_ApiLoginMethodBody.tpl.php new file mode 100644 index 000000000..1cb6000d7 --- /dev/null +++ b/src/Resources/skeleton/security/jsonLogin/_ApiLoginMethodBody.tpl.php @@ -0,0 +1,6 @@ +json(['message' => 'missing credentials'], Response::HTTP_UNAUTHORIZED); +} + +return $this->json(['user' => $user->getUserIdentifier()]); diff --git a/src/Security/SecurityControllerBuilder.php b/src/Security/SecurityControllerBuilder.php index d28b79ad0..789aaaf8e 100644 --- a/src/Security/SecurityControllerBuilder.php +++ b/src/Security/SecurityControllerBuilder.php @@ -11,9 +11,16 @@ namespace Symfony\Bundle\MakerBundle\Security; +use PhpParser\Builder\Param; +use PhpParser\Node\Attribute; +use PhpParser\Node\Name; +use PhpParser\Node\NullableType; +use Symfony\Bundle\MakerBundle\Util\ClassNameDetails; use Symfony\Bundle\MakerBundle\Util\ClassSourceManipulator; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; /** @@ -32,7 +39,7 @@ public function addLoginMethod(ClassSourceManipulator $manipulator): void $manipulator->addUseStatementIfNecessary(AuthenticationUtils::class); $loginMethodBuilder->addParam( - (new \PhpParser\Builder\Param('authenticationUtils'))->setTypeHint('AuthenticationUtils') + (new Param('authenticationUtils'))->setTypeHint('AuthenticationUtils') ); $manipulator->addMethodBody($loginMethodBuilder, <<<'CODE' @@ -80,4 +87,45 @@ public function addLogoutMethod(ClassSourceManipulator $manipulator): void ); $manipulator->addMethodBuilder($logoutMethodBuilder); } + + public function addFormLoginMethod(ClassSourceManipulator $manipulator, string $loginTemplatePath): void + { + $methodBuilder = $manipulator->createMethodBuilder('login', Response::class, false); + $methodBuilder->addAttribute($manipulator->buildAttributeNode(Route::class, ['path' => '/login', 'name' => 'app_login'])); + + $this->addUseStatements($manipulator, [Route::class]); + + $methodBuilder->addParam((new Param('authenticationUtils'))->setType('AuthenticationUtils')); + + $contents = file_get_contents(\dirname(__DIR__).'/Resources/skeleton/security/formLogin/_LoginMethodBody.tpl.php'); + + $manipulator->addMethodBody($methodBuilder, $contents, ['template_path' => $loginTemplatePath]); + $manipulator->addMethodBuilder($methodBuilder); + } + + public function addJsonLoginMethod(ClassSourceManipulator $manipulator, ClassNameDetails $userClass): void + { + $methodBuilder = $manipulator->createMethodBuilder('apiLogin', JsonResponse::class, false); + + $methodBuilder->addAttribute($manipulator->buildAttributeNode(Route::class, ['path' => '/api/login', 'name' => 'app_api_login'])); + + $this->addUseStatements($manipulator, [Route::class, $userClass->getFullName(), CurrentUser::class]); + + $methodBuilder->addParam( + (new Param('user')) + ->setType(new NullableType($userClass->getShortName())) + ->addAttribute(new Attribute(new Name('CurrentUser'))) + ); + + $manipulator->addMethodBody($methodBuilder, file_get_contents(\dirname(__DIR__).'/Resources/skeleton/security/jsonLogin/_ApiLoginMethodBody.tpl.php')); + + $manipulator->addMethodBuilder($methodBuilder); + } + + private function addUseStatements(ClassSourceManipulator $manipulator, array $useStatements): void + { + foreach ($useStatements as $statement) { + $manipulator->addUseStatementIfNecessary($statement); + } + } } diff --git a/src/Util/ClassSourceManipulator.php b/src/Util/ClassSourceManipulator.php index 4db3191ca..44ab46379 100644 --- a/src/Util/ClassSourceManipulator.php +++ b/src/Util/ClassSourceManipulator.php @@ -25,8 +25,11 @@ use PhpParser\BuilderHelpers; use PhpParser\Lexer; use PhpParser\Node; +use PhpParser\Node\Expr\Variable; +use PhpParser\Node\Scalar\String_; use PhpParser\NodeTraverser; use PhpParser\NodeVisitor; +use PhpParser\NodeVisitorAbstract; use PhpParser\Parser; use Symfony\Bundle\MakerBundle\ConsoleStyle; use Symfony\Bundle\MakerBundle\Doctrine\BaseCollectionRelation; @@ -312,9 +315,16 @@ public function addMethodBuilder(Builder\Method $methodBuilder, array $params = $this->addMethod($methodBuilder->getNode()); } - public function addMethodBody(Builder\Method $methodBuilder, string $methodBody): void + /** + * @param string $methodBody Can be the contents of a *.tpl.php file or a simple string + * @param array $templateVars When the $methodBody contains template var's the need to be parsed, + * e.g. $tpl_user_class_name + * Pass in an array like ['user_class_name' => 'User'] + * omitting the tpl_ prefix. + */ + public function addMethodBody(Builder\Method $methodBuilder, string $methodBody, array $templateVars = []): void { - $nodes = $this->parser->parse($methodBody); + $nodes = $this->parseTemplateVariables($this->parser->parse($methodBody), $templateVars); $methodBuilder->addStmts($nodes); } @@ -1359,4 +1369,46 @@ private function sortOptionsByClassConstructorParameters(array $options, string return array_merge($sorted, $options); } + + /** + * Replaces variables that start with "tpl_" within a node tree with the + * corresponding values found in $templateVars. + * + * E.g. Parse a php-file.tpl.php like: + * $this->render($tpl_base_path.'/my-route') + * + * & Provide $templateVars with: + * ['base_path' => 'cool-controller'] + * + * will result in: + * + * $this->render('cool-controller/my-route') + * + * @param Node[] $nodes + * @param array $templateVars + */ + private function parseTemplateVariables(array $nodes, array $templateVars = []): array + { + if (empty($templateVars)) { + return $nodes; + } + + $traverser = new NodeTraverser(); + $traverser->addVisitor(new class($templateVars) extends NodeVisitorAbstract { + public function __construct(private array $vars) + { + } + + public function enterNode(Node $node) + { + if ($node instanceof Variable && str_starts_with($node->name, 'tpl_')) { + $name = str_replace('tpl_', '', $node->name); + + return new String_($this->vars[$name]); + } + } + }); + + return $traverser->traverse($nodes); + } } diff --git a/tests/Maker/Security/AbstractSecurityMakerTestCase.php b/tests/Maker/Security/AbstractSecurityMakerTestCase.php new file mode 100644 index 000000000..214f63a8b --- /dev/null +++ b/tests/Maker/Security/AbstractSecurityMakerTestCase.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\Maker\Security; + +use Symfony\Bundle\MakerBundle\Test\MakerTestCase; +use Symfony\Bundle\MakerBundle\Test\MakerTestRunner; + +/** + * @author Jesse Rushlow + * + * @internal + */ +abstract class AbstractSecurityMakerTestCase extends MakerTestCase +{ + protected function makeUser(MakerTestRunner $runner, string $identifier = 'email'): void + { + $runner->runConsole('make:user', [ + 'User', // Class Name + 'y', // Create as Entity + $identifier, // Property used to identify the user, + 'y', // Uses a password + ]); + } +} diff --git a/tests/Maker/Security/MakeFormLoginTest.php b/tests/Maker/Security/MakeFormLoginTest.php index a3c3bf4a7..31ac3972e 100644 --- a/tests/Maker/Security/MakeFormLoginTest.php +++ b/tests/Maker/Security/MakeFormLoginTest.php @@ -12,14 +12,13 @@ namespace Symfony\Bundle\MakerBundle\Tests\Maker\Security; use Symfony\Bundle\MakerBundle\Maker\Security\MakeFormLogin; -use Symfony\Bundle\MakerBundle\Test\MakerTestCase; use Symfony\Bundle\MakerBundle\Test\MakerTestRunner; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; /** * @author Jesse Rushlow */ -class MakeFormLoginTest extends MakerTestCase +final class MakeFormLoginTest extends AbstractSecurityMakerTestCase { protected function getMakerClass(): string { @@ -127,14 +126,4 @@ private function runLoginTest(MakerTestRunner $runner): void $runner->runTests(); } - - private function makeUser(MakerTestRunner $runner, string $identifier = 'email'): void - { - $runner->runConsole('make:user', [ - 'User', // Class Name - 'y', // Create as Entity - $identifier, // Property used to identify the user, - 'y', // Uses a password - ]); - } } diff --git a/tests/Maker/Security/MakeJsonLoginTest.php b/tests/Maker/Security/MakeJsonLoginTest.php new file mode 100644 index 000000000..8e6784bb7 --- /dev/null +++ b/tests/Maker/Security/MakeJsonLoginTest.php @@ -0,0 +1,140 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\Maker\Security; + +use Symfony\Bundle\MakerBundle\Maker\Security\MakeJsonLogin; +use Symfony\Bundle\MakerBundle\Test\MakerTestRunner; +use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; + +/** + * @author Jesse Rushlow + */ +final class MakeJsonLoginTest extends AbstractSecurityMakerTestCase +{ + protected function getMakerClass(): string + { + return MakeJsonLogin::class; + } + + public function getTestDetails(): \Generator + { + yield 'generates_json_login_using_defaults' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $this->makeUser($runner); + + $output = $runner->runMaker([ + 'SecurityController', // Controller Name + 'n', // Generate Logout + ]); + + $this->assertStringContainsString('Success', $output); + + $fixturePath = \dirname(__DIR__, 2).'/fixtures/security/make-json-login/expected'; + + $this->assertFileEquals($fixturePath.'/SecurityControllerWithoutLogout.php', $runner->getPath('src/Controller/SecurityController.php')); + + $securityConfig = $runner->readYaml('config/packages/security.yaml'); + + $this->assertSame('app_api_login', $securityConfig['security']['firewalls']['main']['json_login']['check_path']); + + $this->runLoginTest($runner); + }), + ]; + + yield 'generates_json_login_with_logout' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $this->makeUser($runner); + + $output = $runner->runMaker([ + 'SecurityController', // Controller Name + 'y', // Generate Logout + ]); + + $this->assertStringContainsString('Success', $output); + + $fixturePath = \dirname(__DIR__, 2).'/fixtures/security/make-json-login/expected'; + + $this->assertFileEquals($fixturePath.'/SecurityController.php', $runner->getPath('src/Controller/SecurityController.php')); + + $securityConfig = $runner->readYaml('config/packages/security.yaml'); + + $this->assertSame('app_api_login', $securityConfig['security']['firewalls']['main']['json_login']['check_path']); + }), + ]; + + yield 'generates_json_login_with_custom_class_name' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $this->makeUser($runner); + + $output = $runner->runMaker([ + 'LoginController', // Controller Name + 'y', // Generate Logout + ]); + + $this->assertStringContainsString('Success', $output); + + $fixturePath = \dirname(__DIR__, 2).'/fixtures/security/make-json-login/expected'; + + $this->assertFileEquals($fixturePath.'/LoginController.php', $runner->getPath('src/Controller/LoginController.php')); + + $securityConfig = $runner->readYaml('config/packages/security.yaml'); + + $this->assertSame('app_api_login', $securityConfig['security']['firewalls']['main']['json_login']['check_path']); + }), + ]; + + yield 'generates_json_login_using_existing_form_login_controller' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $this->makeUser($runner); + + $runner->copy('security/make-json-login/setup/', ''); + + $output = $runner->runMaker([ + 'SecurityController', // Controller Name + 'n', // Generate Logout + ]); + + $this->assertStringContainsString('Success', $output); + + $fixturePath = \dirname(__DIR__, 2).'/fixtures/security/make-json-login/expected'; + + $this->assertFileEquals($fixturePath.'/SecurityControllerWithFormLogin.php', $runner->getPath('src/Controller/SecurityController.php')); + + $securityConfig = $runner->readYaml('config/packages/security.yaml'); + + $this->assertSame('app_api_login', $securityConfig['security']['firewalls']['main']['json_login']['check_path']); + }), + ]; + } + + private function runLoginTest(MakerTestRunner $runner): void + { + $fixturePath = 'security/make-json-login/'; + + $runner->renderTemplateFile($fixturePath.'/LoginTest.php', 'tests/LoginTest.php', []); + + // plaintext password: needed for entities, simplifies overall + $runner->modifyYamlFile('config/packages/security.yaml', function (array $config) { + if (isset($config['when@test']['security']['password_hashers'])) { + $config['when@test']['security']['password_hashers'] = [PasswordAuthenticatedUserInterface::class => 'plaintext']; + + return $config; + } + + return $config; + }); + + $runner->configureDatabase(); + + $runner->runTests(); + } +} diff --git a/tests/fixtures/security/make-form-login/expected/LoginController.php b/tests/fixtures/security/make-form-login/expected/LoginController.php index 5355f737f..fde969c15 100644 --- a/tests/fixtures/security/make-form-login/expected/LoginController.php +++ b/tests/fixtures/security/make-form-login/expected/LoginController.php @@ -15,13 +15,10 @@ public function login(AuthenticationUtils $authenticationUtils): Response // get the login error if there is one $error = $authenticationUtils->getLastAuthenticationError(); - // last username entered by the user + /** last username entered by the user */ $lastUsername = $authenticationUtils->getLastUsername(); - return $this->render('login/login.html.twig', [ - 'last_username' => $lastUsername, - 'error' => $error, - ]); + return $this->render('login/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]); } #[Route(path: '/logout', name: 'app_logout')] diff --git a/tests/fixtures/security/make-form-login/expected/SecurityController.php b/tests/fixtures/security/make-form-login/expected/SecurityController.php index 5e4022d14..1342bdc92 100644 --- a/tests/fixtures/security/make-form-login/expected/SecurityController.php +++ b/tests/fixtures/security/make-form-login/expected/SecurityController.php @@ -15,13 +15,10 @@ public function login(AuthenticationUtils $authenticationUtils): Response // get the login error if there is one $error = $authenticationUtils->getLastAuthenticationError(); - // last username entered by the user + /** last username entered by the user */ $lastUsername = $authenticationUtils->getLastUsername(); - return $this->render('security/login.html.twig', [ - 'last_username' => $lastUsername, - 'error' => $error, - ]); + return $this->render('security/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]); } #[Route(path: '/logout', name: 'app_logout')] diff --git a/tests/fixtures/security/make-form-login/expected/SecurityControllerWithoutLogout.php b/tests/fixtures/security/make-form-login/expected/SecurityControllerWithoutLogout.php index b3640590a..6cb63350e 100644 --- a/tests/fixtures/security/make-form-login/expected/SecurityControllerWithoutLogout.php +++ b/tests/fixtures/security/make-form-login/expected/SecurityControllerWithoutLogout.php @@ -15,12 +15,9 @@ public function login(AuthenticationUtils $authenticationUtils): Response // get the login error if there is one $error = $authenticationUtils->getLastAuthenticationError(); - // last username entered by the user + /** last username entered by the user */ $lastUsername = $authenticationUtils->getLastUsername(); - return $this->render('security/login.html.twig', [ - 'last_username' => $lastUsername, - 'error' => $error, - ]); + return $this->render('security/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]); } } diff --git a/tests/fixtures/security/make-json-login/LoginTest.php b/tests/fixtures/security/make-json-login/LoginTest.php new file mode 100644 index 000000000..b95ebe251 --- /dev/null +++ b/tests/fixtures/security/make-json-login/LoginTest.php @@ -0,0 +1,38 @@ + + */ +class LoginTest extends WebTestCase +{ + public function testJsonLogin(): void + { + $client = static::createClient(); + $client->jsonRequest('POST', '/api/login'); + + self::assertResponseStatusCodeSame(401); + + $em = static::getContainer()->get('doctrine')->getManager(); + + $user = (new User()) + ->setEmail('jr@rushlow.dev') + ->setPassword('wordpass') + ; + + $em->persist($user); + $em->flush(); + + $client->jsonRequest('POST', '/api/login', [ + 'username' => 'jr@rushlow.dev', + 'password' => 'wordpass', + ]); + + self::assertResponseIsSuccessful(); + self::assertJsonStringEqualsJsonString('{"user":"jr@rushlow.dev"}', $client->getResponse()->getContent()); + } +} diff --git a/tests/fixtures/security/make-json-login/expected/LoginController.php b/tests/fixtures/security/make-json-login/expected/LoginController.php new file mode 100644 index 000000000..140b7b14d --- /dev/null +++ b/tests/fixtures/security/make-json-login/expected/LoginController.php @@ -0,0 +1,29 @@ +json(['message' => 'missing credentials'], Response::HTTP_UNAUTHORIZED); + } + + return $this->json(['user' => $user->getUserIdentifier()]); + } + + #[Route(path: '/logout', name: 'app_logout')] + public function logout(): void + { + throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.'); + } +} diff --git a/tests/fixtures/security/make-json-login/expected/SecurityController.php b/tests/fixtures/security/make-json-login/expected/SecurityController.php new file mode 100644 index 000000000..ef0e619a2 --- /dev/null +++ b/tests/fixtures/security/make-json-login/expected/SecurityController.php @@ -0,0 +1,29 @@ +json(['message' => 'missing credentials'], Response::HTTP_UNAUTHORIZED); + } + + return $this->json(['user' => $user->getUserIdentifier()]); + } + + #[Route(path: '/logout', name: 'app_logout')] + public function logout(): void + { + throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.'); + } +} diff --git a/tests/fixtures/security/make-json-login/expected/SecurityControllerWithFormLogin.php b/tests/fixtures/security/make-json-login/expected/SecurityControllerWithFormLogin.php new file mode 100644 index 000000000..837de5aba --- /dev/null +++ b/tests/fixtures/security/make-json-login/expected/SecurityControllerWithFormLogin.php @@ -0,0 +1,45 @@ +getLastAuthenticationError(); + + // last username entered by the user + $lastUsername = $authenticationUtils->getLastUsername(); + + return $this->render('security/login.html.twig', [ + 'last_username' => $lastUsername, + 'error' => $error, + ]); + } + + #[Route(path: '/logout', name: 'app_logout')] + public function logout(): void + { + throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.'); + } + + #[Route(path: '/api/login', name: 'app_api_login')] + public function apiLogin(#[CurrentUser] ?User $user): JsonResponse + { + if (null === $user) { + return $this->json(['message' => 'missing credentials'], Response::HTTP_UNAUTHORIZED); + } + + return $this->json(['user' => $user->getUserIdentifier()]); + } +} diff --git a/tests/fixtures/security/make-json-login/expected/SecurityControllerWithoutLogout.php b/tests/fixtures/security/make-json-login/expected/SecurityControllerWithoutLogout.php new file mode 100644 index 000000000..bc82a16bb --- /dev/null +++ b/tests/fixtures/security/make-json-login/expected/SecurityControllerWithoutLogout.php @@ -0,0 +1,23 @@ +json(['message' => 'missing credentials'], Response::HTTP_UNAUTHORIZED); + } + + return $this->json(['user' => $user->getUserIdentifier()]); + } +} diff --git a/tests/fixtures/security/make-json-login/setup/src/Controller/SecurityController.php b/tests/fixtures/security/make-json-login/setup/src/Controller/SecurityController.php new file mode 100644 index 000000000..5e4022d14 --- /dev/null +++ b/tests/fixtures/security/make-json-login/setup/src/Controller/SecurityController.php @@ -0,0 +1,32 @@ +getLastAuthenticationError(); + + // last username entered by the user + $lastUsername = $authenticationUtils->getLastUsername(); + + return $this->render('security/login.html.twig', [ + 'last_username' => $lastUsername, + 'error' => $error, + ]); + } + + #[Route(path: '/logout', name: 'app_logout')] + public function logout(): void + { + throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.'); + } +}