From 95edc806a1f2623f245a23cb580c46f83c7c5943 Mon Sep 17 00:00:00 2001 From: Wouter de Jong Date: Sat, 14 Mar 2020 15:17:10 +0100 Subject: [PATCH] Added pre-authenticated authenticators (X.509 & REMOTE_USER) --- .../Security/Factory/RemoteUserFactory.php | 15 +- .../Security/Factory/X509Factory.php | 16 ++- .../config/security_authenticator.xml | 25 ++++ .../AbstractPreAuthenticatedAuthenticator.php | 136 ++++++++++++++++++ .../Authenticator/RemoteUserAuthenticator.php | 48 +++++++ .../Http/Authenticator/X509Authenticator.php | 61 ++++++++ .../EventListener/UserCheckerListener.php | 3 +- .../RemoteUserAuthenticatorTest.php | 62 ++++++++ .../Authenticator/X509AuthenticatorTest.php | 110 ++++++++++++++ 9 files changed, 473 insertions(+), 3 deletions(-) create mode 100644 src/Symfony/Component/Security/Http/Authenticator/AbstractPreAuthenticatedAuthenticator.php create mode 100644 src/Symfony/Component/Security/Http/Authenticator/RemoteUserAuthenticator.php create mode 100644 src/Symfony/Component/Security/Http/Authenticator/X509Authenticator.php create mode 100644 src/Symfony/Component/Security/Http/Tests/Authenticator/RemoteUserAuthenticatorTest.php create mode 100644 src/Symfony/Component/Security/Http/Tests/Authenticator/X509AuthenticatorTest.php diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RemoteUserFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RemoteUserFactory.php index b37229d886..0f0c44f8ab 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RemoteUserFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RemoteUserFactory.php @@ -22,7 +22,7 @@ use Symfony\Component\DependencyInjection\Reference; * @author Fabien Potencier * @author Maxime Douailin */ -class RemoteUserFactory implements SecurityFactoryInterface +class RemoteUserFactory implements SecurityFactoryInterface, AuthenticatorFactoryInterface { public function create(ContainerBuilder $container, string $id, array $config, string $userProvider, ?string $defaultEntryPoint) { @@ -43,6 +43,19 @@ class RemoteUserFactory implements SecurityFactoryInterface return [$providerId, $listenerId, $defaultEntryPoint]; } + public function createAuthenticator(ContainerBuilder $container, string $id, array $config, string $userProviderId) + { + $authenticatorId = 'security.authenticator.remote_user.'.$id; + $container + ->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.remote_user')) + ->replaceArgument(0, new Reference($userProviderId)) + ->replaceArgument(2, $firewallName) + ->replaceArgument(3, $config['user']) + ; + + return $authenticatorId; + } + public function getPosition() { return 'pre_auth'; diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/X509Factory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/X509Factory.php index e3ba596d93..604cee7e44 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/X509Factory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/X509Factory.php @@ -21,7 +21,7 @@ use Symfony\Component\DependencyInjection\Reference; * * @author Fabien Potencier */ -class X509Factory implements SecurityFactoryInterface +class X509Factory implements SecurityFactoryInterface, AuthenticatorFactoryInterface { public function create(ContainerBuilder $container, string $id, array $config, string $userProvider, ?string $defaultEntryPoint) { @@ -44,6 +44,20 @@ class X509Factory implements SecurityFactoryInterface return [$providerId, $listenerId, $defaultEntryPoint]; } + public function createAuthenticator(ContainerBuilder $container, string $id, array $config, string $userProviderId) + { + $authenticatorId = 'security.authenticator.x509.'.$id; + $container + ->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.x509')) + ->replaceArgument(0, new Reference($userProviderId)) + ->replaceArgument(2, $firewallName) + ->replaceArgument(3, $config['user']) + ->replaceArgument(4, $config['credentials']) + ; + + return $authenticatorId; + } + public function getPosition() { return 'pre_auth'; diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.xml index a5b6e87782..0ff79a0ebd 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.xml +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.xml @@ -10,6 +10,7 @@ class="Symfony\Component\Security\Http\Authentication\AuthenticatorManager" abstract="true" > + authenticators @@ -82,6 +83,7 @@ + realm name user provider @@ -111,5 +113,28 @@ options + + + + user provider + + firewall name + user key + credentials key + + + + + + user provider + + firewall name + user key + + diff --git a/src/Symfony/Component/Security/Http/Authenticator/AbstractPreAuthenticatedAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/AbstractPreAuthenticatedAuthenticator.php new file mode 100644 index 0000000000..b3a02bf1bd --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/AbstractPreAuthenticatedAuthenticator.php @@ -0,0 +1,136 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Authenticator; + +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\Authentication\Token\PreAuthenticatedToken; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Core\User\UserProviderInterface; + +/** + * The base authenticator for authenticators to use pre-authenticated + * requests (e.g. using certificates). + * + * @author Wouter de Jong + * @author Fabien Potencier + * + * @internal + * @experimental in Symfony 5.1 + */ +abstract class AbstractPreAuthenticatedAuthenticator implements InteractiveAuthenticatorInterface, CustomAuthenticatedInterface +{ + private $userProvider; + private $tokenStorage; + private $firewallName; + private $logger; + + public function __construct(UserProviderInterface $userProvider, TokenStorageInterface $tokenStorage, string $firewallName, ?LoggerInterface $logger = null) + { + $this->userProvider = $userProvider; + $this->tokenStorage = $tokenStorage; + $this->firewallName = $firewallName; + $this->logger = $logger; + } + + /** + * Returns the username of the pre-authenticated user. + * + * This authenticator is skipped if null is returned or a custom + * BadCredentialsException is thrown. + */ + abstract protected function extractUsername(Request $request): ?string; + + public function supports(Request $request): ?bool + { + try { + $username = $this->extractUsername($request); + } catch (BadCredentialsException $e) { + $this->clearToken($e); + + if (null !== $this->logger) { + $this->logger->debug('Skipping pre-authenticated authenticator as a BadCredentialsException is thrown.', ['exception' => $e, 'authenticator' => \get_class($this)]); + } + + return false; + } + + if (null === $username) { + if (null !== $this->logger) { + $this->logger->debug('Skipping pre-authenticated authenticator no username could be extracted.', ['authenticator' => \get_class($this)]); + } + + return false; + } + + $request->attributes->set('_pre_authenticated_username', $username); + + return true; + } + + public function getCredentials(Request $request) + { + return [ + 'username' => $request->attributes->get('_pre_authenticated_username'), + ]; + } + + public function getUser($credentials): ?UserInterface + { + return $this->userProvider->loadUserByUsername($credentials['username']); + } + + public function checkCredentials($credentials, UserInterface $user): bool + { + // the user is already authenticated before it entered Symfony + return true; + } + + public function createAuthenticatedToken(UserInterface $user, string $providerKey): TokenInterface + { + return new PreAuthenticatedToken($user, null, $providerKey); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $providerKey): ?Response + { + return null; // let the original request continue + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $this->clearToken($exception); + + return null; + } + + public function isInteractive(): bool + { + return true; + } + + private function clearToken(AuthenticationException $exception): void + { + $token = $this->tokenStorage->getToken(); + if ($token instanceof PreAuthenticatedToken && $this->firewallName === $token->getProviderKey()) { + $this->tokenStorage->setToken(null); + + if (null !== $this->logger) { + $this->logger->info('Cleared pre-authenticated token due to an exception.', ['exception' => $exception]); + } + } + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/RemoteUserAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/RemoteUserAuthenticator.php new file mode 100644 index 0000000000..3a01087767 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/RemoteUserAuthenticator.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\Component\Security\Http\Authenticator; + +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\UserProviderInterface; + +/** + * This authenticator authenticates a remote user. + * + * @author Wouter de Jong + * @author Fabien Potencier + * @author Maxime Douailin + * + * @internal in Symfony 5.1 + */ +class RemoteUserAuthenticator extends AbstractPreAuthenticatedAuthenticator +{ + private $userKey; + + public function __construct(UserProviderInterface $userProvider, TokenStorageInterface $tokenStorage, string $firewallName, string $userKey = 'REMOTE_USER', ?LoggerInterface $logger = null) + { + parent::__construct($userProvider, $tokenStorage, $firewallName, $logger); + + $this->userKey = $userKey; + } + + protected function extractUsername(Request $request): ?string + { + if (!$request->server->has($this->userKey)) { + throw new BadCredentialsException(sprintf('User key was not found: "%s".', $this->userKey)); + } + + return $request->server->get($this->userKey); + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/X509Authenticator.php b/src/Symfony/Component/Security/Http/Authenticator/X509Authenticator.php new file mode 100644 index 0000000000..d482579d05 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/X509Authenticator.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Authenticator; + +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\UserProviderInterface; + +/** + * This authenticator authenticates pre-authenticated (by the + * webserver) X.509 certificates. + * + * @author Wouter de Jong + * @author Fabien Potencier + * + * @internal + * @experimental in Symfony 5.1 + */ +class X509Authenticator extends AbstractPreAuthenticatedAuthenticator +{ + private $userKey; + private $credentialsKey; + + public function __construct(UserProviderInterface $userProvider, TokenStorageInterface $tokenStorage, string $firewallName, string $userKey = 'SSL_CLIENT_S_DN_Email', string $credentialsKey = 'SSL_CLIENT_S_DN', ?LoggerInterface $logger = null) + { + parent::__construct($userProvider, $tokenStorage, $firewallName, $logger); + + $this->userKey = $userKey; + $this->credentialsKey = $credentialsKey; + } + + protected function extractUsername(Request $request): string + { + $username = null; + if ($request->server->has($this->userKey)) { + $username = $request->server->get($this->userKey); + } elseif ( + $request->server->has($this->credentialsKey) + && preg_match('#emailAddress=([^,/@]++@[^,/]++)#', $request->server->get($this->credentialsKey), $matches) + ) { + $username = $matches[1]; + } + + if (null === $username) { + throw new BadCredentialsException(sprintf('SSL credentials not found: %s, %s', $this->userKey, $this->credentialsKey)); + } + + return $username; + } +} diff --git a/src/Symfony/Component/Security/Http/EventListener/UserCheckerListener.php b/src/Symfony/Component/Security/Http/EventListener/UserCheckerListener.php index 8ebbaca709..34fdfdf84d 100644 --- a/src/Symfony/Component/Security/Http/EventListener/UserCheckerListener.php +++ b/src/Symfony/Component/Security/Http/EventListener/UserCheckerListener.php @@ -4,6 +4,7 @@ namespace Symfony\Component\Security\Http\EventListener; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Security\Core\User\UserCheckerInterface; +use Symfony\Component\Security\Http\Authenticator\AbstractPreAuthenticatedAuthenticator; use Symfony\Component\Security\Http\Event\VerifyAuthenticatorCredentialsEvent; /** @@ -23,7 +24,7 @@ class UserCheckerListener implements EventSubscriberInterface public function preCredentialsVerification(VerifyAuthenticatorCredentialsEvent $event): void { - if (null === $event->getUser()) { + if (null === $event->getUser() || $event->getAuthenticator() instanceof AbstractPreAuthenticatedAuthenticator) { return; } diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/RemoteUserAuthenticatorTest.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/RemoteUserAuthenticatorTest.php new file mode 100644 index 0000000000..80cddd1ddb --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/RemoteUserAuthenticatorTest.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Authenticator; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Http\Authenticator\RemoteUserAuthenticator; + +class RemoteUserAuthenticatorTest extends TestCase +{ + /** + * @dataProvider provideAuthenticators + */ + public function testSupport(RemoteUserAuthenticator $authenticator, $parameterName) + { + $request = $this->createRequest([$parameterName => 'TheUsername']); + + $this->assertTrue($authenticator->supports($request)); + } + + public function testSupportNoUser() + { + $authenticator = new RemoteUserAuthenticator($this->createMock(UserProviderInterface::class), new TokenStorage(), 'main'); + + $this->assertFalse($authenticator->supports($this->createRequest([]))); + } + + /** + * @dataProvider provideAuthenticators + */ + public function testGetCredentials(RemoteUserAuthenticator $authenticator, $parameterName) + { + $request = $this->createRequest([$parameterName => 'TheUsername']); + + $authenticator->supports($request); + $this->assertEquals(['username' => 'TheUsername'], $authenticator->getCredentials($request)); + } + + public function provideAuthenticators() + { + $userProvider = $this->createMock(UserProviderInterface::class); + + yield [new RemoteUserAuthenticator($userProvider, new TokenStorage(), 'main'), 'REMOTE_USER']; + yield [new RemoteUserAuthenticator($userProvider, new TokenStorage(), 'main', 'CUSTOM_USER_PARAMETER'), 'CUSTOM_USER_PARAMETER']; + } + + private function createRequest(array $server) + { + return new Request([], [], [], [], [], $server); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/X509AuthenticatorTest.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/X509AuthenticatorTest.php new file mode 100644 index 0000000000..e839504285 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/X509AuthenticatorTest.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Authenticator; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Http\Authenticator\X509Authenticator; + +class X509AuthenticatorTest extends TestCase +{ + private $userProvider; + private $authenticator; + + protected function setUp(): void + { + $this->userProvider = $this->createMock(UserProviderInterface::class); + $this->authenticator = new X509Authenticator($this->userProvider, new TokenStorage(), 'main'); + } + + /** + * @dataProvider provideServerVars + */ + public function testAuthentication($user, $credentials) + { + $serverVars = []; + if ('' !== $user) { + $serverVars['SSL_CLIENT_S_DN_Email'] = $user; + } + if ('' !== $credentials) { + $serverVars['SSL_CLIENT_S_DN'] = $credentials; + } + + $request = $this->createRequest($serverVars); + $this->assertTrue($this->authenticator->supports($request)); + $this->assertEquals(['username' => $user], $this->authenticator->getCredentials($request)); + } + + public static function provideServerVars() + { + yield ['TheUser', 'TheCredentials']; + yield ['TheUser', '']; + } + + /** + * @dataProvider provideServerVarsNoUser + */ + public function testAuthenticationNoUser($emailAddress, $credentials) + { + $request = $this->createRequest(['SSL_CLIENT_S_DN' => $credentials]); + + $this->assertTrue($this->authenticator->supports($request)); + $this->assertEquals(['username' => $emailAddress], $this->authenticator->getCredentials($request)); + } + + public static function provideServerVarsNoUser() + { + yield ['cert@example.com', 'CN=Sample certificate DN/emailAddress=cert@example.com']; + yield ['cert+something@example.com', 'CN=Sample certificate DN/emailAddress=cert+something@example.com']; + yield ['cert@example.com', 'CN=Sample certificate DN,emailAddress=cert@example.com']; + yield ['cert+something@example.com', 'CN=Sample certificate DN,emailAddress=cert+something@example.com']; + yield ['cert+something@example.com', 'emailAddress=cert+something@example.com,CN=Sample certificate DN']; + yield ['cert+something@example.com', 'emailAddress=cert+something@example.com']; + yield ['firstname.lastname@mycompany.co.uk', 'emailAddress=firstname.lastname@mycompany.co.uk,CN=Firstname.Lastname,OU=london,OU=company design and engineering,OU=Issuer London,OU=Roaming,OU=Interactive,OU=Users,OU=Standard,OU=Business,DC=england,DC=core,DC=company,DC=co,DC=uk']; + } + + public function testSupportNoData() + { + $request = $this->createRequest([]); + + $this->assertFalse($this->authenticator->supports($request)); + } + + public function testAuthenticationCustomUserKey() + { + $authenticator = new X509Authenticator($this->userProvider, new TokenStorage(), 'main', 'TheUserKey'); + + $request = $this->createRequest([ + 'TheUserKey' => 'TheUser', + ]); + $this->assertTrue($authenticator->supports($request)); + $this->assertEquals(['username' => 'TheUser'], $authenticator->getCredentials($request)); + } + + public function testAuthenticationCustomCredentialsKey() + { + $authenticator = new X509Authenticator($this->userProvider, new TokenStorage(), 'main', 'SSL_CLIENT_S_DN_Email', 'TheCertKey'); + + $request = $this->createRequest([ + 'TheCertKey' => 'CN=Sample certificate DN/emailAddress=cert@example.com', + ]); + $this->assertTrue($authenticator->supports($request)); + $this->assertEquals(['username' => 'cert@example.com'], $authenticator->getCredentials($request)); + } + + private function createRequest(array $server) + { + return new Request([], [], [], [], [], $server); + } +}