[Security] Add a JSON authentication listener

This commit is contained in:
Kévin Dunglas 2016-06-02 13:58:58 +02:00 committed by Fabien Potencier
parent e765849215
commit 02178bc12f
12 changed files with 531 additions and 2 deletions

View File

@ -0,0 +1,96 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <dunglas@gmail.com>
*/
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;
}
}

View File

@ -140,7 +140,20 @@
<argument /> <!-- x509 user -->
<argument /> <!-- x509 credentials -->
<argument type="service" id="logger" on-invalid="null" />
<argument type="service" id="event_dispatcher" on-invalid="null"/>
<argument type="service" id="event_dispatcher" on-invalid="null" />
</service>
<service id="security.authentication.listener.json" class="Symfony\Component\Security\Http\Firewall\UsernamePasswordJsonAuthenticationListener" public="false" abstract="true">
<tag name="monolog.logger" channel="security" />
<argument type="service" id="security.token_storage" />
<argument type="service" id="security.authentication.manager" />
<argument /> <!-- Provider-shared Key -->
<argument type="service" id="security.authentication.success_handler" />
<argument type="service" id="security.authentication.failure_handler" />
<argument type="collection" /> <!-- Options -->
<argument type="service" id="logger" on-invalid="null" />
<argument type="service" id="event_dispatcher" on-invalid="null" />
<argument type="service" id="property_accessor" on-invalid="null" />
</service>
<service id="security.authentication.listener.remote_user" class="Symfony\Component\Security\Http\Firewall\RemoteUserAuthenticationListener" public="false" abstract="true">

View File

@ -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 @@ class SecurityBundle extends Bundle
$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());

View File

@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <dunglas@gmail.com>
*/
class TestController
{
public function loginCheckAction()
{
throw new \RuntimeException(sprintf('%s should never be called.', __FUNCTION__));
}
}

View File

@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <dunglas@gmail.com>
*/
class JsonLoginBundle extends Bundle
{
}

View File

@ -0,0 +1,32 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <dunglas@gmail.com>
*/
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'));
}
}

View File

@ -0,0 +1,16 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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(),
);

View File

@ -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 }

View File

@ -0,0 +1,3 @@
login_check:
path: /chk
defaults: { _controller: JsonLoginBundle:Test:loginCheck }

View File

@ -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"
},

View File

@ -0,0 +1,154 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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\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\Event\InteractiveLoginEvent;
use Symfony\Component\Security\Http\SecurityEvents;
/**
* UsernamePasswordJsonAuthenticationListener is a stateless implementation of
* an authentication via a JSON document composed of a username and a password.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
class UsernamePasswordJsonAuthenticationListener implements ListenerInterface
{
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)
{
$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}
*/
public function handle(GetResponseEvent $event)
{
$request = $event->getRequest();
$data = json_decode($request->getContent());
if (!$data instanceof \stdClass) {
throw new BadCredentialsException('Invalid JSON.');
}
try {
$username = $this->propertyAccessor->getValue($data, $this->options['username_path']);
} catch (AccessException $e) {
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('The key "%s" must be provided.', $this->options['password_path']));
}
if (!is_string($username)) {
throw new BadCredentialsException(sprintf('The key "%s" must be a string.', $this->options['username_path']));
}
if (strlen($username) > Security::MAX_USERNAME_LENGTH) {
throw new BadCredentialsException('Invalid username.');
}
if (!is_string($password)) {
throw new BadCredentialsException(sprintf('The key "%s" must be 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 $response;
}
}

View File

@ -0,0 +1,145 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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\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;
/**
* @author Kévin Dunglas <dunglas@gmail.com>
*/
class UsernamePasswordJsonAuthenticationListenerTest extends \PHPUnit_Framework_TestCase
{
/**
* @var UsernamePasswordJsonAuthenticationListener
*/
private $listener;
private function createListener(array $options = array(), $success = true)
{
$tokenStorage = $this->getMock(TokenStorageInterface::class);
$authenticationManager = $this->getMock(AuthenticationManagerInterface::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->handle($event);
$this->assertEquals('ko', $event->getResponse()->getContent());
}
public function testUsePath()
{
$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);
$this->listener->handle($event);
$this->assertEquals('ok', $event->getResponse()->getContent());
}
/**
* @expectedException \Symfony\Component\Security\Core\Exception\BadCredentialsException
*/
public function testAttemptAuthenticationNoUsername()
{
$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);
}
/**
* @expectedException \Symfony\Component\Security\Core\Exception\BadCredentialsException
*/
public function testAttemptAuthenticationNoPassword()
{
$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);
}
/**
* @expectedException \Symfony\Component\Security\Core\Exception\BadCredentialsException
*/
public function testAttemptAuthenticationUsernameNotAString()
{
$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);
}
/**
* @expectedException \Symfony\Component\Security\Core\Exception\BadCredentialsException
*/
public function testAttemptAuthenticationPasswordNotAString()
{
$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);
}
/**
* @expectedException \Symfony\Component\Security\Core\Exception\BadCredentialsException
*/
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));
$event = new GetResponseEvent($this->getMock(KernelInterface::class), $request, KernelInterface::MASTER_REQUEST);
$this->listener->handle($event);
}
}