vendor/symfony/maker-bundle/src/Maker/MakeAuthenticator.php line 408

Open in your IDE?
  1. <?php
  2. /*
  3. * This file is part of the Symfony MakerBundle package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Bundle\MakerBundle\Maker;
  11. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  12. use Symfony\Bundle\MakerBundle\ConsoleStyle;
  13. use Symfony\Bundle\MakerBundle\DependencyBuilder;
  14. use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper;
  15. use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
  16. use Symfony\Bundle\MakerBundle\FileManager;
  17. use Symfony\Bundle\MakerBundle\Generator;
  18. use Symfony\Bundle\MakerBundle\InputConfiguration;
  19. use Symfony\Bundle\MakerBundle\Security\InteractiveSecurityHelper;
  20. use Symfony\Bundle\MakerBundle\Security\SecurityConfigUpdater;
  21. use Symfony\Bundle\MakerBundle\Security\SecurityControllerBuilder;
  22. use Symfony\Bundle\MakerBundle\Str;
  23. use Symfony\Bundle\MakerBundle\Util\ClassSourceManipulator;
  24. use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
  25. use Symfony\Bundle\MakerBundle\Util\YamlManipulationFailedException;
  26. use Symfony\Bundle\MakerBundle\Util\YamlSourceManipulator;
  27. use Symfony\Bundle\MakerBundle\Validator;
  28. use Symfony\Bundle\SecurityBundle\SecurityBundle;
  29. use Symfony\Bundle\TwigBundle\TwigBundle;
  30. use Symfony\Component\Console\Command\Command;
  31. use Symfony\Component\Console\Input\InputArgument;
  32. use Symfony\Component\Console\Input\InputInterface;
  33. use Symfony\Component\Console\Input\InputOption;
  34. use Symfony\Component\Console\Question\Question;
  35. use Symfony\Component\HttpFoundation\RedirectResponse;
  36. use Symfony\Component\HttpFoundation\Request;
  37. use Symfony\Component\HttpFoundation\Response;
  38. use Symfony\Component\Routing\Annotation\Route;
  39. use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
  40. use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
  41. use Symfony\Component\Security\Core\Exception\AuthenticationException;
  42. use Symfony\Component\Security\Core\Security;
  43. use Symfony\Component\Security\Guard\AuthenticatorInterface as GuardAuthenticatorInterface;
  44. use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
  45. use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
  46. use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
  47. use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
  48. use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
  49. use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
  50. use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
  51. use Symfony\Component\Security\Http\Util\TargetPathTrait;
  52. use Symfony\Component\Yaml\Yaml;
  53. /**
  54. * @author Ryan Weaver <ryan@symfonycasts.com>
  55. * @author Jesse Rushlow <jr@rushlow.dev>
  56. *
  57. * @internal
  58. */
  59. final class MakeAuthenticator extends AbstractMaker
  60. {
  61. private const AUTH_TYPE_EMPTY_AUTHENTICATOR = 'empty-authenticator';
  62. private const AUTH_TYPE_FORM_LOGIN = 'form-login';
  63. private $fileManager;
  64. private $configUpdater;
  65. private $generator;
  66. private $doctrineHelper;
  67. private $securityControllerBuilder;
  68. public function __construct(FileManager $fileManager, SecurityConfigUpdater $configUpdater, Generator $generator, DoctrineHelper $doctrineHelper, SecurityControllerBuilder $securityControllerBuilder)
  69. {
  70. $this->fileManager = $fileManager;
  71. $this->configUpdater = $configUpdater;
  72. $this->generator = $generator;
  73. $this->doctrineHelper = $doctrineHelper;
  74. $this->securityControllerBuilder = $securityControllerBuilder;
  75. }
  76. public static function getCommandName(): string
  77. {
  78. return 'make:auth';
  79. }
  80. public static function getCommandDescription(): string
  81. {
  82. return 'Creates a Guard authenticator of different flavors';
  83. }
  84. public function configureCommand(Command $command, InputConfiguration $inputConfig): void
  85. {
  86. $command
  87. ->setHelp(file_get_contents(__DIR__.'/../Resources/help/MakeAuth.txt'));
  88. }
  89. public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
  90. {
  91. if (!$this->fileManager->fileExists($path = 'config/packages/security.yaml')) {
  92. 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.');
  93. }
  94. $manipulator = new YamlSourceManipulator($this->fileManager->getFileContents($path));
  95. $securityData = $manipulator->getData();
  96. // @legacy - Can be removed when Symfony 5.4 support is dropped
  97. if (interface_exists(GuardAuthenticatorInterface::class) && !($securityData['security']['enable_authenticator_manager'] ?? false)) {
  98. throw new RuntimeCommandException('MakerBundle only supports the new authenticator based security system. See https://symfony.com/doc/current/security.html');
  99. }
  100. // authenticator type
  101. $authenticatorTypeValues = [
  102. 'Empty authenticator' => self::AUTH_TYPE_EMPTY_AUTHENTICATOR,
  103. 'Login form authenticator' => self::AUTH_TYPE_FORM_LOGIN,
  104. ];
  105. $command->addArgument('authenticator-type', InputArgument::REQUIRED);
  106. $authenticatorType = $io->choice(
  107. 'What style of authentication do you want?',
  108. array_keys($authenticatorTypeValues),
  109. key($authenticatorTypeValues)
  110. );
  111. $input->setArgument(
  112. 'authenticator-type',
  113. $authenticatorTypeValues[$authenticatorType]
  114. );
  115. if (self::AUTH_TYPE_FORM_LOGIN === $input->getArgument('authenticator-type')) {
  116. $neededDependencies = [TwigBundle::class => 'twig'];
  117. $missingPackagesMessage = $this->addDependencies($neededDependencies, 'Twig must be installed to display the login form.');
  118. if ($missingPackagesMessage) {
  119. throw new RuntimeCommandException($missingPackagesMessage);
  120. }
  121. if (!isset($securityData['security']['providers']) || !$securityData['security']['providers']) {
  122. throw new RuntimeCommandException('To generate a form login authentication, you must configure at least one entry under "providers" in "security.yaml".');
  123. }
  124. }
  125. // authenticator class
  126. $command->addArgument('authenticator-class', InputArgument::REQUIRED);
  127. $questionAuthenticatorClass = new Question('The class name of the authenticator to create (e.g. <fg=yellow>AppCustomAuthenticator</>)');
  128. $questionAuthenticatorClass->setValidator(
  129. function ($answer) {
  130. Validator::notBlank($answer);
  131. return Validator::classDoesNotExist(
  132. $this->generator->createClassNameDetails($answer, 'Security\\', 'Authenticator')->getFullName()
  133. );
  134. }
  135. );
  136. $input->setArgument('authenticator-class', $io->askQuestion($questionAuthenticatorClass));
  137. $interactiveSecurityHelper = new InteractiveSecurityHelper();
  138. $command->addOption('firewall-name', null, InputOption::VALUE_OPTIONAL);
  139. $input->setOption('firewall-name', $firewallName = $interactiveSecurityHelper->guessFirewallName($io, $securityData));
  140. $command->addOption('entry-point', null, InputOption::VALUE_OPTIONAL);
  141. if (self::AUTH_TYPE_FORM_LOGIN === $input->getArgument('authenticator-type')) {
  142. $command->addArgument('controller-class', InputArgument::REQUIRED);
  143. $input->setArgument(
  144. 'controller-class',
  145. $io->ask(
  146. 'Choose a name for the controller class (e.g. <fg=yellow>SecurityController</>)',
  147. 'SecurityController',
  148. [Validator::class, 'validateClassName']
  149. )
  150. );
  151. $command->addArgument('user-class', InputArgument::REQUIRED);
  152. $input->setArgument(
  153. 'user-class',
  154. $userClass = $interactiveSecurityHelper->guessUserClass($io, $securityData['security']['providers'])
  155. );
  156. $command->addArgument('username-field', InputArgument::REQUIRED);
  157. $input->setArgument(
  158. 'username-field',
  159. $interactiveSecurityHelper->guessUserNameField($io, $userClass, $securityData['security']['providers'])
  160. );
  161. $command->addArgument('logout-setup', InputArgument::REQUIRED);
  162. $input->setArgument(
  163. 'logout-setup',
  164. $io->confirm(
  165. 'Do you want to generate a \'/logout\' URL?',
  166. true
  167. )
  168. );
  169. }
  170. }
  171. public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
  172. {
  173. $manipulator = new YamlSourceManipulator($this->fileManager->getFileContents('config/packages/security.yaml'));
  174. $securityData = $manipulator->getData();
  175. $this->generateAuthenticatorClass(
  176. $securityData,
  177. $input->getArgument('authenticator-type'),
  178. $input->getArgument('authenticator-class'),
  179. $input->hasArgument('user-class') ? $input->getArgument('user-class') : null,
  180. $input->hasArgument('username-field') ? $input->getArgument('username-field') : null
  181. );
  182. // update security.yaml with guard config
  183. $securityYamlUpdated = false;
  184. $entryPoint = $input->getOption('entry-point');
  185. if (self::AUTH_TYPE_FORM_LOGIN !== $input->getArgument('authenticator-type')) {
  186. $entryPoint = false;
  187. }
  188. try {
  189. $newYaml = $this->configUpdater->updateForAuthenticator(
  190. $this->fileManager->getFileContents($path = 'config/packages/security.yaml'),
  191. $input->getOption('firewall-name'),
  192. $entryPoint,
  193. $input->getArgument('authenticator-class'),
  194. $input->hasArgument('logout-setup') ? $input->getArgument('logout-setup') : false
  195. );
  196. $generator->dumpFile($path, $newYaml);
  197. $securityYamlUpdated = true;
  198. } catch (YamlManipulationFailedException $e) {
  199. }
  200. if (self::AUTH_TYPE_FORM_LOGIN === $input->getArgument('authenticator-type')) {
  201. $this->generateFormLoginFiles(
  202. $input->getArgument('controller-class'),
  203. $input->getArgument('username-field'),
  204. $input->getArgument('logout-setup')
  205. );
  206. }
  207. $generator->writeChanges();
  208. $this->writeSuccessMessage($io);
  209. $io->text(
  210. $this->generateNextMessage(
  211. $securityYamlUpdated,
  212. $input->getArgument('authenticator-type'),
  213. $input->getArgument('authenticator-class'),
  214. $securityData,
  215. $input->hasArgument('user-class') ? $input->getArgument('user-class') : null,
  216. $input->hasArgument('logout-setup') ? $input->getArgument('logout-setup') : false
  217. )
  218. );
  219. }
  220. private function generateAuthenticatorClass(array $securityData, string $authenticatorType, string $authenticatorClass, $userClass, $userNameField): void
  221. {
  222. $useStatements = new UseStatementGenerator([
  223. Request::class,
  224. Response::class,
  225. TokenInterface::class,
  226. Passport::class,
  227. ]);
  228. // generate authenticator class
  229. if (self::AUTH_TYPE_EMPTY_AUTHENTICATOR === $authenticatorType) {
  230. $useStatements->addUseStatement([
  231. AuthenticationException::class,
  232. AbstractAuthenticator::class,
  233. ]);
  234. $this->generator->generateClass(
  235. $authenticatorClass,
  236. 'authenticator/EmptyAuthenticator.tpl.php',
  237. ['use_statements' => $useStatements]
  238. );
  239. return;
  240. }
  241. $useStatements->addUseStatement([
  242. RedirectResponse::class,
  243. UrlGeneratorInterface::class,
  244. Security::class,
  245. AbstractLoginFormAuthenticator::class,
  246. CsrfTokenBadge::class,
  247. UserBadge::class,
  248. PasswordCredentials::class,
  249. TargetPathTrait::class,
  250. ]);
  251. $userClassNameDetails = $this->generator->createClassNameDetails(
  252. '\\'.$userClass,
  253. 'Entity\\'
  254. );
  255. $this->generator->generateClass(
  256. $authenticatorClass,
  257. 'authenticator/LoginFormAuthenticator.tpl.php',
  258. [
  259. 'use_statements' => $useStatements,
  260. 'user_fully_qualified_class_name' => trim($userClassNameDetails->getFullName(), '\\'),
  261. 'user_class_name' => $userClassNameDetails->getShortName(),
  262. 'username_field' => $userNameField,
  263. 'username_field_label' => Str::asHumanWords($userNameField),
  264. 'username_field_var' => Str::asLowerCamelCase($userNameField),
  265. 'user_needs_encoder' => $this->userClassHasEncoder($securityData, $userClass),
  266. 'user_is_entity' => $this->doctrineHelper->isClassAMappedEntity($userClass),
  267. ]
  268. );
  269. }
  270. private function generateFormLoginFiles(string $controllerClass, string $userNameField, bool $logoutSetup): void
  271. {
  272. $controllerClassNameDetails = $this->generator->createClassNameDetails(
  273. $controllerClass,
  274. 'Controller\\',
  275. 'Controller'
  276. );
  277. if (!class_exists($controllerClassNameDetails->getFullName())) {
  278. $useStatements = new UseStatementGenerator([
  279. AbstractController::class,
  280. Route::class,
  281. AuthenticationUtils::class,
  282. ]);
  283. $controllerPath = $this->generator->generateController(
  284. $controllerClassNameDetails->getFullName(),
  285. 'authenticator/EmptySecurityController.tpl.php',
  286. ['use_statements' => $useStatements]
  287. );
  288. $controllerSourceCode = $this->generator->getFileContentsForPendingOperation($controllerPath);
  289. } else {
  290. $controllerPath = $this->fileManager->getRelativePathForFutureClass($controllerClassNameDetails->getFullName());
  291. $controllerSourceCode = $this->fileManager->getFileContents($controllerPath);
  292. }
  293. if (method_exists($controllerClassNameDetails->getFullName(), 'login')) {
  294. throw new RuntimeCommandException(sprintf('Method "login" already exists on class %s', $controllerClassNameDetails->getFullName()));
  295. }
  296. $manipulator = new ClassSourceManipulator($controllerSourceCode, true);
  297. $this->securityControllerBuilder->addLoginMethod($manipulator);
  298. if ($logoutSetup) {
  299. $this->securityControllerBuilder->addLogoutMethod($manipulator);
  300. }
  301. $this->generator->dumpFile($controllerPath, $manipulator->getSourceCode());
  302. // create login form template
  303. $this->generator->generateTemplate(
  304. 'security/login.html.twig',
  305. 'authenticator/login_form.tpl.php',
  306. [
  307. 'username_field' => $userNameField,
  308. 'username_is_email' => false !== stripos($userNameField, 'email'),
  309. 'username_label' => ucfirst(Str::asHumanWords($userNameField)),
  310. 'logout_setup' => $logoutSetup,
  311. ]
  312. );
  313. }
  314. private function generateNextMessage(bool $securityYamlUpdated, string $authenticatorType, string $authenticatorClass, array $securityData, $userClass, bool $logoutSetup): array
  315. {
  316. $nextTexts = ['Next:'];
  317. $nextTexts[] = '- Customize your new authenticator.';
  318. if (!$securityYamlUpdated) {
  319. $yamlExample = $this->configUpdater->updateForAuthenticator(
  320. 'security: {}',
  321. 'main',
  322. null,
  323. $authenticatorClass,
  324. $logoutSetup
  325. );
  326. $nextTexts[] = "- Your <info>security.yaml</info> could not be updated automatically. You'll need to add the following config manually:\n\n".$yamlExample;
  327. }
  328. if (self::AUTH_TYPE_FORM_LOGIN === $authenticatorType) {
  329. $nextTexts[] = sprintf('- Finish the redirect "TODO" in the <info>%s::onAuthenticationSuccess()</info> method.', $authenticatorClass);
  330. if (!$this->doctrineHelper->isClassAMappedEntity($userClass)) {
  331. $nextTexts[] = sprintf('- Review <info>%s::getUser()</info> to make sure it matches your needs.', $authenticatorClass);
  332. }
  333. $nextTexts[] = '- Review & adapt the login template: <info>'.$this->fileManager->getPathForTemplate('security/login.html.twig').'</info>.';
  334. }
  335. return $nextTexts;
  336. }
  337. private function userClassHasEncoder(array $securityData, string $userClass): bool
  338. {
  339. $userNeedsEncoder = false;
  340. $hashersData = $securityData['security']['encoders'] ?? $securityData['security']['encoders'] ?? [];
  341. foreach ($hashersData as $userClassWithEncoder => $encoder) {
  342. if ($userClass === $userClassWithEncoder || is_subclass_of($userClass, $userClassWithEncoder) || class_implements($userClass, $userClassWithEncoder)) {
  343. $userNeedsEncoder = true;
  344. }
  345. }
  346. return $userNeedsEncoder;
  347. }
  348. public function configureDependencies(DependencyBuilder $dependencies, InputInterface $input = null): void
  349. {
  350. $dependencies->addClassDependency(
  351. SecurityBundle::class,
  352. 'security'
  353. );
  354. // needed to update the YAML files
  355. $dependencies->addClassDependency(
  356. Yaml::class,
  357. 'yaml'
  358. );
  359. }
  360. }