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..0d58c39b05 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/GuardAuthenticationFactory.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory; + +use Symfony\Component\Config\Definition\Builder\NodeDefinition; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\DefinitionDecorator; +use Symfony\Component\DependencyInjection\Reference; + +/** + * Configures the "guard" authentication provider key under a firewall. + * + * @author Ryan Weaver + */ +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/SecurityBundle.php b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php index 72f7b68de9..f2dcab1061 100644 --- a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php +++ b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php @@ -23,6 +23,7 @@ use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\RemoteUse use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\SimplePreAuthenticationFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\SimpleFormFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\UserProvider\InMemoryFactory; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\GuardAuthenticationFactory; /** * Bundle. @@ -44,6 +45,7 @@ class SecurityBundle extends Bundle $extension->addSecurityListenerFactory(new RemoteUserFactory()); $extension->addSecurityListenerFactory(new SimplePreAuthenticationFactory()); $extension->addSecurityListenerFactory(new SimpleFormFactory()); + $extension->addSecurityListenerFactory(new GuardAuthenticationFactory()); $extension->addUserProviderFactory(new InMemoryFactory()); $container->addCompilerPass(new AddSecurityVotersPass()); 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..cfbc37859b --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/GuardAuthenticationFactoryTest.php @@ -0,0 +1,180 @@ + + * + * 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/Core/Exception/AuthenticationExpiredException.php b/src/Symfony/Component/Security/Core/Exception/AuthenticationExpiredException.php new file mode 100644 index 0000000000..caf2e6cc38 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Exception/AuthenticationExpiredException.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Exception; + +/** + * AuthenticationServiceException is thrown when an authenticated token becomes un-authentcated between requests. + * + * In practice, this is due to the User changing between requests (e.g. password changes), + * causes the token to become un-authenticated. + * + * @author Ryan Weaver + */ +class AuthenticationExpiredException extends AccountStatusException +{ + /** + * {@inheritdoc} + */ + public function getMessageKey() + { + return 'Authentication expired because your account information has changed.'; + } +} diff --git a/src/Symfony/Component/Security/Guard/.gitignore b/src/Symfony/Component/Security/Guard/.gitignore new file mode 100644 index 0000000000..c49a5d8df5 --- /dev/null +++ b/src/Symfony/Component/Security/Guard/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/Security/Guard/AbstractGuardAuthenticator.php b/src/Symfony/Component/Security/Guard/AbstractGuardAuthenticator.php new file mode 100644 index 0000000000..609d772e19 --- /dev/null +++ b/src/Symfony/Component/Security/Guard/AbstractGuardAuthenticator.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Guard; + +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Guard\Token\PostAuthenticationGuardToken; + +/** + * An optional base class that creates a PostAuthenticationGuardToken for you. + * + * @author Ryan Weaver + */ +abstract class AbstractGuardAuthenticator implements GuardAuthenticatorInterface +{ + /** + * Shortcut to create a PostAuthenticationGuardToken for you, if you don't really + * care about which authenticated token you're using. + * + * @param UserInterface $user + * @param string $providerKey + * + * @return PostAuthenticationGuardToken + */ + public function createAuthenticatedToken(UserInterface $user, $providerKey) + { + return new PostAuthenticationGuardToken( + $user, + $providerKey, + $user->getRoles() + ); + } +} diff --git a/src/Symfony/Component/Security/Guard/Authenticator/AbstractFormLoginAuthenticator.php b/src/Symfony/Component/Security/Guard/Authenticator/AbstractFormLoginAuthenticator.php new file mode 100644 index 0000000000..b3c6bd73a0 --- /dev/null +++ b/src/Symfony/Component/Security/Guard/Authenticator/AbstractFormLoginAuthenticator.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Guard\Authenticator; + +use Symfony\Component\Security\Guard\AbstractGuardAuthenticator; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Security; + +/** + * A base class to make form login authentication easier! + * + * @author Ryan Weaver + */ +abstract class AbstractFormLoginAuthenticator extends AbstractGuardAuthenticator +{ + /** + * Return the URL to the login page. + * + * @return string + */ + abstract protected function getLoginUrl(); + + /** + * The user will be redirected to the secure page they originally tried + * to access. But if no such page exists (i.e. the user went to the + * login page directly), this returns the URL the user should be redirected + * to after logging in successfully (e.g. your homepage). + * + * @return string + */ + abstract protected function getDefaultSuccessRedirectUrl(); + + /** + * Override to change what happens after a bad username/password is submitted. + * + * @param Request $request + * @param AuthenticationException $exception + * + * @return RedirectResponse + */ + public function onAuthenticationFailure(Request $request, AuthenticationException $exception) + { + $request->getSession()->set(Security::AUTHENTICATION_ERROR, $exception); + $url = $this->getLoginUrl(); + + return new RedirectResponse($url); + } + + /** + * Override to change what happens after successful authentication. + * + * @param Request $request + * @param TokenInterface $token + * @param string $providerKey + * + * @return RedirectResponse + */ + public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey) + { + // if the user hit a secure page and start() was called, this was + // the URL they were on, and probably where you want to redirect to + $targetPath = $request->getSession()->get('_security.'.$providerKey.'.target_path'); + + if (!$targetPath) { + $targetPath = $this->getDefaultSuccessRedirectUrl(); + } + + return new RedirectResponse($targetPath); + } + + public function supportsRememberMe() + { + return true; + } + + /** + * Override to control what happens when the user hits a secure page + * but isn't logged in yet. + * + * @param Request $request + * @param AuthenticationException|null $authException + * + * @return RedirectResponse + */ + public function start(Request $request, AuthenticationException $authException = null) + { + $url = $this->getLoginUrl(); + + return new RedirectResponse($url); + } +} 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..6140be0980 --- /dev/null +++ b/src/Symfony/Component/Security/Guard/Firewall/GuardAuthenticationListener.php @@ -0,0 +1,193 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Guard\Firewall; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\GetResponseEvent; +use Symfony\Component\Security\Guard\GuardAuthenticatorHandler; +use Symfony\Component\Security\Guard\Token\PreAuthenticationGuardToken; +use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; +use Symfony\Component\Security\Guard\GuardAuthenticatorInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Http\Firewall\ListenerInterface; +use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface; + +/** + * Authentication listener for the "guard" system. + * + * @author Ryan Weaver + */ +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, array $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 getCredentials on guard configurator.', array('firewall_key' => $this->providerKey, 'authenticator' => get_class($guardAuthenticator))); + } + + // allow the authenticator to fetch authentication info from the request + $credentials = $guardAuthenticator->getCredentials($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 PreAuthenticationGuardToken($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, $this->providerKey); + + 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 (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 (!$guardAuthenticator->supportsRememberMe()) { + if (null !== $this->logger) { + $this->logger->info('Remember me skipped: your authenticator does not support it.', 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); + } +} diff --git a/src/Symfony/Component/Security/Guard/GuardAuthenticatorHandler.php b/src/Symfony/Component/Security/Guard/GuardAuthenticatorHandler.php new file mode 100644 index 0000000000..5e1351dcc2 --- /dev/null +++ b/src/Symfony/Component/Security/Guard/GuardAuthenticatorHandler.php @@ -0,0 +1,139 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Guard; + +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +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\User\UserInterface; +use Symfony\Component\Security\Guard\Token\PostAuthenticationGuardToken; +use Symfony\Component\Security\Http\Event\InteractiveLoginEvent; +use Symfony\Component\Security\Http\SecurityEvents; + +/** + * A utility class that does much of the *work* during the guard authentication process. + * + * By having the logic here instead of the listener, more of the process + * can be called directly (e.g. for manual authentication) or overridden. + * + * @author Ryan Weaver + */ +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 + * @param string $providerKey The key of the firewall + * + * @return null|Response + */ + public function handleAuthenticationFailure(AuthenticationException $authenticationException, Request $request, GuardAuthenticatorInterface $guardAuthenticator, $providerKey) + { + $token = $this->tokenStorage->getToken(); + if ($token instanceof PostAuthenticationGuardToken && $providerKey === $token->getProviderKey()) { + $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) + )); + } +} diff --git a/src/Symfony/Component/Security/Guard/GuardAuthenticatorInterface.php b/src/Symfony/Component/Security/Guard/GuardAuthenticatorInterface.php new file mode 100644 index 0000000000..2db313cc2b --- /dev/null +++ b/src/Symfony/Component/Security/Guard/GuardAuthenticatorInterface.php @@ -0,0 +1,150 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Guard; + +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\User\UserInterface; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Guard\Token\GuardTokenInterface; +use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; + +/** + * The interface for all "guard" authenticators. + * + * The methods on this interface are called throughout the guard authentication + * process to give you the power to control most parts of the process from + * one location. + * + * @author Ryan Weaver + */ +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 getUser() and checkCredentials() + * + * 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 getCredentials(Request $request); + + /** + * Return a UserInterface object based on the credentials. + * + * The *credentials* are the return value from getCredentials() + * + * You may throw an AuthenticationException if you wish. If you return + * null, then a UsernameNotFoundException is thrown for you. + * + * @param mixed $credentials + * @param UserProviderInterface $userProvider + * + * @throws AuthenticationException + * + * @return UserInterface|null + */ + public function getUser($credentials, UserProviderInterface $userProvider); + + /** + * Throw an AuthenticationException if the credentials are invalid. + * + * The *credentials* are the return value from getCredentials() + * + * @param mixed $credentials + * @param UserInterface $user + * + * @throws AuthenticationException + */ + public function checkCredentials($credentials, UserInterface $user); + + /** + * 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 GuardTokenInterface + */ + 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/LICENSE b/src/Symfony/Component/Security/Guard/LICENSE new file mode 100644 index 0000000000..43028bc600 --- /dev/null +++ b/src/Symfony/Component/Security/Guard/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2004-2015 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. 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..2a58085bae --- /dev/null +++ b/src/Symfony/Component/Security/Guard/Provider/GuardAuthenticationProvider.php @@ -0,0 +1,145 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Guard\Provider; + +use Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface; +use Symfony\Component\Security\Core\Authentication\Token\AnonymousToken; +use Symfony\Component\Security\Core\Exception\UsernameNotFoundException; +use Symfony\Component\Security\Guard\GuardAuthenticatorInterface; +use Symfony\Component\Security\Guard\Token\GuardTokenInterface; +use Symfony\Component\Security\Guard\Token\PreAuthenticationGuardToken; +use Symfony\Component\Security\Core\User\UserCheckerInterface; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationExpiredException; + +/** + * Responsible for accepting the PreAuthenticationGuardToken and calling + * the correct authenticator to retrieve the authenticated token. + * + * @author Ryan Weaver + */ +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 GuardTokenInterface $token + * + * @return TokenInterface + */ + public function authenticate(TokenInterface $token) + { + if (!$this->supports($token)) { + throw new \InvalidArgumentException('GuardAuthenticationProvider only supports GuardTokenInterface.'); + } + + if (!$token instanceof PreAuthenticationGuardToken) { + /* + * The listener *only* passes PreAuthenticationGuardToken instances. + * This means that an authenticated token (e.g. PostAuthenticationGuardToken) + * is being passed here, which happens if that token becomes + * "not authenticated" (e.g. happens if the user changes between + * requests). In this case, the user should be logged out, so + * we will return an AnonymousToken to accomplish that. + */ + + // this should never happen - but technically, the token is + // authenticated... so it could just be returned + if ($token->isAuthenticated()) { + return $token; + } + + // this AccountStatusException causes the user to be logged out + throw new AuthenticationExpiredException(); + } + + // 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); + } + } + + // no matching authenticator found - but there will be multiple GuardAuthenticationProvider + // instances that will be checked if you have multiple firewalls. + } + + private function authenticateViaGuard(GuardAuthenticatorInterface $guardAuthenticator, PreAuthenticationGuardToken $token) + { + // get the user from the GuardAuthenticator + $user = $guardAuthenticator->getUser($token->getCredentials(), $this->userProvider); + + if (null === $user) { + throw new UsernameNotFoundException(sprintf( + 'Null returned from %s::getUser()', + get_class($guardAuthenticator) + )); + } + + if (!$user instanceof UserInterface) { + throw new \UnexpectedValueException(sprintf( + 'The %s::getUser() method must return a UserInterface. You returned %s.', + get_class($guardAuthenticator), + is_object($user) ? get_class($user) : gettype($user) + )); + } + + $this->userChecker->checkPreAuth($user); + $guardAuthenticator->checkCredentials($token->getCredentials(), $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 GuardTokenInterface; + } +} diff --git a/src/Symfony/Component/Security/Guard/README.md b/src/Symfony/Component/Security/Guard/README.md new file mode 100644 index 0000000000..845340f923 --- /dev/null +++ b/src/Symfony/Component/Security/Guard/README.md @@ -0,0 +1,22 @@ +Security Component - Guard +========================== + +The Guard component brings many layers of authentication together, making +it much easier to create complex authentication systems where you have +total control. + +Resources +--------- + +Documentation: + +https://symfony.com/doc/2.8/book/security.html + +Tests +----- + +You can run the unit tests with the following command: + + $ cd path/to/Symfony/Component/Security/Guard/ + $ composer.phar install --dev + $ phpunit 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..8fab3991f9 --- /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\PreAuthenticationGuardToken; +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('getCredentials') + ->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 PreAuthenticationGuardToken($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('getCredentials') + ->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('getCredentials') + ->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, $providerKey); + + $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('getCredentials') + ->will($this->returnValue(null)); + $authenticatorB + ->expects($this->once()) + ->method('getCredentials') + ->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..6f36702df1 --- /dev/null +++ b/src/Symfony/Component/Security/Guard/Tests/GuardAuthenticatorHandlerTest.php @@ -0,0 +1,141 @@ + + * + * 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; + +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() + { + // setToken() not called - getToken() will return null, so there's nothing to clear + $this->tokenStorage->expects($this->never()) + ->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, 'firewall_provider_key'); + $this->assertSame($response, $actualResponse); + } + + /** + * @dataProvider getTokenClearingTests + */ + public function testHandleAuthenticationClearsToken($tokenClass, $tokenProviderKey, $actualProviderKey, $shouldTokenBeCleared) + { + $token = $this->getMockBuilder($tokenClass) + ->disableOriginalConstructor() + ->getMock(); + $token->expects($this->any()) + ->method('getProviderKey') + ->will($this->returnValue($tokenProviderKey)); + + // make the $token be the current token + $this->tokenStorage->expects($this->once()) + ->method('getToken') + ->will($this->returnValue($token)); + + $this->tokenStorage->expects($shouldTokenBeCleared ? $this->once() : $this->never()) + ->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, $actualProviderKey); + $this->assertSame($response, $actualResponse); + } + + public function getTokenClearingTests() + { + $tests = array(); + // correct token class and matching firewall => clear the token + $tests[] = array('Symfony\Component\Security\Guard\Token\PostAuthenticationGuardToken', 'the_firewall_key', 'the_firewall_key', true); + $tests[] = array('Symfony\Component\Security\Guard\Token\PostAuthenticationGuardToken', 'the_firewall_key', 'different_key', false); + $tests[] = array('Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken', 'the_firewall_key', 'the_firewall_key', false); + + return $tests; + } + + 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..33c00e5073 --- /dev/null +++ b/src/Symfony/Component/Security/Guard/Tests/Provider/GuardAuthenticationProviderTest.php @@ -0,0 +1,115 @@ + + * + * 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; +use Symfony\Component\Security\Guard\Token\PostAuthenticationGuardToken; + +/** + * @author Ryan Weaver + */ +class GuardAuthenticationProviderTest extends \PHPUnit_Framework_TestCase +{ + private $userProvider; + private $userChecker; + private $preAuthenticationToken; + + 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->preAuthenticationToken->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->preAuthenticationToken->expects($this->atLeastOnce()) + ->method('getCredentials') + ->will($this->returnValue($enteredCredentials)); + + // authenticators A and C are never called + $authenticatorA->expects($this->never()) + ->method('getUser'); + $authenticatorC->expects($this->never()) + ->method('getUser'); + + $mockedUser = $this->getMock('Symfony\Component\Security\Core\User\UserInterface'); + $authenticatorB->expects($this->once()) + ->method('getUser') + ->with($enteredCredentials, $this->userProvider) + ->will($this->returnValue($mockedUser)); + // checkCredentials is called + $authenticatorB->expects($this->once()) + ->method('checkCredentials') + ->with($enteredCredentials, $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->preAuthenticationToken); + $this->assertSame($authedToken, $actualAuthedToken); + } + + /** + * @expectedException \Symfony\Component\Security\Core\Exception\AuthenticationExpiredException + */ + public function testGuardWithNoLongerAuthenticatedTriggersLogout() + { + $providerKey = 'my_firewall_abc'; + + // create a token and mark it as NOT authenticated anymore + // this mimics what would happen if a user "changed" between request + $mockedUser = $this->getMock('Symfony\Component\Security\Core\User\UserInterface'); + $token = new PostAuthenticationGuardToken($mockedUser, $providerKey, array('ROLE_USER')); + $token->setAuthenticated(false); + + $provider = new GuardAuthenticationProvider(array(), $this->userProvider, $providerKey, $this->userChecker); + $actualToken = $provider->authenticate($token); + } + + protected function setUp() + { + $this->userProvider = $this->getMock('Symfony\Component\Security\Core\User\UserProviderInterface'); + $this->userChecker = $this->getMock('Symfony\Component\Security\Core\User\UserCheckerInterface'); + $this->preAuthenticationToken = $this->getMockBuilder('Symfony\Component\Security\Guard\Token\PreAuthenticationGuardToken') + ->disableOriginalConstructor() + ->getMock(); + } + + protected function tearDown() + { + $this->userProvider = null; + $this->userChecker = null; + $this->preAuthenticationToken = null; + } +} diff --git a/src/Symfony/Component/Security/Guard/Token/GuardTokenInterface.php b/src/Symfony/Component/Security/Guard/Token/GuardTokenInterface.php new file mode 100644 index 0000000000..f0db250dd3 --- /dev/null +++ b/src/Symfony/Component/Security/Guard/Token/GuardTokenInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Guard\Token; + +/** + * A marker interface that both guard tokens implement. + * + * Any tokens passed to GuardAuthenticationProvider (i.e. any tokens that + * are handled by the guard auth system) must implement this + * interface. + * + * @author Ryan Weaver + */ +interface GuardTokenInterface +{ +} diff --git a/src/Symfony/Component/Security/Guard/Token/PostAuthenticationGuardToken.php b/src/Symfony/Component/Security/Guard/Token/PostAuthenticationGuardToken.php new file mode 100644 index 0000000000..36c40cab95 --- /dev/null +++ b/src/Symfony/Component/Security/Guard/Token/PostAuthenticationGuardToken.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Guard\Token; + +use Symfony\Component\Security\Core\Authentication\Token\AbstractToken; +use Symfony\Component\Security\Core\Role\RoleInterface; +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * Used as an "authenticated" token, though it could be set to not-authenticated later. + * + * If you're using Guard authentication, you *must* use a class that implements + * GuardTokenInterface as your authenticated token (like this class). + * + * @author Ryan Weaver n@gmail.com> + */ +class PostAuthenticationGuardToken extends AbstractToken implements GuardTokenInterface +{ + 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); + } + + /** + * 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/PreAuthenticationGuardToken.php b/src/Symfony/Component/Security/Guard/Token/PreAuthenticationGuardToken.php new file mode 100644 index 0000000000..abbe985c9e --- /dev/null +++ b/src/Symfony/Component/Security/Guard/Token/PreAuthenticationGuardToken.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Guard\Token; + +use Symfony\Component\Security\Core\Authentication\Token\AbstractToken; + +/** + * The token used by the guard auth system before authentication. + * + * The GuardAuthenticationListener creates this, which is then consumed + * immediately by the GuardAuthenticationProvider. If authentication is + * successful, a different authenticated token is returned + * + * @author Ryan Weaver + */ +class PreAuthenticationGuardToken extends AbstractToken implements GuardTokenInterface +{ + 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 \LogicException('The PreAuthenticationGuardToken is *never* authenticated.'); + } +} diff --git a/src/Symfony/Component/Security/Guard/composer.json b/src/Symfony/Component/Security/Guard/composer.json new file mode 100644 index 0000000000..1e0dc8cedd --- /dev/null +++ b/src/Symfony/Component/Security/Guard/composer.json @@ -0,0 +1,36 @@ +{ + "name": "symfony/security-guard", + "type": "library", + "description": "Symfony Security Component - Guard", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=5.3.9", + "symfony/security-core": "~2.8|~3.0.0", + "symfony/security-http": "~2.8|~3.0.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "~2.8|~3.0.0", + "psr/log": "~1.0" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Security\\Guard\\": "" } + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "2.8-dev" + } + } +} diff --git a/src/Symfony/Component/Security/Guard/phpunit.xml.dist b/src/Symfony/Component/Security/Guard/phpunit.xml.dist new file mode 100644 index 0000000000..093628b332 --- /dev/null +++ b/src/Symfony/Component/Security/Guard/phpunit.xml.dist @@ -0,0 +1,33 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./vendor + ./Tests + + + + diff --git a/src/Symfony/Component/Security/Http/EntryPoint/AuthenticationEntryPointInterface.php b/src/Symfony/Component/Security/Http/EntryPoint/AuthenticationEntryPointInterface.php index 0d7595d407..df777f688c 100644 --- a/src/Symfony/Component/Security/Http/EntryPoint/AuthenticationEntryPointInterface.php +++ b/src/Symfony/Component/Security/Http/EntryPoint/AuthenticationEntryPointInterface.php @@ -16,15 +16,25 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; /** - * AuthenticationEntryPointInterface is the interface used to start the - * authentication scheme. + * Implement this interface for any classes that will be called to "start" + * the authentication process (see method for more details). * * @author Fabien Potencier */ interface AuthenticationEntryPointInterface { /** - * Starts the authentication scheme. + * Returns a response that directs the user to authenticate. + * + * This is called when an anonymous request accesses a resource that + * requires authentication. The job of this method is to return some + * response that "helps" the user start into the authentication process. + * + * Examples: + * A) For a form login, you might redirect to the login page + * return new Response('/login'); + * B) For an API token authentication system, you return a 401 response + * return new Response('Auth header required', 401); * * @param Request $request The request that resulted in an AuthenticationException * @param AuthenticationException $authException The exception that started the authentication process diff --git a/src/Symfony/Component/Security/phpunit.xml.dist b/src/Symfony/Component/Security/phpunit.xml.dist index 7747b371c1..c0dbb2d299 100644 --- a/src/Symfony/Component/Security/phpunit.xml.dist +++ b/src/Symfony/Component/Security/phpunit.xml.dist @@ -15,6 +15,7 @@ ./Acl/Tests/ ./Core/Tests/ ./Http/Tests/ + ./Guard/Tests/