From 4261e6a3e0e187fbc13fc6e73363147895ee879c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Thu, 2 Jun 2016 13:58:58 +0200 Subject: [PATCH 1/4] [Security] Add UsernamePasswordJsonAuthenticationListener class --- ...namePasswordJsonAuthenticationListener.php | 74 ++++++++++++ ...PasswordJsonAuthenticationListenerTest.php | 107 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 src/Symfony/Component/Security/Http/Firewall/UsernamePasswordJsonAuthenticationListener.php create mode 100644 src/Symfony/Component/Security/Tests/Http/Firewall/UsernamePasswordJsonAuthenticationListenerTest.php diff --git a/src/Symfony/Component/Security/Http/Firewall/UsernamePasswordJsonAuthenticationListener.php b/src/Symfony/Component/Security/Http/Firewall/UsernamePasswordJsonAuthenticationListener.php new file mode 100644 index 0000000000000..dbd8ad93b9f9c --- /dev/null +++ b/src/Symfony/Component/Security/Http/Firewall/UsernamePasswordJsonAuthenticationListener.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Firewall; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; +use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; +use Symfony\Component\Security\Http\HttpUtils; +use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface; + +/** + * UsernamePasswordJsonAuthenticationListener is an implementation of + * an authentication via a JSON document composed of a username and a password. + * + * @author Kévin Dunglas + */ +class UsernamePasswordJsonAuthenticationListener extends AbstractAuthenticationListener +{ + public function __construct(TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager, SessionAuthenticationStrategyInterface $sessionStrategy, HttpUtils $httpUtils, $providerKey, AuthenticationSuccessHandlerInterface $successHandler, AuthenticationFailureHandlerInterface $failureHandler, array $options = array(), LoggerInterface $logger = null, EventDispatcherInterface $dispatcher = null) + { + parent::__construct($tokenStorage, $authenticationManager, $sessionStrategy, $httpUtils, $providerKey, $successHandler, $failureHandler, array_merge(array( + 'username_parameter' => '_username', + 'password_parameter' => '_password', + ), $options), $logger, $dispatcher); + } + + /** + * {@inheritdoc} + */ + protected function attemptAuthentication(Request $request) + { + $data = json_decode($request->getContent(), true); + + if (!isset($data[$this->options['username_parameter']])) { + throw new BadCredentialsException(sprintf('Missing key "%s".', $this->options['username_parameter'])); + } + + if (!isset($data[$this->options['password_parameter']])) { + throw new BadCredentialsException(sprintf('Missing key "%s".', $this->options['password_parameter'])); + } + + $username = $data[$this->options['username_parameter']]; + if (!is_string($username)) { + throw new BadCredentialsException(sprintf('The key "%s" must contain a string.', $this->options['username_parameter'])); + } + + if (strlen($username) > Security::MAX_USERNAME_LENGTH) { + throw new BadCredentialsException('Invalid username.'); + } + + $password = $data[$this->options['password_parameter']]; + if (!is_string($password)) { + throw new BadCredentialsException(sprintf('The key "%s" must contain a string.', $this->options['password_parameter'])); + } + + return $this->authenticationManager->authenticate(new UsernamePasswordToken($username, $password, $this->providerKey)); + } +} diff --git a/src/Symfony/Component/Security/Tests/Http/Firewall/UsernamePasswordJsonAuthenticationListenerTest.php b/src/Symfony/Component/Security/Tests/Http/Firewall/UsernamePasswordJsonAuthenticationListenerTest.php new file mode 100644 index 0000000000000..6376426b669fd --- /dev/null +++ b/src/Symfony/Component/Security/Tests/Http/Firewall/UsernamePasswordJsonAuthenticationListenerTest.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Tests\Http\Firewall; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; +use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; +use Symfony\Component\Security\Http\Firewall\UsernamePasswordJsonAuthenticationListener; +use Symfony\Component\Security\Http\HttpUtils; +use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface; + +/** + * @author Kévin Dunglas + */ +class UsernamePasswordJsonAuthenticationListenerTest extends \PHPUnit_Framework_TestCase +{ + /** + * @var UsernamePasswordJsonAuthenticationListener + */ + private $listener; + + /** + * @var \ReflectionMethod + */ + private $attemptAuthenticationMethod; + + protected function setUp() { + $tokenStorage = $this->getMock(TokenStorageInterface::class); + $authenticationManager = $this->getMock(AuthenticationManagerInterface::class); + $authenticationManager->method('authenticate')->willReturn(true); + $sessionAuthenticationStrategyInterface = $this->getMock(SessionAuthenticationStrategyInterface::class); + $httpUtils = $this->getMock(HttpUtils::class); + $authenticationSuccessHandler = $this->getMock(AuthenticationSuccessHandlerInterface::class); + $authenticationFailureHandler = $this->getMock(AuthenticationFailureHandlerInterface::class); + + $this->listener = new UsernamePasswordJsonAuthenticationListener($tokenStorage, $authenticationManager, $sessionAuthenticationStrategyInterface, $httpUtils, 'providerKey', $authenticationSuccessHandler, $authenticationFailureHandler); + $this->attemptAuthenticationMethod = new \ReflectionMethod($this->listener, 'attemptAuthentication'); + $this->attemptAuthenticationMethod->setAccessible(true); + } + + public function testAttemptAuthentication() + { + $request = new Request(array(), array(), array(), array(), array(), array(), '{"_username": "dunglas", "_password": "foo"}'); + + $result = $this->attemptAuthenticationMethod->invokeArgs($this->listener, array($request)); + $this->assertTrue($result); + } + + /** + * @expectedException \Symfony\Component\Security\Core\Exception\BadCredentialsException + */ + public function testAttemptAuthenticationNoUsername() + { + $request = new Request(array(), array(), array(), array(), array(), array(), '{"usr": "dunglas", "_password": "foo"}'); + $this->attemptAuthenticationMethod->invokeArgs($this->listener, array($request)); + } + + /** + * @expectedException \Symfony\Component\Security\Core\Exception\BadCredentialsException + */ + public function testAttemptAuthenticationNoPassword() + { + $request = new Request(array(), array(), array(), array(), array(), array(), '{"_username": "dunglas", "pass": "foo"}'); + $this->attemptAuthenticationMethod->invokeArgs($this->listener, array($request)); + } + + /** + * @expectedException \Symfony\Component\Security\Core\Exception\BadCredentialsException + */ + public function testAttemptAuthenticationUsernameNotAString() + { + $request = new Request(array(), array(), array(), array(), array(), array(), '{"_username": 1, "_password": "foo"}'); + $this->attemptAuthenticationMethod->invokeArgs($this->listener, array($request)); + } + + /** + * @expectedException \Symfony\Component\Security\Core\Exception\BadCredentialsException + */ + public function testAttemptAuthenticationPasswordNotAString() + { + $request = new Request(array(), array(), array(), array(), array(), array(), '{"_username": "dunglas", "_password": 1}'); + $this->attemptAuthenticationMethod->invokeArgs($this->listener, array($request)); + } + + /** + * @expectedException \Symfony\Component\Security\Core\Exception\BadCredentialsException + */ + public function testAttemptAuthenticationUsernameTooLong() + { + $username = str_repeat('x', Security::MAX_USERNAME_LENGTH + 1); + $request = new Request(array(), array(), array(), array(), array(), array(), sprintf('{"_username": "%s", "_password": 1}', $username)); + + $this->attemptAuthenticationMethod->invokeArgs($this->listener, array($request)); + } +} From 84114ad9b4d9e48909ea289302b6d5120470c009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Thu, 2 Jun 2016 14:14:04 +0200 Subject: [PATCH 2/4] [SecurityBundle] Register the UsernamePasswordJsonAuthenticationListener class --- .../Security/Factory/JsonLoginFactory.php | 96 ++++++++++++++ .../Resources/config/security_listeners.xml | 15 ++- .../Bundle/SecurityBundle/SecurityBundle.php | 2 + .../Controller/TestController.php | 23 ++++ .../JsonLoginBundle/JsonLoginBundle.php | 21 +++ .../Tests/Functional/JsonLoginTest.php | 32 +++++ .../Functional/app/JsonLogin/bundles.php | 16 +++ .../Tests/Functional/app/JsonLogin/config.yml | 24 ++++ .../Functional/app/JsonLogin/routing.yml | 3 + ...namePasswordJsonAuthenticationListener.php | 120 +++++++++++++++--- ...PasswordJsonAuthenticationListenerTest.php | 94 ++++++++++---- 11 files changed, 397 insertions(+), 49 deletions(-) create mode 100644 src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/JsonLoginFactory.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/JsonLoginBundle/Controller/TestController.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/JsonLoginBundle/JsonLoginBundle.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/JsonLoginTest.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/bundles.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/config.yml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/routing.yml diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/JsonLoginFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/JsonLoginFactory.php new file mode 100644 index 0000000000000..dbd29fa53cfd1 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/JsonLoginFactory.php @@ -0,0 +1,96 @@ + + * + * 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\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\DefinitionDecorator; +use Symfony\Component\DependencyInjection\Reference; + +/** + * JsonLoginFactory creates services for JSON login authentication. + * + * @author Kévin Dunglas + */ +class JsonLoginFactory extends AbstractFactory +{ + public function __construct() + { + $this->addOption('username_path', 'username'); + $this->addOption('password_path', 'password'); + } + + /** + * {@inheritdoc} + */ + public function getPosition() + { + return 'form'; + } + + /** + * {@inheritdoc} + */ + public function getKey() + { + return 'json-login'; + } + + /** + * {@inheritdoc} + */ + protected function createAuthProvider(ContainerBuilder $container, $id, $config, $userProviderId) + { + $provider = 'security.authentication.provider.dao.'.$id; + $container + ->setDefinition($provider, new DefinitionDecorator('security.authentication.provider.dao')) + ->replaceArgument(0, new Reference($userProviderId)) + ->replaceArgument(1, new Reference('security.user_checker.'.$id)) + ->replaceArgument(2, $id) + ; + + return $provider; + } + + /** + * {@inheritdoc} + */ + protected function getListenerId() + { + return 'security.authentication.listener.json'; + } + + /** + * {@inheritdoc} + */ + protected function isRememberMeAware($config) + { + return false; + } + + /** + * {@inheritdoc} + */ + protected function createListener($container, $id, $config, $userProvider) + { + $listenerId = $this->getListenerId(); + $listener = new DefinitionDecorator($listenerId); + $listener->replaceArgument(2, $id); + $listener->replaceArgument(3, new Reference($this->createAuthenticationSuccessHandler($container, $id, $config))); + $listener->replaceArgument(4, new Reference($this->createAuthenticationFailureHandler($container, $id, $config))); + $listener->replaceArgument(5, array_intersect_key($config, $this->options)); + + $listenerId .= '.'.$id; + $container->setDefinition($listenerId, $listener); + + return $listenerId; + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml index 7bdaf628cf40c..f6b9cbf811ee3 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml @@ -140,7 +140,20 @@ - + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php index f2dfc991fbcef..c50aab24e12e4 100644 --- a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php +++ b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php @@ -11,6 +11,7 @@ namespace Symfony\Bundle\SecurityBundle; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\JsonLoginFactory; use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\AddSecurityVotersPass; @@ -42,6 +43,7 @@ public function build(ContainerBuilder $container) $extension = $container->getExtension('security'); $extension->addSecurityListenerFactory(new FormLoginFactory()); $extension->addSecurityListenerFactory(new FormLoginLdapFactory()); + $extension->addSecurityListenerFactory(new JsonLoginFactory()); $extension->addSecurityListenerFactory(new HttpBasicFactory()); $extension->addSecurityListenerFactory(new HttpBasicLdapFactory()); $extension->addSecurityListenerFactory(new HttpDigestFactory()); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/JsonLoginBundle/Controller/TestController.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/JsonLoginBundle/Controller/TestController.php new file mode 100644 index 0000000000000..ae32244364e42 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/JsonLoginBundle/Controller/TestController.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\JsonLoginBundle\Controller; + +/** + * @author Kévin Dunglas + */ +class TestController +{ + public function loginCheckAction() + { + throw new \RuntimeException(sprintf('%s should never be called.', __FUNCTION__)); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/JsonLoginBundle/JsonLoginBundle.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/JsonLoginBundle/JsonLoginBundle.php new file mode 100644 index 0000000000000..88b57859ad24c --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/JsonLoginBundle/JsonLoginBundle.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\JsonLoginBundle; + +use Symfony\Component\HttpKernel\Bundle\Bundle; + +/** + * @author Kévin Dunglas + */ +class JsonLoginBundle extends Bundle +{ +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/JsonLoginTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/JsonLoginTest.php new file mode 100644 index 0000000000000..5d13a2d649be2 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/JsonLoginTest.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\Functional; + +/** + * @author Kévin Dunglas + */ +class JsonLoginTest extends WebTestCase +{ + public function testJsonLoginSuccess() + { + $client = $this->createClient(array('test_case' => 'JsonLogin', 'root_config' => 'config.yml')); + $client->request('POST', '/chk', array(), array(), array(), '{"user": {"login": "dunglas", "password": "foo"}}'); + $this->assertEquals('http://localhost/', $client->getResponse()->headers->get('location')); + } + + public function testJsonLoginFailure() + { + $client = $this->createClient(array('test_case' => 'JsonLogin', 'root_config' => 'config.yml')); + $client->request('POST', '/chk', array(), array(), array(), '{"user": {"login": "dunglas", "password": "bad"}}'); + $this->assertEquals('http://localhost/login', $client->getResponse()->headers->get('location')); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/bundles.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/bundles.php new file mode 100644 index 0000000000000..d6e121031861c --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/bundles.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +return array( + new Symfony\Bundle\SecurityBundle\SecurityBundle(), + new Symfony\Bundle\FrameworkBundle\FrameworkBundle(), + new Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\JsonLoginBundle\JsonLoginBundle(), +); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/config.yml new file mode 100644 index 0000000000000..8234b21727135 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/config.yml @@ -0,0 +1,24 @@ +imports: + - { resource: ./../config/framework.yml } + +security: + encoders: + Symfony\Component\Security\Core\User\User: plaintext + + providers: + in_memory: + memory: + users: + dunglas: { password: foo, roles: [ROLE_USER] } + + firewalls: + main: + pattern: ^/ + anonymous: true + json_login: + check_path: /mychk + username_path: user.login + password_path: user.password + + access_control: + - { path: ^/foo, roles: ROLE_USER } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/routing.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/routing.yml new file mode 100644 index 0000000000000..ee49b4829bdd7 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/routing.yml @@ -0,0 +1,3 @@ +login_check: + path: /chk + defaults: { _controller: JsonLoginBundle:Test:loginCheck } diff --git a/src/Symfony/Component/Security/Http/Firewall/UsernamePasswordJsonAuthenticationListener.php b/src/Symfony/Component/Security/Http/Firewall/UsernamePasswordJsonAuthenticationListener.php index dbd8ad93b9f9c..d0ad64ddfdd97 100644 --- a/src/Symfony/Component/Security/Http/Firewall/UsernamePasswordJsonAuthenticationListener.php +++ b/src/Symfony/Component/Security/Http/Firewall/UsernamePasswordJsonAuthenticationListener.php @@ -14,61 +14,141 @@ use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\GetResponseEvent; +use Symfony\Component\PropertyAccess\Exception\AccessException; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; -use Symfony\Component\Security\Http\HttpUtils; -use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface; +use Symfony\Component\Security\Http\Event\InteractiveLoginEvent; +use Symfony\Component\Security\Http\SecurityEvents; /** - * UsernamePasswordJsonAuthenticationListener is an implementation of + * UsernamePasswordJsonAuthenticationListener is a stateless implementation of * an authentication via a JSON document composed of a username and a password. * * @author Kévin Dunglas */ -class UsernamePasswordJsonAuthenticationListener extends AbstractAuthenticationListener +class UsernamePasswordJsonAuthenticationListener implements ListenerInterface { - public function __construct(TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager, SessionAuthenticationStrategyInterface $sessionStrategy, HttpUtils $httpUtils, $providerKey, AuthenticationSuccessHandlerInterface $successHandler, AuthenticationFailureHandlerInterface $failureHandler, array $options = array(), LoggerInterface $logger = null, EventDispatcherInterface $dispatcher = null) + private $tokenStorage; + private $authenticationManager; + private $providerKey; + private $successHandler; + private $failureHandler; + private $options; + private $logger; + private $eventDispatcher; + private $propertyAccessor; + + public function __construct(TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager, $providerKey, AuthenticationSuccessHandlerInterface $successHandler, AuthenticationFailureHandlerInterface $failureHandler, array $options = array(), LoggerInterface $logger = null, EventDispatcherInterface $eventDispatcher = null, PropertyAccessorInterface $propertyAccessor = null) { - parent::__construct($tokenStorage, $authenticationManager, $sessionStrategy, $httpUtils, $providerKey, $successHandler, $failureHandler, array_merge(array( - 'username_parameter' => '_username', - 'password_parameter' => '_password', - ), $options), $logger, $dispatcher); + $this->tokenStorage = $tokenStorage; + $this->authenticationManager = $authenticationManager; + $this->providerKey = $providerKey; + $this->successHandler = $successHandler; + $this->failureHandler = $failureHandler; + $this->logger = $logger; + $this->eventDispatcher = $eventDispatcher; + $this->options = array_merge(array('username_path' => 'username', 'password_path' => 'password'), $options); + $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); } /** * {@inheritdoc} */ - protected function attemptAuthentication(Request $request) + public function handle(GetResponseEvent $event) { - $data = json_decode($request->getContent(), true); + $request = $event->getRequest(); + $data = json_decode($request->getContent()); + + if (!$data instanceof \stdClass) { + throw new BadCredentialsException('Invalid JSON.'); + } - if (!isset($data[$this->options['username_parameter']])) { - throw new BadCredentialsException(sprintf('Missing key "%s".', $this->options['username_parameter'])); + try { + $username = $this->propertyAccessor->getValue($data, $this->options['username_path']); + } catch (AccessException $e) { + throw new BadCredentialsException(sprintf('Missing key "%s".', $this->options['username_path'])); } - if (!isset($data[$this->options['password_parameter']])) { - throw new BadCredentialsException(sprintf('Missing key "%s".', $this->options['password_parameter'])); + try { + $password = $this->propertyAccessor->getValue($data, $this->options['password_path']); + } catch (AccessException $e) { + throw new BadCredentialsException(sprintf('Missing key "%s".', $this->options['password_path'])); } - $username = $data[$this->options['username_parameter']]; if (!is_string($username)) { - throw new BadCredentialsException(sprintf('The key "%s" must contain a string.', $this->options['username_parameter'])); + throw new BadCredentialsException(sprintf('The key "%s" must contain a string.', $this->options['username_path'])); } if (strlen($username) > Security::MAX_USERNAME_LENGTH) { throw new BadCredentialsException('Invalid username.'); } - $password = $data[$this->options['password_parameter']]; if (!is_string($password)) { - throw new BadCredentialsException(sprintf('The key "%s" must contain a string.', $this->options['password_parameter'])); + throw new BadCredentialsException(sprintf('The key "%s" must contain a string.', $this->options['password_path'])); + } + + try { + $token = new UsernamePasswordToken($username, $password, $this->providerKey); + + $this->authenticationManager->authenticate($token); + $response = $this->onSuccess($request, $token); + } catch (AuthenticationException $e) { + $response = $this->onFailure($request, $e); + } + + $event->setResponse($response); + } + + private function onSuccess(Request $request, TokenInterface $token) + { + if (null !== $this->logger) { + $this->logger->info('User has been authenticated successfully.', array('username' => $token->getUsername())); + } + + $this->tokenStorage->setToken($token); + + if (null !== $this->eventDispatcher) { + $loginEvent = new InteractiveLoginEvent($request, $token); + $this->eventDispatcher->dispatch(SecurityEvents::INTERACTIVE_LOGIN, $loginEvent); + } + + $response = $this->successHandler->onAuthenticationSuccess($request, $token); + + if (!$response instanceof Response) { + throw new \RuntimeException('Authentication Success Handler did not return a Response.'); + } + + return $response; + } + + private function onFailure(Request $request, AuthenticationException $failed) + { + if (null !== $this->logger) { + $this->logger->info('Authentication request failed.', array('exception' => $failed)); + } + + $token = $this->tokenStorage->getToken(); + if ($token instanceof UsernamePasswordToken && $this->providerKey === $token->getProviderKey()) { + $this->tokenStorage->setToken(null); + } + + $response = $this->failureHandler->onAuthenticationFailure($request, $failed); + + if (!$response instanceof Response) { + throw new \RuntimeException('Authentication Failure Handler did not return a Response.'); } - return $this->authenticationManager->authenticate(new UsernamePasswordToken($username, $password, $this->providerKey)); + return $response; } } diff --git a/src/Symfony/Component/Security/Tests/Http/Firewall/UsernamePasswordJsonAuthenticationListenerTest.php b/src/Symfony/Component/Security/Tests/Http/Firewall/UsernamePasswordJsonAuthenticationListenerTest.php index 6376426b669fd..6b99a6d22f519 100644 --- a/src/Symfony/Component/Security/Tests/Http/Firewall/UsernamePasswordJsonAuthenticationListenerTest.php +++ b/src/Symfony/Component/Security/Tests/Http/Firewall/UsernamePasswordJsonAuthenticationListenerTest.php @@ -12,14 +12,16 @@ namespace Symfony\Component\Security\Tests\Http\Firewall; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\GetResponseEvent; +use Symfony\Component\HttpKernel\KernelInterface; use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; use Symfony\Component\Security\Http\Firewall\UsernamePasswordJsonAuthenticationListener; -use Symfony\Component\Security\Http\HttpUtils; -use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface; /** * @author Kévin Dunglas @@ -31,31 +33,53 @@ class UsernamePasswordJsonAuthenticationListenerTest extends \PHPUnit_Framework_ */ private $listener; - /** - * @var \ReflectionMethod - */ - private $attemptAuthenticationMethod; - - protected function setUp() { + private function createListener(array $options = array(), $success = true) + { $tokenStorage = $this->getMock(TokenStorageInterface::class); $authenticationManager = $this->getMock(AuthenticationManagerInterface::class); - $authenticationManager->method('authenticate')->willReturn(true); - $sessionAuthenticationStrategyInterface = $this->getMock(SessionAuthenticationStrategyInterface::class); - $httpUtils = $this->getMock(HttpUtils::class); + + if ($success) { + $authenticationManager->method('authenticate')->willReturn(true); + } else { + $authenticationManager->method('authenticate')->willThrowException(new AuthenticationException()); + } + $authenticationSuccessHandler = $this->getMock(AuthenticationSuccessHandlerInterface::class); + $authenticationSuccessHandler->method('onAuthenticationSuccess')->willReturn(new Response('ok')); $authenticationFailureHandler = $this->getMock(AuthenticationFailureHandlerInterface::class); + $authenticationFailureHandler->method('onAuthenticationFailure')->willReturn(new Response('ko')); + + $this->listener = new UsernamePasswordJsonAuthenticationListener($tokenStorage, $authenticationManager, 'providerKey', $authenticationSuccessHandler, $authenticationFailureHandler, $options); + } + + public function testHandleSuccess() + { + $this->createListener(); + $request = new Request(array(), array(), array(), array(), array(), array(), '{"username": "dunglas", "password": "foo"}'); + $event = new GetResponseEvent($this->getMock(KernelInterface::class), $request, KernelInterface::MASTER_REQUEST); + + $this->listener->handle($event); + $this->assertEquals('ok', $event->getResponse()->getContent()); + } + + public function testHandleFailure() + { + $this->createListener(array(), false); + $request = new Request(array(), array(), array(), array(), array(), array(), '{"username": "dunglas", "password": "foo"}'); + $event = new GetResponseEvent($this->getMock(KernelInterface::class), $request, KernelInterface::MASTER_REQUEST); - $this->listener = new UsernamePasswordJsonAuthenticationListener($tokenStorage, $authenticationManager, $sessionAuthenticationStrategyInterface, $httpUtils, 'providerKey', $authenticationSuccessHandler, $authenticationFailureHandler); - $this->attemptAuthenticationMethod = new \ReflectionMethod($this->listener, 'attemptAuthentication'); - $this->attemptAuthenticationMethod->setAccessible(true); + $this->listener->handle($event); + $this->assertEquals('ko', $event->getResponse()->getContent()); } - public function testAttemptAuthentication() + public function testUsePath() { - $request = new Request(array(), array(), array(), array(), array(), array(), '{"_username": "dunglas", "_password": "foo"}'); + $this->createListener(array('username_path' => 'user.login', 'password_path' => 'user.pwd')); + $request = new Request(array(), array(), array(), array(), array(), array(), '{"user": {"login": "dunglas", "pwd": "foo"}}'); + $event = new GetResponseEvent($this->getMock(KernelInterface::class), $request, KernelInterface::MASTER_REQUEST); - $result = $this->attemptAuthenticationMethod->invokeArgs($this->listener, array($request)); - $this->assertTrue($result); + $this->listener->handle($event); + $this->assertEquals('ok', $event->getResponse()->getContent()); } /** @@ -63,8 +87,11 @@ public function testAttemptAuthentication() */ public function testAttemptAuthenticationNoUsername() { - $request = new Request(array(), array(), array(), array(), array(), array(), '{"usr": "dunglas", "_password": "foo"}'); - $this->attemptAuthenticationMethod->invokeArgs($this->listener, array($request)); + $this->createListener(); + $request = new Request(array(), array(), array(), array(), array(), array(), '{"usr": "dunglas", "password": "foo"}'); + $event = new GetResponseEvent($this->getMock(KernelInterface::class), $request, KernelInterface::MASTER_REQUEST); + + $this->listener->handle($event); } /** @@ -72,8 +99,11 @@ public function testAttemptAuthenticationNoUsername() */ public function testAttemptAuthenticationNoPassword() { - $request = new Request(array(), array(), array(), array(), array(), array(), '{"_username": "dunglas", "pass": "foo"}'); - $this->attemptAuthenticationMethod->invokeArgs($this->listener, array($request)); + $this->createListener(); + $request = new Request(array(), array(), array(), array(), array(), array(), '{"username": "dunglas", "pass": "foo"}'); + $event = new GetResponseEvent($this->getMock(KernelInterface::class), $request, KernelInterface::MASTER_REQUEST); + + $this->listener->handle($event); } /** @@ -81,8 +111,11 @@ public function testAttemptAuthenticationNoPassword() */ public function testAttemptAuthenticationUsernameNotAString() { - $request = new Request(array(), array(), array(), array(), array(), array(), '{"_username": 1, "_password": "foo"}'); - $this->attemptAuthenticationMethod->invokeArgs($this->listener, array($request)); + $this->createListener(); + $request = new Request(array(), array(), array(), array(), array(), array(), '{"username": 1, "password": "foo"}'); + $event = new GetResponseEvent($this->getMock(KernelInterface::class), $request, KernelInterface::MASTER_REQUEST); + + $this->listener->handle($event); } /** @@ -90,8 +123,11 @@ public function testAttemptAuthenticationUsernameNotAString() */ public function testAttemptAuthenticationPasswordNotAString() { - $request = new Request(array(), array(), array(), array(), array(), array(), '{"_username": "dunglas", "_password": 1}'); - $this->attemptAuthenticationMethod->invokeArgs($this->listener, array($request)); + $this->createListener(); + $request = new Request(array(), array(), array(), array(), array(), array(), '{"username": "dunglas", "password": 1}'); + $event = new GetResponseEvent($this->getMock(KernelInterface::class), $request, KernelInterface::MASTER_REQUEST); + + $this->listener->handle($event); } /** @@ -99,9 +135,11 @@ public function testAttemptAuthenticationPasswordNotAString() */ public function testAttemptAuthenticationUsernameTooLong() { + $this->createListener(); $username = str_repeat('x', Security::MAX_USERNAME_LENGTH + 1); - $request = new Request(array(), array(), array(), array(), array(), array(), sprintf('{"_username": "%s", "_password": 1}', $username)); + $request = new Request(array(), array(), array(), array(), array(), array(), sprintf('{"username": "%s", "password": 1}', $username)); + $event = new GetResponseEvent($this->getMock(KernelInterface::class), $request, KernelInterface::MASTER_REQUEST); - $this->attemptAuthenticationMethod->invokeArgs($this->listener, array($request)); + $this->listener->handle($event); } } From 10ecbe10dff57623f2c6bb1d45621220557ba143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Wed, 2 Nov 2016 15:54:44 +0100 Subject: [PATCH 3/4] Update exception messages --- .../UsernamePasswordJsonAuthenticationListener.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Symfony/Component/Security/Http/Firewall/UsernamePasswordJsonAuthenticationListener.php b/src/Symfony/Component/Security/Http/Firewall/UsernamePasswordJsonAuthenticationListener.php index d0ad64ddfdd97..bf3c62129dd16 100644 --- a/src/Symfony/Component/Security/Http/Firewall/UsernamePasswordJsonAuthenticationListener.php +++ b/src/Symfony/Component/Security/Http/Firewall/UsernamePasswordJsonAuthenticationListener.php @@ -77,17 +77,17 @@ public function handle(GetResponseEvent $event) try { $username = $this->propertyAccessor->getValue($data, $this->options['username_path']); } catch (AccessException $e) { - throw new BadCredentialsException(sprintf('Missing key "%s".', $this->options['username_path'])); + throw new BadCredentialsException(sprintf('The key "%s" must be provided.', $this->options['username_path'])); } try { $password = $this->propertyAccessor->getValue($data, $this->options['password_path']); } catch (AccessException $e) { - throw new BadCredentialsException(sprintf('Missing key "%s".', $this->options['password_path'])); + throw new BadCredentialsException(sprintf('The key "%s" must be provided.', $this->options['password_path'])); } if (!is_string($username)) { - throw new BadCredentialsException(sprintf('The key "%s" must contain a string.', $this->options['username_path'])); + throw new BadCredentialsException(sprintf('The key "%s" must be a string.', $this->options['username_path'])); } if (strlen($username) > Security::MAX_USERNAME_LENGTH) { @@ -95,7 +95,7 @@ public function handle(GetResponseEvent $event) } if (!is_string($password)) { - throw new BadCredentialsException(sprintf('The key "%s" must contain a string.', $this->options['password_path'])); + throw new BadCredentialsException(sprintf('The key "%s" must be a string.', $this->options['password_path'])); } try { From 3477b2d533160fa6f56f7bf6bb83433b12597d10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Fri, 2 Dec 2016 09:51:07 +0100 Subject: [PATCH 4/4] Update composer.json --- src/Symfony/Bundle/SecurityBundle/composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Bundle/SecurityBundle/composer.json b/src/Symfony/Bundle/SecurityBundle/composer.json index f4323dc72dbbc..d846404f2cacc 100644 --- a/src/Symfony/Bundle/SecurityBundle/composer.json +++ b/src/Symfony/Bundle/SecurityBundle/composer.json @@ -17,7 +17,7 @@ ], "require": { "php": ">=5.5.9", - "symfony/security": "~3.2", + "symfony/security": "~3.3", "symfony/http-kernel": "~3.2", "symfony/polyfill-php70": "~1.0" },