From cfcd1c7ec13da3b9d5bc94626aae52ef5bd6781b Mon Sep 17 00:00:00 2001 From: Rob Frawley 2nd Date: Mon, 5 Feb 2018 02:57:55 -0500 Subject: [PATCH] Adds an "AUTHENTICATION_SUCCESS_SENSITIVE" event that includes sensitive user credentials, dispatched immediately prior to the existing "AUTHENTICATION_SUCCESS" event (which does not include sensitive user credentials, by default). The existing "AUTHENTICATION_SUCCESS" event can be configured such that the token is not sanitized of sensitive data, but that introduces the problem of exposing this data to *all* authentication success subscribers/listeners when the vast majority do not need, and should not have, access to such data. The newly added "AUTHENTICATION_SUCCESS_SENSITIVE" event includes the unsanitized token, leaving the raw password/api-key), enabling actions such as password re-hashing and other credentials-aware tasks. Between the existing and newly added authentication events, there is now a *clear* separation between subscribers/listeners that *should* have user credentials (and must shoulder the added responsibility of handling it), and those that *should not* have access to user credentials. --- .../AuthenticationProviderManager.php | 11 ++ .../Security/Core/AuthenticationEvents.php | 25 ++- .../Event/AuthenticationSensitiveEvent.php | 97 ++++++++++ .../AuthenticationProviderManagerTest.php | 52 +++++- .../AuthenticationSensitiveEventTest.php | 175 ++++++++++++++++++ 5 files changed, 349 insertions(+), 11 deletions(-) create mode 100644 src/Symfony/Component/Security/Core/Event/AuthenticationSensitiveEvent.php create mode 100644 src/Symfony/Component/Security/Core/Tests/Event/AuthenticationSensitiveEventTest.php diff --git a/src/Symfony/Component/Security/Core/Authentication/AuthenticationProviderManager.php b/src/Symfony/Component/Security/Core/Authentication/AuthenticationProviderManager.php index 3a86aecd09aad..d02ab5d9d8880 100644 --- a/src/Symfony/Component/Security/Core/Authentication/AuthenticationProviderManager.php +++ b/src/Symfony/Component/Security/Core/Authentication/AuthenticationProviderManager.php @@ -15,6 +15,7 @@ use Symfony\Component\Security\Core\Event\AuthenticationEvent; use Symfony\Component\Security\Core\AuthenticationEvents; use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Security\Core\Event\AuthenticationSensitiveEvent; use Symfony\Component\Security\Core\Exception\AccountStatusException; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\ProviderNotFoundException; @@ -32,6 +33,10 @@ class AuthenticationProviderManager implements AuthenticationManagerInterface { private $providers; private $eraseCredentials; + + /** + * @var EventDispatcherInterface|null + */ private $eventDispatcher; /** @@ -62,6 +67,7 @@ public function authenticate(TokenInterface $token) { $lastException = null; $result = null; + $providerClassName = null; foreach ($this->providers as $provider) { if (!$provider instanceof AuthenticationProviderInterface) { @@ -76,6 +82,7 @@ public function authenticate(TokenInterface $token) $result = $provider->authenticate($token); if (null !== $result) { + $providerClassName = get_class($provider); break; } } catch (AccountStatusException $e) { @@ -88,6 +95,10 @@ public function authenticate(TokenInterface $token) } if (null !== $result) { + if (null !== $this->eventDispatcher) { + $this->eventDispatcher->dispatch(AuthenticationEvents::AUTHENTICATION_SUCCESS_SENSITIVE, new AuthenticationSensitiveEvent($token, $result, $providerClassName)); + } + if (true === $this->eraseCredentials) { $result->eraseCredentials(); } diff --git a/src/Symfony/Component/Security/Core/AuthenticationEvents.php b/src/Symfony/Component/Security/Core/AuthenticationEvents.php index 6aad681ba308c..29dde33080d7a 100644 --- a/src/Symfony/Component/Security/Core/AuthenticationEvents.php +++ b/src/Symfony/Component/Security/Core/AuthenticationEvents.php @@ -13,9 +13,32 @@ final class AuthenticationEvents { + /** + * The AUTHENTICATION_SUCCESS_SENSITIVE event occurs after a user is + * authenticated by one provider. It is dispatched immediately *prior* to + * the companion AUTHENTICATION_SUCCESS event. + * + * This event *does* contain user credentials and other sensitive data. This + * enables rehashing and other credentials-aware actions. Listeners and + * subscribers of this event carry the added responsibility of passing + * around sensitive data and usage should be limited to cases where this + * extra information is explicitly utilized; otherwise, use the + * AUTHENTICATION_SUCCESS event instead. + * + * @Event("Symfony\Component\Security\Core\Event\AuthenticationSensitiveEvent") + */ + const AUTHENTICATION_SUCCESS_SENSITIVE = 'security.authentication.success_sensitive'; + /** * The AUTHENTICATION_SUCCESS event occurs after a user is authenticated - * by one provider. + * by one provider. It is dispatched immediately *after* the companion + * AUTHENTICATION_SUCCESS_SENSITIVE event. + * + * This event does *not* contain user credentials and other sensitive data + * by default. Listeners and subscribers of this event are shielded from + * the added responsibility of passing around sensitive data and this event + * should be used unless such extra information is required; use the + * AUTHENTICATION_SUCCESS_SENSITIVE event instead if this is the case. * * @Event("Symfony\Component\Security\Core\Event\AuthenticationEvent") */ diff --git a/src/Symfony/Component/Security/Core/Event/AuthenticationSensitiveEvent.php b/src/Symfony/Component/Security/Core/Event/AuthenticationSensitiveEvent.php new file mode 100644 index 0000000000000..69232396aa6e9 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Event/AuthenticationSensitiveEvent.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Event; + +use Symfony\Component\EventDispatcher\Event; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; + +/** + * This is an authentication event that includes sensitive data. + * + * @author Rob Frawley 2nd + */ +class AuthenticationSensitiveEvent extends Event +{ + private $preAuthenticationToken; + private $authenticationToken; + private $authenticationProviderClassName; + + public function __construct(TokenInterface $preAuthenticationToken, TokenInterface $authenticationToken, ?string $authenticationProviderClassName = null) + { + $this->preAuthenticationToken = $preAuthenticationToken; + $this->authenticationToken = $authenticationToken; + $this->authenticationProviderClassName = $authenticationProviderClassName; + } + + public function getPreAuthenticationToken(): TokenInterface + { + return $this->preAuthenticationToken; + } + + public function getAuthenticationToken(): TokenInterface + { + return $this->authenticationToken; + } + + public function getAuthenticationProviderClassName(): ?string + { + return $this->authenticationProviderClassName; + } + + /** + * Tries to extract the credentials password, first from the post-auth token and second from the pre-auth token. + * It uses either a custom extraction closure (optionally passed as its first and only argument) or the default + * extraction implementation. The default extractor fetches the token's credentials and directly returns it if + * the value is a scalar or object that implements a "__toString()" method. If the credentials val is an array + * the first "password", "api_key", "api-key", or "secret" index value (that exists and is non-false after being + * cast to a sting using the prior described method) is returned. Lastly, if none of the previous conditions are + * met, "null" is returned. + * + * @param \Closure|null $extractor An optional custom token credentials password extraction \Closure that is + * provided an auth token (as an instance of TokenInterface) and an auth event + * (as an instance of AuthenticationSensitiveEvent). This closure is called + * first with the final-auth token and second with the pre-auth token, returning + * early if a non-null/non-empty scalar/castable-object value is returned. + * + * @return null|string Either a credentials password/secret/auth_key is returned or null on extraction failure + */ + public function getAuthenticationTokenPassword(?\Closure $extractor = null): ?string + { + $extractor = $extractor ?? function (TokenInterface $token): ?string { + return $this->tryCoercibleCredentialsPasswordToString($credentials = $token->getCredentials()) + ?: $this->tryArrayFindCredentialsPasswordToString($credentials); + }; + + return ($extractor($this->authenticationToken, $this) ?: null) + ?: ($extractor($this->preAuthenticationToken, $this) ?: null); + } + + private function tryCoercibleCredentialsPasswordToString($credentials): ?string + { + return is_scalar($credentials) || method_exists($credentials, '__toString') + ? $credentials + : null; + } + + private function tryArrayFindCredentialsPasswordToString($credentials): ?string + { + if (is_array($credentials)) { + foreach (array('password', 'api_key', 'api-key', 'secret') as $index) { + if ($c = $this->tryCoercibleCredentialsPasswordToString($credentials[$index] ?? null)) { + return $c; + } + } + } + + return null; + } +} diff --git a/src/Symfony/Component/Security/Core/Tests/Authentication/AuthenticationProviderManagerTest.php b/src/Symfony/Component/Security/Core/Tests/Authentication/AuthenticationProviderManagerTest.php index 19828b63eca9a..0e86db4112ab9 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authentication/AuthenticationProviderManagerTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authentication/AuthenticationProviderManagerTest.php @@ -12,10 +12,14 @@ namespace Symfony\Component\Security\Core\Tests\Authentication; use PHPUnit\Framework\TestCase; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Security\Core\Authentication\AuthenticationProviderManager; +use Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface; use Symfony\Component\Security\Core\AuthenticationEvents; use Symfony\Component\Security\Core\Event\AuthenticationEvent; use Symfony\Component\Security\Core\Event\AuthenticationFailureEvent; +use Symfony\Component\Security\Core\Event\AuthenticationSensitiveEvent; use Symfony\Component\Security\Core\Exception\ProviderNotFoundException; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\AccountStatusException; @@ -152,29 +156,57 @@ public function testAuthenticateDispatchesAuthenticationFailureEvent() } } - public function testAuthenticateDispatchesAuthenticationSuccessEvent() + public function testAuthenticateDispatchesAuthenticationSuccessEvents() { - $token = new UsernamePasswordToken('foo', 'bar', 'key'); + $finalToken = new UsernamePasswordToken('foo', 'bar', 'baz', array('role-01', 'role-02')); + $priorToken = new UsernamePasswordToken('foo', 'bar', 'baz'); - $provider = $this->getMockBuilder('Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface')->getMock(); - $provider->expects($this->once())->method('supports')->willReturn(true); - $provider->expects($this->once())->method('authenticate')->willReturn($token); + $provider = $this->getAuthenticationProvider(true, $finalToken); + $providerCN = get_class($provider); - $dispatcher = $this->getMockBuilder('Symfony\Component\EventDispatcher\EventDispatcherInterface')->getMock(); + $dispatcher = $this->getMockBuilder(EventDispatcherInterface::class)->getMock(); $dispatcher - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('dispatch') - ->with(AuthenticationEvents::AUTHENTICATION_SUCCESS, $this->equalTo(new AuthenticationEvent($token))); + ->withConsecutive(array( + AuthenticationEvents::AUTHENTICATION_SUCCESS_SENSITIVE, $this->equalTo(new AuthenticationSensitiveEvent($priorToken, $finalToken, $providerCN)), + ), array( + AuthenticationEvents::AUTHENTICATION_SUCCESS, $this->equalTo(new AuthenticationEvent($finalToken)), + )); $manager = new AuthenticationProviderManager(array($provider)); $manager->setEventDispatcher($dispatcher); - $this->assertSame($token, $manager->authenticate($token)); + $this->assertSame($finalToken, $manager->authenticate($priorToken)); + } + + public function testAuthenticateDispatchesAuthenticationSuccessEventsWithCredentialsAvailableAndRemovedForSuccessiveDispatches() + { + $finalToken = new UsernamePasswordToken('foo', 'bar', 'baz', array('role-01', 'role-02')); + $priorToken = new UsernamePasswordToken('foo', 'bar', 'baz'); + + $provider = $this->getAuthenticationProvider(true, $finalToken); + $providerCN = get_class($provider); + + $dispatcher = new EventDispatcher(); + $dispatcher->addListener(AuthenticationEvents::AUTHENTICATION_SUCCESS_SENSITIVE, function (AuthenticationSensitiveEvent $event) use ($providerCN) { + $this->assertSame($providerCN, $event->getAuthenticationProviderClassName()); + $this->assertSame('bar', $event->getAuthenticationTokenPassword()); + $this->assertEquals('bar', $event->getPreAuthenticationToken()->getCredentials()); + $this->assertEquals('bar', $event->getAuthenticationToken()->getCredentials()); + }); + $dispatcher->addListener(AuthenticationEvents::AUTHENTICATION_SUCCESS, function (AuthenticationEvent $event) { + $this->assertEquals('', $event->getAuthenticationToken()->getCredentials()); + }); + + $manager = new AuthenticationProviderManager(array($provider)); + $manager->setEventDispatcher($dispatcher); + $manager->authenticate($priorToken); } protected function getAuthenticationProvider($supports, $token = null, $exception = null) { - $provider = $this->getMockBuilder('Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface')->getMock(); + $provider = $this->getMockBuilder(AuthenticationProviderInterface::class)->getMock(); $provider->expects($this->once()) ->method('supports') ->will($this->returnValue($supports)) diff --git a/src/Symfony/Component/Security/Core/Tests/Event/AuthenticationSensitiveEventTest.php b/src/Symfony/Component/Security/Core/Tests/Event/AuthenticationSensitiveEventTest.php new file mode 100644 index 0000000000000..0f884aed7c29b --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Event/AuthenticationSensitiveEventTest.php @@ -0,0 +1,175 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Tests\Authentication; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Event\AuthenticationSensitiveEvent; + +class AuthenticationSensitiveEventTest extends TestCase +{ + public static function provideTestAccessorMethodsData(): \Iterator + { + $createPassExtractorFunc = function ($default = null) { + return function ($token, $event) use ($default): ?string { + self::assertInstanceOf(TokenInterface::class, $token); + self::assertInstanceOf(AuthenticationSensitiveEvent::class, $event); + + $c = $token->getCredentials(); + + if (method_exists($c, 'getInnerCredentials')) { + return $c->getInnerCredentials(); + } + + if ($c instanceof \Closure) { + return ($c)(); + } + + if (isset($c[$k = sprintf('%s-key', $default)])) { + return $c[$k]; + } + + return $default; + }; + }; + + $createHasStrCastableClass = function (?string $return = null) { + return new class($return) { + private $return; + + public function __construct(?string $return = null) + { + $this->return = $return; + } + + public function __toString(): string + { + return $this->return ?? ''; + } + }; + }; + + $createNotStrCastableClass = function (?string $return = null) { + return new class($return) { + private $return; + + public function __construct(?string $return = null) + { + $this->return = $return; + } + + public function getInnerCredentials(): string + { + return $this->return ?? ''; + } + }; + }; + + $createCredentialsAFuncVal = function ($return = null) { + return function () use ($return) { + return $return; + }; + }; + + // expects credential password of "null" type + yield array(null); + yield array(null, $createHasStrCastableClass('')); + yield array(null, $createNotStrCastableClass('foo')); + yield array(null, array('unknown-index-foo' => 'foo')); + yield array(null, null, $createHasStrCastableClass('')); + yield array(null, null, $createNotStrCastableClass('foo')); + yield array(null, null, array('unknown-index-bar' => 'bar')); + yield array(null, null, null, $createPassExtractorFunc(null)); + + // expects credential password of "foo" value + yield array('foo', 'foo'); + yield array('foo', 'foo', 'bar'); + yield array('foo', $createHasStrCastableClass('foo')); + yield array('foo', $createNotStrCastableClass('foo'), null, $createPassExtractorFunc()); + yield array('foo', $createCredentialsAFuncVal('foo'), null, $createPassExtractorFunc()); + + // expects credential password of "bar" value + yield array('bar', null, 'bar'); + yield array('bar', null, $createHasStrCastableClass('bar')); + yield array('bar', null, $createNotStrCastableClass('bar'), $createPassExtractorFunc()); + yield array('bar', null, $createCredentialsAFuncVal('bar'), $createPassExtractorFunc()); + + // expects credential password of "baz" value + yield array('baz', null, null, $createPassExtractorFunc('baz')); + + // expects array value will be extracted for all supported indexes + foreach (array('password', 'api_key', 'api-key', 'secret') as $index) { + // expects credential password of "null" type + yield array(null, array($index => null)); + yield array(null, null, array($index => '')); + yield array(null, array($index => ''), array($index => null)); + + // expects credential password of "foo" value + yield array('foo', array($index => 'foo')); + yield array('foo', array($index => 'foo'), array($index => null)); + yield array('foo', array($index => 'foo'), array($index => '')); + yield array('foo', array($index => 'foo'), array('unknown-index-bar' => 'bar')); + yield array('foo', array($index => 'foo'), array($index => 'bar')); + + // expects credential password of "bar" value + yield array('bar', null, array($index => 'bar')); + yield array('bar', array($index => null), array($index => 'bar')); + yield array('bar', array($index => ''), array($index => 'bar')); + yield array('bar', array('unknown-index-foo' => 'foo'), array($index => 'bar')); + yield array('bar', array($index => $createNotStrCastableClass), array($index => 'bar')); + + // expects credential password of "{$index}-val" variable + yield array( + sprintf('%s-val', $index), + array(sprintf('%s-key', $index) => sprintf('%s-val', $index)), + null, + $createPassExtractorFunc($index), + ); + } + } + + /** + * @dataProvider provideTestAccessorMethodsData + * + * @param string $expectedPassword + * @param string|array|null $finalCredentials + * @param string|array|null $priorCredentials + * @param \Closure|null $passwordExtractor + */ + public function testAccessorMethods(string $expectedPassword = null, $finalCredentials = null, $priorCredentials = null, \Closure $passwordExtractor = null): void + { + $event = new AuthenticationSensitiveEvent( + $priorToken = $this->getTokenInterfaceMock($priorCredentials), + $finalToken = $this->getTokenInterfaceMock($finalCredentials), + AuthenticationProviderInterface::class + ); + + $this->assertSame($priorToken, $event->getPreAuthenticationToken()); + $this->assertSame($finalToken, $event->getAuthenticationToken()); + $this->assertSame(AuthenticationProviderInterface::class, $event->getAuthenticationProviderClassName()); + $this->assertSame($expectedPassword, $event->getAuthenticationTokenPassword($passwordExtractor)); + } + + private function getTokenInterfaceMock($credentials = null): TokenInterface + { + $token = $this + ->getMockBuilder(TokenInterface::class) + ->getMock(); + + $token->expects($this->any()) + ->method('getCredentials') + ->will($this->returnValue($credentials)); + + return $token; + } +}