From ed3465b82ae7748df129429b66e2e061507cd3ed Mon Sep 17 00:00:00 2001 From: Jesse Rushlow <40327885+jrushlow@users.noreply.github.com> Date: Wed, 24 Apr 2024 05:01:22 -0400 Subject: [PATCH] feature #1522 [make:security:custom] create a custom authenticator --- .../Security/MakeCustomAuthenticator.php | 144 ++++++++++++++++++ src/Resources/config/makers.xml | 6 + src/Resources/help/security/MakeCustom.txt | 8 + .../security/custom/Authenticator.tpl.php | 67 ++++++++ .../Security/MakeCustomAuthenticatorTest.php | 48 ++++++ .../expected/FixtureAuthenticator.php | 76 +++++++++ 6 files changed, 349 insertions(+) create mode 100644 src/Maker/Security/MakeCustomAuthenticator.php create mode 100644 src/Resources/help/security/MakeCustom.txt create mode 100644 src/Resources/skeleton/security/custom/Authenticator.tpl.php create mode 100644 tests/Maker/Security/MakeCustomAuthenticatorTest.php create mode 100644 tests/fixtures/security/make-custom-authenticator/expected/FixtureAuthenticator.php diff --git a/src/Maker/Security/MakeCustomAuthenticator.php b/src/Maker/Security/MakeCustomAuthenticator.php new file mode 100644 index 000000000..8b66d9c06 --- /dev/null +++ b/src/Maker/Security/MakeCustomAuthenticator.php @@ -0,0 +1,144 @@ + + * + * 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\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\Maker\Common\InstallDependencyTrait; +use Symfony\Bundle\MakerBundle\Util\ClassNameDetails; +use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator; +use Symfony\Bundle\MakerBundle\Util\YamlSourceManipulator; +use Symfony\Bundle\MakerBundle\Validator; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; +use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; + +/** + * @author Jesse Rushlow + * + * @internal + */ +final class MakeCustomAuthenticator extends AbstractMaker +{ + use InstallDependencyTrait; + + private const SECURITY_CONFIG_PATH = 'config/packages/security.yaml'; + + private ClassNameDetails $authenticatorClassName; + + public function __construct( + private FileManager $fileManager, + private Generator $generator, + ) { + } + + public static function getCommandName(): string + { + return 'make:security:custom'; + } + + public static function getCommandDescription(): string + { + return 'Create a custom security authenticator.'; + } + + public function configureCommand(Command $command, InputConfiguration $inputConfig): void + { + $command + ->setHelp(file_get_contents(__DIR__.'/../../Resources/help/security/MakeCustom.txt')) + ; + } + + public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void + { + $this->installDependencyIfNeeded( + io: $io, + expectedClassToExist: AbstractAuthenticator::class, + composerPackage: 'symfony/security-bundle' + ); + + 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)); + } + + $name = $io->ask( + question: 'What is the class name of the authenticator (e.g. CustomAuthenticator)', + validator: static function (mixed $answer) { + return Validator::notBlank($answer); + } + ); + + $this->authenticatorClassName = $this->generator->createClassNameDetails( + name: $name, + namespacePrefix: 'Security\\', + suffix: 'Authenticator' + ); + } + + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void + { + // Configure security to use custom authenticator + $securityConfig = ($ysm = new YamlSourceManipulator( + $this->fileManager->getFileContents(self::SECURITY_CONFIG_PATH) + ))->getData(); + + $securityConfig['security']['firewalls']['main']['custom_authenticators'] = [$this->authenticatorClassName->getFullName()]; + + $ysm->setData($securityConfig); + $generator->dumpFile(self::SECURITY_CONFIG_PATH, $ysm->getContents()); + + // Generate the new authenticator + $useStatements = new UseStatementGenerator([ + Request::class, + Response::class, + TokenInterface::class, + AuthenticationException::class, + AbstractAuthenticator::class, + Passport::class, + JsonResponse::class, + UserBadge::class, + CustomUserMessageAuthenticationException::class, + SelfValidatingPassport::class, + ]); + + $generator->generateClass( + className: $this->authenticatorClassName->getFullName(), + templateName: 'security/custom/Authenticator.tpl.php', + variables: [ + 'use_statements' => $useStatements, + 'class_short_name' => $this->authenticatorClassName->getShortName(), + ] + ); + + $generator->writeChanges(); + + $this->writeSuccessMessage($io); + } + + public function configureDependencies(DependencyBuilder $dependencies): void + { + } +} diff --git a/src/Resources/config/makers.xml b/src/Resources/config/makers.xml index 02c635941..3d913d1ac 100644 --- a/src/Resources/config/makers.xml +++ b/src/Resources/config/makers.xml @@ -159,6 +159,12 @@ + + + + + + diff --git a/src/Resources/help/security/MakeCustom.txt b/src/Resources/help/security/MakeCustom.txt new file mode 100644 index 000000000..2c396e6ee --- /dev/null +++ b/src/Resources/help/security/MakeCustom.txt @@ -0,0 +1,8 @@ +The %command.name% command generates a simple custom authenticator +class based off the example provided in: + +https://symfony.com/doc/current/security/custom_authenticator.html + +This will also update your security.yaml for the new custom authenticator. + +php %command.full_name% diff --git a/src/Resources/skeleton/security/custom/Authenticator.tpl.php b/src/Resources/skeleton/security/custom/Authenticator.tpl.php new file mode 100644 index 000000000..dacec9b52 --- /dev/null +++ b/src/Resources/skeleton/security/custom/Authenticator.tpl.php @@ -0,0 +1,67 @@ + + +namespace ; + + + +/** +* @see https://symfony.com/doc/current/security/custom_authenticator.html +*/ +class extends AbstractAuthenticator +{ + /** + * Called on every request to decide if this authenticator should be + * used for the request. Returning `false` will cause this authenticator + * to be skipped. + */ + public function supports(Request $request): ?bool + { + // return $request->headers->has('X-AUTH-TOKEN'); + } + + public function authenticate(Request $request): Passport + { + // $apiToken = $request->headers->get('X-AUTH-TOKEN'); + // if (null === $apiToken) { + // The token header was empty, authentication fails with HTTP Status + // Code 401 "Unauthorized" + // throw new CustomUserMessageAuthenticationException('No API token provided'); + // } + + // implement your own logic to get the user identifier from `$apiToken` + // e.g. by looking up a user in the database using its API key + // $userIdentifier = /** ... */; + + // return new SelfValidatingPassport(new UserBadge($userIdentifier)); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + // on success, let the request continue + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $data = [ + // you may want to customize or obfuscate the message first + 'message' => strtr($exception->getMessageKey(), $exception->getMessageData()) + + // or to translate this message + // $this->translator->trans($exception->getMessageKey(), $exception->getMessageData()) + ]; + + return new JsonResponse($data, Response::HTTP_UNAUTHORIZED); + } + + // public function start(Request $request, AuthenticationException $authException = null): Response + // { + // /* + // * If you would like this class to control what happens when an anonymous user accesses a + // * protected page (e.g. redirect to /login), uncomment this method and make this class + // * implement Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface. + // * + // * For more details, see https://symfony.com/doc/current/security/experimental_authenticators.html#configuring-the-authentication-entry-point + // */ + // } +} diff --git a/tests/Maker/Security/MakeCustomAuthenticatorTest.php b/tests/Maker/Security/MakeCustomAuthenticatorTest.php new file mode 100644 index 000000000..a8e39748f --- /dev/null +++ b/tests/Maker/Security/MakeCustomAuthenticatorTest.php @@ -0,0 +1,48 @@ + + * + * 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\MakeCustomAuthenticator; +use Symfony\Bundle\MakerBundle\Test\MakerTestCase; +use Symfony\Bundle\MakerBundle\Test\MakerTestRunner; + +/** + * @author Jesse Rushlow + */ +class MakeCustomAuthenticatorTest extends MakerTestCase +{ + protected function getMakerClass(): string + { + return MakeCustomAuthenticator::class; + } + + public function getTestDetails(): \Generator + { + yield 'generates_custom_authenticator' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $output = $runner->runMaker([ + 'FixtureAuthenticator', // Authenticator Name + ]); + + $this->assertStringContainsString('Success', $output); + $fixturePath = \dirname(__DIR__, 2).'/fixtures/security/make-custom-authenticator/expected'; + + $this->assertFileEquals($fixturePath.'/FixtureAuthenticator.php', $runner->getPath('src/Security/FixtureAuthenticator.php')); + + $securityConfig = $runner->readYaml('config/packages/security.yaml'); + + self::assertArrayHasKey('custom_authenticators', $mainFirewall = $securityConfig['security']['firewalls']['main']); + self::assertSame(['App\Security\FixtureAuthenticator'], $mainFirewall['custom_authenticators']); + }), + ]; + } +} diff --git a/tests/fixtures/security/make-custom-authenticator/expected/FixtureAuthenticator.php b/tests/fixtures/security/make-custom-authenticator/expected/FixtureAuthenticator.php new file mode 100644 index 000000000..c9edf8402 --- /dev/null +++ b/tests/fixtures/security/make-custom-authenticator/expected/FixtureAuthenticator.php @@ -0,0 +1,76 @@ +headers->has('X-AUTH-TOKEN'); + } + + public function authenticate(Request $request): Passport + { + // $apiToken = $request->headers->get('X-AUTH-TOKEN'); + // if (null === $apiToken) { + // The token header was empty, authentication fails with HTTP Status + // Code 401 "Unauthorized" + // throw new CustomUserMessageAuthenticationException('No API token provided'); + // } + + // implement your own logic to get the user identifier from `$apiToken` + // e.g. by looking up a user in the database using its API key + // $userIdentifier = /** ... */; + + // return new SelfValidatingPassport(new UserBadge($userIdentifier)); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + // on success, let the request continue + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $data = [ + // you may want to customize or obfuscate the message first + 'message' => strtr($exception->getMessageKey(), $exception->getMessageData()), + + // or to translate this message + // $this->translator->trans($exception->getMessageKey(), $exception->getMessageData()) + ]; + + return new JsonResponse($data, Response::HTTP_UNAUTHORIZED); + } + + // public function start(Request $request, AuthenticationException $authException = null): Response + // { + // /* + // * If you would like this class to control what happens when an anonymous user accesses a + // * protected page (e.g. redirect to /login), uncomment this method and make this class + // * implement Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface. + // * + // * For more details, see https://symfony.com/doc/current/security/experimental_authenticators.html#configuring-the-authentication-entry-point + // */ + // } +}