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

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