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

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