From 05af97c7f7cb6464e539c038c129003f2d0462ce Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Sun, 17 May 2015 14:39:26 -0400 Subject: [PATCH] Initial commit (but after some polished work) of the new Guard authentication system --- .../Factory/GuardAuthenticationFactory.php | 113 +++++++++ .../DependencyInjection/SecurityExtension.php | 1 + .../SecurityBundle/Resources/config/guard.xml | 40 ++++ .../GuardAuthenticationFactoryTest.php | 181 ++++++++++++++ .../Guard/AbstractGuardAuthenticator.php | 31 +++ .../Firewall/GuardAuthenticationListener.php | 180 ++++++++++++++ .../Guard/GuardAuthenticatorHandler.php | 122 ++++++++++ .../Guard/GuardAuthenticatorInterface.php | 119 ++++++++++ .../Provider/GuardAuthenticationProvider.php | 106 +++++++++ .../GuardAuthenticationListenerTest.php | 222 ++++++++++++++++++ .../Tests/GuardAuthenticatorHandlerTest.php | 99 ++++++++ .../GuardAuthenticationProviderTest.php | 93 ++++++++ .../Guard/Token/GenericGuardToken.php | 96 ++++++++ .../Token/NonAuthenticatedGuardToken.php | 56 +++++ 14 files changed, 1459 insertions(+) create mode 100644 src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/GuardAuthenticationFactory.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Resources/config/guard.xml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/GuardAuthenticationFactoryTest.php create mode 100644 src/Symfony/Component/Security/Guard/AbstractGuardAuthenticator.php create mode 100644 src/Symfony/Component/Security/Guard/Firewall/GuardAuthenticationListener.php create mode 100644 src/Symfony/Component/Security/Guard/GuardAuthenticatorHandler.php create mode 100644 src/Symfony/Component/Security/Guard/GuardAuthenticatorInterface.php create mode 100644 src/Symfony/Component/Security/Guard/Provider/GuardAuthenticationProvider.php create mode 100644 src/Symfony/Component/Security/Guard/Tests/Firewall/GuardAuthenticationListenerTest.php create mode 100644 src/Symfony/Component/Security/Guard/Tests/GuardAuthenticatorHandlerTest.php create mode 100644 src/Symfony/Component/Security/Guard/Tests/Provider/GuardAuthenticationProviderTest.php create mode 100644 src/Symfony/Component/Security/Guard/Token/GenericGuardToken.php create mode 100644 src/Symfony/Component/Security/Guard/Token/NonAuthenticatedGuardToken.php diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/GuardAuthenticationFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/GuardAuthenticationFactory.php new file mode 100644 index 0000000000..e721ee77fe --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/GuardAuthenticationFactory.php @@ -0,0 +1,113 @@ + + */ +class GuardAuthenticationFactory implements SecurityFactoryInterface +{ + public function getPosition() + { + return 'pre_auth'; + } + + public function getKey() + { + return 'guard'; + } + + public function addConfiguration(NodeDefinition $node) + { + $node + ->fixXmlConfig('authenticator') + ->children() + ->scalarNode('provider') + ->info('A key from the "providers" section of your security config, in case your user provider is different than the firewall') + ->end() + ->scalarNode('entry_point') + ->info('A service id (of one of your authenticators) whose start() method should be called when an anonymous user hits a page that requires authentication') + ->defaultValue(null) + ->end() + ->arrayNode('authenticators') + ->info('An array of service ids for all of your "authenticators"') + ->requiresAtLeastOneElement() + ->prototype('scalar')->end() + ->end() + ->end() + ; + } + + public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint) + { + $authenticatorIds = $config['authenticators']; + $authenticatorReferences = array(); + foreach ($authenticatorIds as $authenticatorId) { + $authenticatorReferences[] = new Reference($authenticatorId); + } + + // configure the GuardAuthenticationFactory to have the dynamic constructor arguments + $providerId = 'security.authentication.provider.guard.'.$id; + $container + ->setDefinition($providerId, new DefinitionDecorator('security.authentication.provider.guard')) + ->replaceArgument(0, $authenticatorReferences) + ->replaceArgument(1, new Reference($userProvider)) + ->replaceArgument(2, $id) + ; + + // listener + $listenerId = 'security.authentication.listener.guard.'.$id; + $listener = $container->setDefinition($listenerId, new DefinitionDecorator('security.authentication.listener.guard')); + $listener->replaceArgument(2, $id); + $listener->replaceArgument(3, $authenticatorReferences); + + // determine the entryPointId to use + $entryPointId = $this->determineEntryPoint($defaultEntryPoint, $config); + + // this is always injected - then the listener decides if it should be used + $container + ->getDefinition($listenerId) + ->addTag('security.remember_me_aware', array('id' => $id, 'provider' => $userProvider)); + + return array($providerId, $listenerId, $entryPointId); + } + + private function determineEntryPoint($defaultEntryPointId, array $config) + { + if ($defaultEntryPointId) { + // explode if they've configured the entry_point, but there is already one + if ($config['entry_point']) { + throw new \LogicException(sprintf( + 'The guard authentication provider cannot use the "%s" entry_point because another entry point is already configured by another provider! Either remove the other provider or move the entry_point configuration as a root key under your firewall', + $config['entry_point'] + )); + } + + return $defaultEntryPointId; + } + + if ($config['entry_point']) { + // if it's configured explicitly, use it! + return $config['entry_point']; + } + + $authenticatorIds = $config['authenticators']; + if (count($authenticatorIds) == 1) { + // if there is only one authenticator, use that as the entry point + return array_shift($authenticatorIds); + } + + // we have multiple entry points - we must ask them to configure one + throw new \LogicException(sprintf( + 'Because you have multiple guard configurators, you need to set the "guard.entry_point" key to one of you configurators (%s)', + implode(', ', $authenticatorIds) + )); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index e82e3ca877..6db34e4d28 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -65,6 +65,7 @@ class SecurityExtension extends Extension $loader->load('templating_php.xml'); $loader->load('templating_twig.xml'); $loader->load('collectors.xml'); + $loader->load('guard.xml'); if (!class_exists('Symfony\Component\ExpressionLanguage\ExpressionLanguage')) { $container->removeDefinition('security.expression_language'); diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/guard.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/guard.xml new file mode 100644 index 0000000000..0524cf2b95 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/guard.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/GuardAuthenticationFactoryTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/GuardAuthenticationFactoryTest.php new file mode 100644 index 0000000000..1f107879db --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/GuardAuthenticationFactoryTest.php @@ -0,0 +1,181 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\DependencyInjection\Security\Factory; + +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\GuardAuthenticationFactory; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; + +class GuardAuthenticationFactoryTest extends \PHPUnit_Framework_TestCase +{ + /** + * @dataProvider getValidConfigurationTests + */ + public function testAddValidConfiguration(array $inputConfig, array $expectedConfig) + { + $factory = new GuardAuthenticationFactory(); + $nodeDefinition = new ArrayNodeDefinition('guard'); + $factory->addConfiguration($nodeDefinition); + + $node = $nodeDefinition->getNode(); + $normalizedConfig = $node->normalize($inputConfig); + $finalizedConfig = $node->finalize($normalizedConfig); + + $this->assertEquals($expectedConfig, $finalizedConfig); + } + + /** + * @expectedException \Symfony\Component\Config\Definition\Exception\InvalidConfigurationException + * @dataProvider getInvalidConfigurationTests + */ + public function testAddInvalidConfiguration(array $inputConfig) + { + $factory = new GuardAuthenticationFactory(); + $nodeDefinition = new ArrayNodeDefinition('guard'); + $factory->addConfiguration($nodeDefinition); + + $node = $nodeDefinition->getNode(); + $normalizedConfig = $node->normalize($inputConfig); + // will validate and throw an exception on invalid + $node->finalize($normalizedConfig); + } + + public function getValidConfigurationTests() + { + $tests = array(); + + // completely basic + $tests[] = array( + array( + 'authenticators' => array('authenticator1', 'authenticator2'), + 'provider' => 'some_provider', + 'entry_point' => 'the_entry_point' + ), + array( + 'authenticators' => array('authenticator1', 'authenticator2'), + 'provider' => 'some_provider', + 'entry_point' => 'the_entry_point' + ) + ); + + // testing xml config fix: authenticator -> authenticators + $tests[] = array( + array( + 'authenticator' => array('authenticator1', 'authenticator2'), + ), + array( + 'authenticators' => array('authenticator1', 'authenticator2'), + 'entry_point' => null, + ) + ); + + return $tests; + } + + public function getInvalidConfigurationTests() + { + $tests = array(); + + // testing not empty + $tests[] = array( + array('authenticators' => array()) + ); + + return $tests; + } + + public function testBasicCreate() + { + // simple configuration + $config = array( + 'authenticators' => array('authenticator123'), + 'entry_point' => null, + ); + list($container, $entryPointId) = $this->executeCreate($config, null); + $this->assertEquals('authenticator123', $entryPointId); + + $providerDefinition = $container->getDefinition('security.authentication.provider.guard.my_firewall'); + $this->assertEquals(array( + 'index_0' => array(new Reference('authenticator123')), + 'index_1' => new Reference('my_user_provider'), + 'index_2' => 'my_firewall' + ), $providerDefinition->getArguments()); + + $listenerDefinition = $container->getDefinition('security.authentication.listener.guard.my_firewall'); + $this->assertEquals('my_firewall', $listenerDefinition->getArgument(2)); + $this->assertEquals(array(new Reference('authenticator123')), $listenerDefinition->getArgument(3)); + } + + public function testExistingDefaultEntryPointUsed() + { + // any existing default entry point is used + $config = array( + 'authenticators' => array('authenticator123'), + 'entry_point' => null, + ); + list($container, $entryPointId) = $this->executeCreate($config, 'some_default_entry_point'); + $this->assertEquals('some_default_entry_point', $entryPointId); + } + + /** + * @expectedException \LogicException + */ + public function testCannotOverrideDefaultEntryPoint() + { + // any existing default entry point is used + $config = array( + 'authenticators' => array('authenticator123'), + 'entry_point' => 'authenticator123', + ); + $this->executeCreate($config, 'some_default_entry_point'); + } + + /** + * @expectedException \LogicException + */ + public function testMultipleAuthenticatorsRequiresEntryPoint() + { + // any existing default entry point is used + $config = array( + 'authenticators' => array('authenticator123', 'authenticatorABC'), + 'entry_point' => null, + ); + $this->executeCreate($config, null); + } + + + public function testCreateWithEntryPoint() + { + // any existing default entry point is used + $config = array( + 'authenticators' => array('authenticator123', 'authenticatorABC'), + 'entry_point' => 'authenticatorABC', + ); + list($container, $entryPointId) = $this->executeCreate($config, null); + $this->assertEquals('authenticatorABC', $entryPointId); + } + + private function executeCreate(array $config, $defaultEntryPointId) + { + $container = new ContainerBuilder(); + $container->register('security.authentication.provider.guard'); + $container->register('security.authentication.listener.guard'); + $id = 'my_firewall'; + $userProviderId = 'my_user_provider'; + + $factory = new GuardAuthenticationFactory(); + list($providerId, $listenerId, $entryPointId) = $factory->create($container, $id, $config, $userProviderId, $defaultEntryPointId); + + return array($container, $entryPointId); + } +} diff --git a/src/Symfony/Component/Security/Guard/AbstractGuardAuthenticator.php b/src/Symfony/Component/Security/Guard/AbstractGuardAuthenticator.php new file mode 100644 index 0000000000..ebd09bb73f --- /dev/null +++ b/src/Symfony/Component/Security/Guard/AbstractGuardAuthenticator.php @@ -0,0 +1,31 @@ + + */ +abstract class AbstractGuardAuthenticator implements GuardAuthenticatorInterface +{ + /** + * Shortcut to create a GenericGuardToken for you, if you don't really + * care about which authenticated token you're using + * + * @param UserInterface $user + * @param string $providerKey + * @return GenericGuardToken + */ + public function createAuthenticatedToken(UserInterface $user, $providerKey) + { + return new GenericGuardToken( + $user, + $providerKey, + $user->getRoles() + ); + } +} \ No newline at end of file diff --git a/src/Symfony/Component/Security/Guard/Firewall/GuardAuthenticationListener.php b/src/Symfony/Component/Security/Guard/Firewall/GuardAuthenticationListener.php new file mode 100644 index 0000000000..a53478a2b7 --- /dev/null +++ b/src/Symfony/Component/Security/Guard/Firewall/GuardAuthenticationListener.php @@ -0,0 +1,180 @@ + + */ +class GuardAuthenticationListener implements ListenerInterface +{ + private $guardHandler; + private $authenticationManager; + private $providerKey; + private $guardAuthenticators; + private $logger; + private $rememberMeServices; + + /** + * @param GuardAuthenticatorHandler $guardHandler The Guard handler + * @param AuthenticationManagerInterface $authenticationManager An AuthenticationManagerInterface instance + * @param string $providerKey The provider (i.e. firewall) key + * @param GuardAuthenticatorInterface[] $guardAuthenticators The authenticators, with keys that match what's passed to GuardAuthenticationProvider + * @param LoggerInterface $logger A LoggerInterface instance + */ + public function __construct(GuardAuthenticatorHandler $guardHandler, AuthenticationManagerInterface $authenticationManager, $providerKey, $guardAuthenticators, LoggerInterface $logger = null) + { + if (empty($providerKey)) { + throw new \InvalidArgumentException('$providerKey must not be empty.'); + } + + $this->guardHandler = $guardHandler; + $this->authenticationManager = $authenticationManager; + $this->providerKey = $providerKey; + $this->guardAuthenticators = $guardAuthenticators; + $this->logger = $logger; + } + + /** + * Iterates over each authenticator to see if each wants to authenticate the request + * + * @param GetResponseEvent $event + */ + public function handle(GetResponseEvent $event) + { + if (null !== $this->logger) { + $this->logger->info('Checking for guard authentication credentials', array('firewall_key' => $this->providerKey, 'authenticators' => count($this->guardAuthenticators))); + } + + foreach ($this->guardAuthenticators as $key => $guardAuthenticator) { + // get a key that's unique to *this* guard authenticator + // this MUST be the same as GuardAuthenticationProvider + $uniqueGuardKey = $this->providerKey.'_'.$key; + + $this->executeGuardAuthenticator($uniqueGuardKey, $guardAuthenticator, $event); + } + } + + private function executeGuardAuthenticator($uniqueGuardKey, GuardAuthenticatorInterface $guardAuthenticator, GetResponseEvent $event) + { + $request = $event->getRequest(); + try { + if (null !== $this->logger) { + $this->logger->info('Calling getCredentialsFromRequest on guard configurator', array('firewall_key' => $this->providerKey, 'authenticator' => get_class($guardAuthenticator))); + } + + // allow the authenticator to fetch authentication info from the request + $credentials = $guardAuthenticator->getCredentialsFromRequest($request); + + // allow null to be returned to skip authentication + if (null === $credentials) { + return; + } + + // create a token with the unique key, so that the provider knows which authenticator to use + $token = new NonAuthenticatedGuardToken($credentials, $uniqueGuardKey); + + if (null !== $this->logger) { + $this->logger->info('Passing guard token information to the GuardAuthenticationProvider', array('firewall_key' => $this->providerKey, 'authenticator' => get_class($guardAuthenticator))); + } + // pass the token into the AuthenticationManager system + // this indirectly calls GuardAuthenticationProvider::authenticate() + $token = $this->authenticationManager->authenticate($token); + + if (null !== $this->logger) { + $this->logger->info('Guard authentication successful!', array('token' => $token, 'authenticator' => get_class($guardAuthenticator))); + } + + // sets the token on the token storage, etc + $this->guardHandler->authenticateWithToken($token, $request); + } catch (AuthenticationException $e) { + // oh no! Authentication failed! + + if (null !== $this->logger) { + $this->logger->info('Guard authentication failed.', array('exception' => $e, 'authenticator' => get_class($guardAuthenticator))); + } + + $response = $this->guardHandler->handleAuthenticationFailure($e, $request, $guardAuthenticator); + + if ($response instanceof Response) { + $event->setResponse($response); + } + + return; + } + + // success! + $response = $this->guardHandler->handleAuthenticationSuccess($token, $request, $guardAuthenticator, $this->providerKey); + if ($response instanceof Response) { + if (null !== $this->logger) { + $this->logger->info('Guard authenticator set success response', array('response' => $response, 'authenticator' => get_class($guardAuthenticator))); + } + + $event->setResponse($response); + } else { + if (null !== $this->logger) { + $this->logger->info('Guard authenticator set no success response: request continues', array('authenticator' => get_class($guardAuthenticator))); + } + } + + // attempt to trigger the remember me functionality + $this->triggerRememberMe($guardAuthenticator, $request, $token, $response); + } + + /** + * Should be called if this listener will support remember me. + * + * @param RememberMeServicesInterface $rememberMeServices + */ + public function setRememberMeServices(RememberMeServicesInterface $rememberMeServices) + { + $this->rememberMeServices = $rememberMeServices; + } + + /** + * Checks to see if remember me is supported in the authenticator and + * on the firewall. If it is, the RememberMeServicesInterface is notified + * + * @param GuardAuthenticatorInterface $guardAuthenticator + * @param Request $request + * @param TokenInterface $token + * @param Response $response + */ + private function triggerRememberMe(GuardAuthenticatorInterface $guardAuthenticator, Request $request, TokenInterface $token, Response $response = null) + { + if (!$guardAuthenticator->supportsRememberMe()) { + return; + } + + if (null === $this->rememberMeServices) { + if (null !== $this->logger) { + $this->logger->info('Remember me skipped: it is not configured for the firewall', array('authenticator' => get_class($guardAuthenticator))); + } + + return; + } + + if (!$response instanceof Response) { + throw new \LogicException(sprintf( + '%s::onAuthenticationSuccess *must* return a Response if you want to use the remember me functionality. Return a Response, or set remember_me to false under the guard configuration.', + get_class($guardAuthenticator) + )); + } + + $this->rememberMeServices->loginSuccess($request, $response, $token); + } +} \ No newline at end of file diff --git a/src/Symfony/Component/Security/Guard/GuardAuthenticatorHandler.php b/src/Symfony/Component/Security/Guard/GuardAuthenticatorHandler.php new file mode 100644 index 0000000000..e574613f7a --- /dev/null +++ b/src/Symfony/Component/Security/Guard/GuardAuthenticatorHandler.php @@ -0,0 +1,122 @@ + + */ +class GuardAuthenticatorHandler +{ + private $tokenStorage; + + private $dispatcher; + + public function __construct(TokenStorageInterface $tokenStorage, EventDispatcherInterface $eventDispatcher = null) + { + $this->tokenStorage = $tokenStorage; + $this->dispatcher = $eventDispatcher; + } + + /** + * Authenticates the given token in the system + * + * @param TokenInterface $token + * @param Request $request + */ + public function authenticateWithToken(TokenInterface $token, Request $request) + { + $this->tokenStorage->setToken($token); + + if (null !== $this->dispatcher) { + $loginEvent = new InteractiveLoginEvent($request, $token); + $this->dispatcher->dispatch(SecurityEvents::INTERACTIVE_LOGIN, $loginEvent); + } + } + + /** + * Returns the "on success" response for the given GuardAuthenticator + * + * @param TokenInterface $token + * @param Request $request + * @param GuardAuthenticatorInterface $guardAuthenticator + * @param string $providerKey The provider (i.e. firewall) key + * @return null|Response + */ + public function handleAuthenticationSuccess(TokenInterface $token, Request $request, GuardAuthenticatorInterface $guardAuthenticator, $providerKey) + { + $response = $guardAuthenticator->onAuthenticationSuccess($request, $token, $providerKey); + + // check that it's a Response or null + if ($response instanceof Response || null === $response) { + return $response; + } + + throw new \UnexpectedValueException(sprintf( + 'The %s::onAuthenticationSuccess method must return null or a Response object. You returned %s', + get_class($guardAuthenticator), + is_object($response) ? get_class($response) : gettype($response) + )); + } + + /** + * Convenience method for authenticating the user and returning the + * Response *if any* for success + * + * @param UserInterface $user + * @param Request $request + * @param GuardAuthenticatorInterface $authenticator + * @param string $providerKey The provider (i.e. firewall) key + * @return Response|null + */ + public function authenticateUserAndHandleSuccess(UserInterface $user, Request $request, GuardAuthenticatorInterface $authenticator, $providerKey) + { + // create an authenticated token for the User + $token = $authenticator->createAuthenticatedToken($user, $providerKey); + // authenticate this in the system + $this->authenticateWithToken($token, $request); + + // return the success metric + return $this->handleAuthenticationSuccess($token, $request, $authenticator, $providerKey); + } + + /** + * Handles an authentication failure and returns the Response for the + * GuardAuthenticator + * + * @param AuthenticationException $authenticationException + * @param Request $request + * @param GuardAuthenticatorInterface $guardAuthenticator + * @return null|Response + */ + public function handleAuthenticationFailure(AuthenticationException $authenticationException, Request $request, GuardAuthenticatorInterface $guardAuthenticator) + { + $this->tokenStorage->setToken(null); + + $response = $guardAuthenticator->onAuthenticationFailure($request, $authenticationException); + if ($response instanceof Response || null === $response) { + // returning null is ok, it means they want the request to continue + return $response; + } + + throw new \UnexpectedValueException(sprintf( + 'The %s::onAuthenticationFailure method must return null or a Response object. You returned %s', + get_class($guardAuthenticator), + is_object($response) ? get_class($response) : gettype($response) + )); + } +} \ No newline at end of file diff --git a/src/Symfony/Component/Security/Guard/GuardAuthenticatorInterface.php b/src/Symfony/Component/Security/Guard/GuardAuthenticatorInterface.php new file mode 100644 index 0000000000..dba8d09e22 --- /dev/null +++ b/src/Symfony/Component/Security/Guard/GuardAuthenticatorInterface.php @@ -0,0 +1,119 @@ + + */ +interface GuardAuthenticatorInterface extends AuthenticationEntryPointInterface +{ + /** + * Get the authentication credentials from the request and return them + * as any type (e.g. an associate array). If you return null, authentication + * will be skipped. + * + * Whatever value you return here will be passed to authenticate() + * + * For example, for a form login, you might: + * + * return array( + * 'username' => $request->request->get('_username'), + * 'password' => $request->request->get('_password'), + * ); + * + * Or for an API token that's on a header, you might use: + * + * return array('api_key' => $request->headers->get('X-API-TOKEN')); + * + * @param Request $request + * @return mixed|null + */ + public function getCredentialsFromRequest(Request $request); + + /** + * Return a UserInterface object based on the credentials OR throw + * an AuthenticationException + * + * The *credentials* are the return value from getCredentialsFromRequest() + * + * @param mixed $credentials + * @param UserProviderInterface $userProvider + * @throws AuthenticationException + * @return UserInterface + */ + public function authenticate($credentials, UserProviderInterface $userProvider); + + /** + * Create an authenticated token for the given user + * + * If you don't care about which token class is used or don't really + * understand what a "token" is, you can skip this method by extending + * the AbstractGuardAuthenticator class from your authenticator. + * + * @see AbstractGuardAuthenticator + * @param UserInterface $user + * @param string $providerKey The provider (i.e. firewall) key + * @return TokenInterface + */ + public function createAuthenticatedToken(UserInterface $user, $providerKey); + + /** + * Called when authentication executed, but failed (e.g. wrong username password) + * + * This should return the Response sent back to the user, like a + * RedirectResponse to the login page or a 403 response. + * + * If you return null, the request will continue, but the user will + * not be authenticated. This is probably not what you want to do. + * + * @param Request $request + * @param AuthenticationException $exception + * @return Response|null + */ + public function onAuthenticationFailure(Request $request, AuthenticationException $exception); + + /** + * Called when authentication executed and was successful! + * + * This should return the Response sent back to the user, like a + * RedirectResponse to the last page they visited. + * + * If you return null, the current request will continue, and the user + * will be authenticated. This makes sense, for example, with an API. + * + * @param Request $request + * @param TokenInterface $token + * @param string $providerKey The provider (i.e. firewall) key + * @return Response|null + */ + public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey); + + /** + * Does this method support remember me cookies? + * + * Remember me cookie will be set if *all* of the following are met: + * A) This method returns true + * B) The remember_me key under your firewall is configured + * C) The "remember me" functionality is activated. This is usually + * done by having a _remember_me checkbox in your form, but + * can be configured by the "always_remember_me" and "remember_me_parameter" + * parameters under the "remember_me" firewall key + * + * @return bool + */ + public function supportsRememberMe(); +} diff --git a/src/Symfony/Component/Security/Guard/Provider/GuardAuthenticationProvider.php b/src/Symfony/Component/Security/Guard/Provider/GuardAuthenticationProvider.php new file mode 100644 index 0000000000..740f4c47c2 --- /dev/null +++ b/src/Symfony/Component/Security/Guard/Provider/GuardAuthenticationProvider.php @@ -0,0 +1,106 @@ + + */ +class GuardAuthenticationProvider implements AuthenticationProviderInterface +{ + /** + * @var GuardAuthenticatorInterface[] + */ + private $guardAuthenticators; + private $userProvider; + private $providerKey; + private $userChecker; + + /** + * @param GuardAuthenticatorInterface[] $guardAuthenticators The authenticators, with keys that match what's passed to GuardAuthenticationListener + * @param UserProviderInterface $userProvider The user provider + * @param string $providerKey The provider (i.e. firewall) key + * @param UserCheckerInterface $userChecker + */ + public function __construct(array $guardAuthenticators, UserProviderInterface $userProvider, $providerKey, UserCheckerInterface $userChecker) + { + $this->guardAuthenticators = $guardAuthenticators; + $this->userProvider = $userProvider; + $this->providerKey = $providerKey; + $this->userChecker = $userChecker; + } + + /** + * Finds the correct authenticator for the token and calls it + * + * @param NonAuthenticatedGuardToken $token + * @return TokenInterface + */ + public function authenticate(TokenInterface $token) + { + if (!$token instanceof NonAuthenticatedGuardToken) { + throw new \InvalidArgumentException('GuardAuthenticationProvider only supports NonAuthenticatedGuardToken'); + } + + // find the *one* GuardAuthenticator that this token originated from + foreach ($this->guardAuthenticators as $key => $guardAuthenticator) { + // get a key that's unique to *this* guard authenticator + // this MUST be the same as GuardAuthenticationListener + $uniqueGuardKey = $this->providerKey.'_'.$key; + + if ($uniqueGuardKey == $token->getGuardProviderKey()) { + return $this->authenticateViaGuard($guardAuthenticator, $token); + } + } + + throw new \LogicException(sprintf( + 'The correct GuardAuthenticator could not be found for unique key "%s". The listener and provider should be passed the same list of authenticators!?', + $token->getGuardProviderKey() + )); + } + + private function authenticateViaGuard(GuardAuthenticatorInterface $guardAuthenticator, NonAuthenticatedGuardToken $token) + { + // get the user from the GuardAuthenticator + $user = $guardAuthenticator->authenticate($token->getCredentials(), $this->userProvider); + + if (!$user instanceof UserInterface) { + throw new \UnexpectedValueException(sprintf( + 'The %s::authenticate method must return a UserInterface. You returned %s', + get_class($guardAuthenticator), + is_object($user) ? get_class($user) : gettype($user) + )); + } + + // check the AdvancedUserInterface methods! + $this->userChecker->checkPreAuth($user);; + $this->userChecker->checkPostAuth($user); + + // turn the UserInterface into a TokenInterface + $authenticatedToken = $guardAuthenticator->createAuthenticatedToken($user, $this->providerKey); + if (!$authenticatedToken instanceof TokenInterface) { + throw new \UnexpectedValueException(sprintf( + 'The %s::createAuthenticatedToken method must return a TokenInterface. You returned %s', + get_class($guardAuthenticator), + is_object($authenticatedToken) ? get_class($authenticatedToken) : gettype($authenticatedToken) + )); + } + + return $authenticatedToken; + } + + public function supports(TokenInterface $token) + { + return $token instanceof NonAuthenticatedGuardToken; + } +} diff --git a/src/Symfony/Component/Security/Guard/Tests/Firewall/GuardAuthenticationListenerTest.php b/src/Symfony/Component/Security/Guard/Tests/Firewall/GuardAuthenticationListenerTest.php new file mode 100644 index 0000000000..b8939a93dd --- /dev/null +++ b/src/Symfony/Component/Security/Guard/Tests/Firewall/GuardAuthenticationListenerTest.php @@ -0,0 +1,222 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Guard\Tests\Firewall; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Guard\Firewall\GuardAuthenticationListener; +use Symfony\Component\Security\Guard\Token\NonAuthenticatedGuardToken; +use Symfony\Component\Security\Core\Exception\AuthenticationException; + +/** + * @author Ryan Weaver + */ +class GuardAuthenticationListenerTest extends \PHPUnit_Framework_TestCase +{ + private $authenticationManager; + private $guardAuthenticatorHandler; + private $event; + private $logger; + private $request; + private $rememberMeServices; + + public function testHandleSuccess() + { + $authenticator = $this->getMock('Symfony\Component\Security\Guard\GuardAuthenticatorInterface'); + $authenticateToken = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\TokenInterface'); + $providerKey = 'my_firewall'; + + $credentials = array('username' => 'weaverryan', 'password' => 'all_your_base'); + $authenticator + ->expects($this->once()) + ->method('getCredentialsFromRequest') + ->with($this->equalTo($this->request)) + ->will($this->returnValue($credentials)); + + // a clone of the token that should be created internally + $uniqueGuardKey = 'my_firewall_0'; + $nonAuthedToken = new NonAuthenticatedGuardToken($credentials, $uniqueGuardKey); + + $this->authenticationManager + ->expects($this->once()) + ->method('authenticate') + ->with($this->equalTo($nonAuthedToken)) + ->will($this->returnValue($authenticateToken)); + + $this->guardAuthenticatorHandler + ->expects($this->once()) + ->method('authenticateWithToken') + ->with($authenticateToken, $this->request); + + $this->guardAuthenticatorHandler + ->expects($this->once()) + ->method('handleAuthenticationSuccess') + ->with($authenticateToken, $this->request, $authenticator, $providerKey); + + $listener = new GuardAuthenticationListener( + $this->guardAuthenticatorHandler, + $this->authenticationManager, + $providerKey, + array($authenticator), + $this->logger + ); + + $listener->setRememberMeServices($this->rememberMeServices); + // should never be called - our handleAuthenticationSuccess() does not return a Response + $this->rememberMeServices + ->expects($this->never()) + ->method('loginSuccess'); + + $listener->handle($this->event); + } + + public function testHandleSuccessWithRememberMe() + { + $authenticator = $this->getMock('Symfony\Component\Security\Guard\GuardAuthenticatorInterface'); + $authenticateToken = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\TokenInterface'); + $providerKey = 'my_firewall_with_rememberme'; + + $authenticator + ->expects($this->once()) + ->method('getCredentialsFromRequest') + ->with($this->equalTo($this->request)) + ->will($this->returnValue(array('username' => 'anything_not_empty'))); + + $this->authenticationManager + ->expects($this->once()) + ->method('authenticate') + ->will($this->returnValue($authenticateToken)); + + $successResponse = new Response('Success!'); + $this->guardAuthenticatorHandler + ->expects($this->once()) + ->method('handleAuthenticationSuccess') + ->will($this->returnValue($successResponse)); + + $listener = new GuardAuthenticationListener( + $this->guardAuthenticatorHandler, + $this->authenticationManager, + $providerKey, + array($authenticator), + $this->logger + ); + + $listener->setRememberMeServices($this->rememberMeServices); + $authenticator->expects($this->once()) + ->method('supportsRememberMe') + ->will($this->returnValue(true)); + // should be called - we do have a success Response + $this->rememberMeServices + ->expects($this->once()) + ->method('loginSuccess'); + + $listener->handle($this->event); + } + + public function testHandleCatchesAuthenticationException() + { + $authenticator = $this->getMock('Symfony\Component\Security\Guard\GuardAuthenticatorInterface'); + $providerKey = 'my_firewall2'; + + $authException = new AuthenticationException('Get outta here crazy user with a bad password!'); + $authenticator + ->expects($this->once()) + ->method('getCredentialsFromRequest') + ->will($this->throwException($authException)); + + // this is not called + $this->authenticationManager + ->expects($this->never()) + ->method('authenticate'); + + $this->guardAuthenticatorHandler + ->expects($this->once()) + ->method('handleAuthenticationFailure') + ->with($authException, $this->request, $authenticator); + + $listener = new GuardAuthenticationListener( + $this->guardAuthenticatorHandler, + $this->authenticationManager, + $providerKey, + array($authenticator), + $this->logger + ); + + $listener->handle($this->event); + } + + public function testReturnNullToSkipAuth() + { + $authenticatorA = $this->getMock('Symfony\Component\Security\Guard\GuardAuthenticatorInterface'); + $authenticatorB = $this->getMock('Symfony\Component\Security\Guard\GuardAuthenticatorInterface'); + $providerKey = 'my_firewall3'; + + $authenticatorA + ->expects($this->once()) + ->method('getCredentialsFromRequest') + ->will($this->returnValue(null)); + $authenticatorB + ->expects($this->once()) + ->method('getCredentialsFromRequest') + ->will($this->returnValue(null)); + + // this is not called + $this->authenticationManager + ->expects($this->never()) + ->method('authenticate'); + + $this->guardAuthenticatorHandler + ->expects($this->never()) + ->method('handleAuthenticationSuccess'); + + $listener = new GuardAuthenticationListener( + $this->guardAuthenticatorHandler, + $this->authenticationManager, + $providerKey, + array($authenticatorA, $authenticatorB), + $this->logger + ); + + $listener->handle($this->event); + } + + protected function setUp() + { + $this->authenticationManager = $this->getMockBuilder('Symfony\Component\Security\Core\Authentication\AuthenticationProviderManager') + ->disableOriginalConstructor() + ->getMock(); + + $this->guardAuthenticatorHandler = $this->getMockBuilder('Symfony\Component\Security\Guard\GuardAuthenticatorHandler') + ->disableOriginalConstructor() + ->getMock(); + + $this->request = new Request(array(), array(), array(), array(), array(), array()); + + $this->event = $this->getMock('Symfony\Component\HttpKernel\Event\GetResponseEvent', array(), array(), '', false); + $this->event + ->expects($this->any()) + ->method('getRequest') + ->will($this->returnValue($this->request)); + + $this->logger = $this->getMock('Psr\Log\LoggerInterface'); + $this->rememberMeServices = $this->getMock('Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface'); + } + + protected function tearDown() + { + $this->authenticationManager = null; + $this->guardAuthenticatorHandler = null; + $this->event = null; + $this->logger = null; + $this->request = null; + } +} diff --git a/src/Symfony/Component/Security/Guard/Tests/GuardAuthenticatorHandlerTest.php b/src/Symfony/Component/Security/Guard/Tests/GuardAuthenticatorHandlerTest.php new file mode 100644 index 0000000000..6b27e209bb --- /dev/null +++ b/src/Symfony/Component/Security/Guard/Tests/GuardAuthenticatorHandlerTest.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Guard\Tests; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Guard\GuardAuthenticatorHandler; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Http\Event\InteractiveLoginEvent; +use Symfony\Component\Security\Http\SecurityEvents; + +/** + * @author Ryan Weaver + */ +class GuardAuthenticatorHandlerTest extends \PHPUnit_Framework_TestCase +{ + private $tokenStorage; + private $dispatcher; + private $token; + private $request; + private $guardAuthenticator; + + public function testAuthenticateWithToken() + { + $this->tokenStorage->expects($this->once()) + ->method('setToken') + ->with($this->token); + + $loginEvent = new InteractiveLoginEvent($this->request, $this->token); + + $this->dispatcher + ->expects($this->once()) + ->method('dispatch') + ->with($this->equalTo(SecurityEvents::INTERACTIVE_LOGIN), $this->equalTo($loginEvent)) + ; + + $handler = new GuardAuthenticatorHandler($this->tokenStorage, $this->dispatcher); + $handler->authenticateWithToken($this->token, $this->request); + } + + public function testHandleAuthenticationSuccess() + { + $providerKey = 'my_handleable_firewall'; + $response = new Response('Guard all the things!'); + $this->guardAuthenticator->expects($this->once()) + ->method('onAuthenticationSuccess') + ->with($this->request, $this->token, $providerKey) + ->will($this->returnValue($response)); + + $handler = new GuardAuthenticatorHandler($this->tokenStorage, $this->dispatcher); + $actualResponse = $handler->handleAuthenticationSuccess($this->token, $this->request, $this->guardAuthenticator, $providerKey); + $this->assertSame($response, $actualResponse); + } + + public function testHandleAuthenticationFailure() + { + $this->tokenStorage->expects($this->once()) + ->method('setToken') + ->with(null); + $authException = new AuthenticationException('Bad password!'); + + $response = new Response('Try again, but with the right password!'); + $this->guardAuthenticator->expects($this->once()) + ->method('onAuthenticationFailure') + ->with($this->request, $authException) + ->will($this->returnValue($response)); + + $handler = new GuardAuthenticatorHandler($this->tokenStorage, $this->dispatcher); + $actualResponse = $handler->handleAuthenticationFailure($authException, $this->request, $this->guardAuthenticator); + $this->assertSame($response, $actualResponse); + } + + protected function setUp() + { + $this->tokenStorage = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface'); + $this->dispatcher = $this->getMock('Symfony\Component\EventDispatcher\EventDispatcherInterface'); + $this->token = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\TokenInterface'); + $this->request = new Request(array(), array(), array(), array(), array(), array()); + $this->guardAuthenticator = $this->getMock('Symfony\Component\Security\Guard\GuardAuthenticatorInterface'); + } + + protected function tearDown() + { + $this->tokenStorage = null; + $this->dispatcher = null; + $this->token = null; + $this->request = null; + $this->guardAuthenticator = null; + } +} diff --git a/src/Symfony/Component/Security/Guard/Tests/Provider/GuardAuthenticationProviderTest.php b/src/Symfony/Component/Security/Guard/Tests/Provider/GuardAuthenticationProviderTest.php new file mode 100644 index 0000000000..0ef6818659 --- /dev/null +++ b/src/Symfony/Component/Security/Guard/Tests/Provider/GuardAuthenticationProviderTest.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Guard\Tests\Provider; + +use Symfony\Component\Security\Guard\Provider\GuardAuthenticationProvider; + +/** + * @author Ryan Weaver + */ +class GuardAuthenticationProviderTest extends \PHPUnit_Framework_TestCase +{ + private $userProvider; + private $userChecker; + private $nonAuthedToken; + + public function testAuthenticate() + { + $providerKey = 'my_cool_firewall'; + + $authenticatorA = $this->getMock('Symfony\Component\Security\Guard\GuardAuthenticatorInterface'); + $authenticatorB = $this->getMock('Symfony\Component\Security\Guard\GuardAuthenticatorInterface'); + $authenticatorC = $this->getMock('Symfony\Component\Security\Guard\GuardAuthenticatorInterface'); + $authenticators = array($authenticatorA, $authenticatorB, $authenticatorC); + + // called 2 times - for authenticator A and B (stops on B because of match) + $this->nonAuthedToken->expects($this->exactly(2)) + ->method('getGuardProviderKey') + // it will return the "1" index, which will match authenticatorB + ->will($this->returnValue('my_cool_firewall_1')); + + $enteredCredentials = array( + 'username' => '_weaverryan_test_user', + 'password' => 'guard_auth_ftw', + ); + $this->nonAuthedToken->expects($this->once()) + ->method('getCredentials') + ->will($this->returnValue($enteredCredentials)); + + // authenticators A and C are never called + $authenticatorA->expects($this->never()) + ->method('authenticate'); + $authenticatorC->expects($this->never()) + ->method('authenticate'); + + $mockedUser = $this->getMock('Symfony\Component\Security\Core\User\UserInterface'); + $authenticatorB->expects($this->once()) + ->method('authenticate') + ->with($enteredCredentials, $this->userProvider) + ->will($this->returnValue($mockedUser)); + $authedToken = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\TokenInterface'); + $authenticatorB->expects($this->once()) + ->method('createAuthenticatedToken') + ->with($mockedUser, $providerKey) + ->will($this->returnValue($authedToken)); + + // user checker should be called + $this->userChecker->expects($this->once()) + ->method('checkPreAuth') + ->with($mockedUser); + $this->userChecker->expects($this->once()) + ->method('checkPostAuth') + ->with($mockedUser); + + $provider = new GuardAuthenticationProvider($authenticators, $this->userProvider, $providerKey, $this->userChecker); + $actualAuthedToken = $provider->authenticate($this->nonAuthedToken); + $this->assertSame($authedToken, $actualAuthedToken); + } + + protected function setUp() + { + $this->userProvider = $this->getMock('Symfony\Component\Security\Core\User\UserProviderInterface'); + $this->userChecker = $this->getMock('Symfony\Component\Security\Core\User\UserCheckerInterface'); + $this->nonAuthedToken = $this->getMockBuilder('Symfony\Component\Security\Guard\Token\NonAuthenticatedGuardToken') + ->disableOriginalConstructor() + ->getMock(); + } + + protected function tearDown() + { + $this->userProvider = null; + $this->userChecker = null; + $this->nonAuthedToken = null; + } +} diff --git a/src/Symfony/Component/Security/Guard/Token/GenericGuardToken.php b/src/Symfony/Component/Security/Guard/Token/GenericGuardToken.php new file mode 100644 index 0000000000..869d0710c8 --- /dev/null +++ b/src/Symfony/Component/Security/Guard/Token/GenericGuardToken.php @@ -0,0 +1,96 @@ + + */ +class GenericGuardToken extends AbstractToken +{ + private $providerKey; + + /** + * @param UserInterface $user The user! + * @param string $providerKey The provider (firewall) key + * @param RoleInterface[]|string[] $roles An array of roles + * + * @throws \InvalidArgumentException + */ + public function __construct(UserInterface $user, $providerKey, array $roles) + { + parent::__construct($roles); + + if (empty($providerKey)) { + throw new \InvalidArgumentException('$providerKey (i.e. firewall key) must not be empty.'); + } + + $this->setUser($user); + $this->providerKey = $providerKey; + + // this token is meant to be used after authentication success, so it is always authenticated + // you could set it as non authenticated later if you need to + parent::setAuthenticated(true); + } + + /** + * {@inheritdoc} + */ + public function setAuthenticated($isAuthenticated) + { + if ($isAuthenticated) { + throw new \LogicException('Cannot set this token to trusted after instantiation.'); + } + + parent::setAuthenticated(false); + } + + /** + * This is meant to be only an authenticated token, where credentials + * have already been used and are thus cleared. + * + * {@inheritdoc} + */ + public function getCredentials() + { + return array(); + } + + /** + * Returns the provider (firewall) key. + * + * @return string + */ + public function getProviderKey() + { + return $this->providerKey; + } + + /** + * {@inheritdoc} + */ + public function serialize() + { + return serialize(array($this->providerKey, parent::serialize())); + } + + /** + * {@inheritdoc} + */ + public function unserialize($serialized) + { + list($this->providerKey, $parentStr) = unserialize($serialized); + parent::unserialize($parentStr); + } +} diff --git a/src/Symfony/Component/Security/Guard/Token/NonAuthenticatedGuardToken.php b/src/Symfony/Component/Security/Guard/Token/NonAuthenticatedGuardToken.php new file mode 100644 index 0000000000..28e21e0dee --- /dev/null +++ b/src/Symfony/Component/Security/Guard/Token/NonAuthenticatedGuardToken.php @@ -0,0 +1,56 @@ + + */ +class NonAuthenticatedGuardToken extends AbstractToken +{ + private $credentials; + private $guardProviderKey; + + /** + * @param mixed $credentials + * @param string $guardProviderKey Unique key that bind this token to a specific GuardAuthenticatorInterface + */ + public function __construct($credentials, $guardProviderKey) + { + $this->credentials = $credentials; + $this->guardProviderKey = $guardProviderKey; + + parent::__construct(array()); + + // never authenticated + parent::setAuthenticated(false); + } + + public function getGuardProviderKey() + { + return $this->guardProviderKey; + } + + /** + * Returns the user credentials, which might be an array of anything you + * wanted to put in there (e.g. username, password, favoriteColor). + * + * @return mixed The user credentials + */ + public function getCredentials() + { + return $this->credentials; + } + + public function setAuthenticated($authenticated) + { + throw new \Exception('The NonAuthenticatedGuardToken is *always* not authenticated'); + } +}