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

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