Skip to content

Commit

Permalink
feature #1522 [make:security:custom] create a custom authenticator
Browse files Browse the repository at this point in the history
  • Loading branch information
jrushlow authored Apr 24, 2024
1 parent e7ea13d commit ed3465b
Show file tree
Hide file tree
Showing 6 changed files with 349 additions and 0 deletions.
144 changes: 144 additions & 0 deletions src/Maker/Security/MakeCustomAuthenticator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php

/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <jr@rushlow.dev>
*
* @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. <fg=yellow>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
{
}
}
6 changes: 6 additions & 0 deletions src/Resources/config/makers.xml
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@
<tag name="maker.command" />
</service>

<service id="maker.maker.make_custom_authenticator" class="Symfony\Bundle\MakerBundle\Maker\Security\MakeCustomAuthenticator">
<argument type="service" id="maker.file_manager" />
<argument type="service" id="maker.generator" />
<tag name="maker.command" />
</service>

<service id="maker.maker.make_webhook" class="Symfony\Bundle\MakerBundle\Maker\MakeWebhook">
<argument type="service" id="maker.file_manager" />
<argument type="service" id="maker.generator" />
Expand Down
8 changes: 8 additions & 0 deletions src/Resources/help/security/MakeCustom.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
The <info>%command.name%</info> command generates a simple custom authenticator
class based off the example provided in:

<href=https://symfony.com/doc/current/security/custom_authenticator.html>https://symfony.com/doc/current/security/custom_authenticator.html</>

This will also update your <info>security.yaml</info> for the new custom authenticator.

<info>php %command.full_name%</info>
67 changes: 67 additions & 0 deletions src/Resources/skeleton/security/custom/Authenticator.tpl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?= "<?php\n" ?>

namespace <?= $namespace; ?>;

<?= $use_statements; ?>

/**
* @see https://symfony.com/doc/current/security/custom_authenticator.html
*/
class <?= $class_short_name ?> 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
// */
// }
}
48 changes: 48 additions & 0 deletions tests/Maker/Security/MakeCustomAuthenticatorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <jr@rushlow.dev>
*/
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']);
}),
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

namespace App\Security;

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;

/**
* @see https://symfony.com/doc/current/security/custom_authenticator.html
*/
class FixtureAuthenticator 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
// */
// }
}

0 comments on commit ed3465b

Please sign in to comment.