Skip to content

Commit

Permalink
Merge pull request #938 from dunglas/security
Browse files Browse the repository at this point in the history
Allow to configure auth access from the resource class
  • Loading branch information
dunglas committed Feb 19, 2017
2 parents 9f0b155 + 3d12f34 commit b5323f1
Show file tree
Hide file tree
Showing 9 changed files with 423 additions and 11 deletions.
3 changes: 3 additions & 0 deletions composer.json
Expand Up @@ -46,6 +46,7 @@
"symfony/config": "^3.2",
"symfony/dependency-injection": "^2.7 || ^3.0",
"symfony/doctrine-bridge": "^2.8 || ^3.0",
"symfony/expression-language": "^2.8 || ^3.0",
"symfony/phpunit-bridge": "^2.7 || ^3.0",
"symfony/security": "^2.7 || ^3.0",
"symfony/templating": "^2.7 || ^3.0",
Expand All @@ -59,7 +60,9 @@
"phpdocumentor/reflection-docblock": "To support extracting metadata from PHPDoc.",
"psr/cache-implementation": "To use metadata caching.",
"symfony/cache": "To have metadata caching when using Symfony integration.",
"symfony/expression-language": "To use authorization features.",
"symfony/config": "To load XML configuration files.",
"symfony/security": "To use authorization features.",
"symfony/twig-bundle": "To use the Swagger UI integration."
},
"autoload": {
Expand Down
74 changes: 74 additions & 0 deletions features/authorization/deny.feature
@@ -0,0 +1,74 @@
Feature: Authorization checking
In order to use the API
As a client software developer
I need to be authorized to access a given resource.

@createSchema
Scenario: An anonymous user retrieve a secured resource
When I add "Accept" header equal to "application/ld+json"
And I send a "GET" request to "/secured_dummies"
Then the response status code should be 401

Scenario: An authenticated user retrieve a secured resource
When I add "Accept" header equal to "application/ld+json"
And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg=="
And I send a "GET" request to "/secured_dummies"
Then the response status code should be 200
And the response should be in JSON


Scenario: A standard user cannot create a secured resource
When I add "Accept" header equal to "application/ld+json"
And I add "Content-Type" header equal to "application/ld+json"
And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg=="
And I send a "POST" request to "/secured_dummies" with body:
"""
{
"title": "Title",
"description": "Description",
"owner": "foo"
}
"""
Then the response status code should be 403

Scenario: An admin can create a secured resource
When I add "Accept" header equal to "application/ld+json"
And I add "Content-Type" header equal to "application/ld+json"
And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu"
And I send a "POST" request to "/secured_dummies" with body:
"""
{
"title": "Title",
"description": "Description",
"owner": "someone"
}
"""
Then the response status code should be 201

Scenario: An admin can create another secured resource
When I add "Accept" header equal to "application/ld+json"
And I add "Content-Type" header equal to "application/ld+json"
And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu"
And I send a "POST" request to "/secured_dummies" with body:
"""
{
"title": "Special Title",
"description": "Description",
"owner": "dunglas"
}
"""
Then the response status code should be 201

Scenario: An user retrieve cannot retrieve an item he doesn't own
When I add "Accept" header equal to "application/ld+json"
And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg=="
And I send a "GET" request to "/secured_dummies/1"
Then the response status code should be 403
And the response should be in JSON

@dropSchema
Scenario: An user can retrieve an item he owns
When I add "Accept" header equal to "application/ld+json"
And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg=="
And I send a "GET" request to "/secured_dummies/2"
Then the response status code should be 200
1 change: 0 additions & 1 deletion src/Bridge/Doctrine/EventListener/WriteListener.php
Expand Up @@ -79,7 +79,6 @@ public function onKernelView(GetResponseForControllerResultEvent $event)
private function getManager(string $resourceClass, $data)
{
$objectManager = $this->managerRegistry->getManagerForClass($resourceClass);

if (null === $objectManager || !is_object($data)) {
return;
}
Expand Down
10 changes: 9 additions & 1 deletion src/Bridge/Symfony/Bundle/Resources/config/api.xml
Expand Up @@ -88,14 +88,14 @@

<!-- Event listeners -->

<!-- kernel.request priority must be < 8 to be executed after the Firewall -->
<service id="api_platform.listener.request.add_format" class="ApiPlatform\Core\EventListener\AddFormatListener">
<argument type="service" id="api_platform.negotiator" />
<argument>%api_platform.formats%</argument>

<tag name="kernel.event_listener" event="kernel.request" method="onKernelRequest" priority="7" />
</service>

<!-- kernel.request priority must be < 8 to be executed after the Firewall -->
<service id="api_platform.listener.request.read" class="ApiPlatform\Core\EventListener\ReadListener">
<argument type="service" id="api_platform.collection_data_provider" />
<argument type="service" id="api_platform.item_data_provider" />
Expand All @@ -111,6 +111,14 @@
<tag name="kernel.event_listener" event="kernel.request" method="onKernelRequest" priority="2" />
</service>

<!-- This listener must be executed only when the current object is available -->
<service id="api_platform.listener.request.deny_access" class="ApiPlatform\Core\EventListener\DenyAccessListener">
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
<argument type="service" id="security.authorization_checker" on-invalid="ignore" />

<tag name="kernel.event_listener" event="kernel.request" method="onKernelRequest" priority="1" />
</service>

<service id="api_platform.listener.view.validate" class="ApiPlatform\Core\Bridge\Symfony\Validator\EventListener\ValidateListener">
<argument type="service" id="validator" />
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
Expand Down
79 changes: 79 additions & 0 deletions src/EventListener/DenyAccessListener.php
@@ -0,0 +1,79 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace ApiPlatform\Core\EventListener;

use ApiPlatform\Core\Exception\RuntimeException;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Util\RequestAttributesExtractor;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;

/**
* Denies access to the current resource if the logged user doesn't have sufficient permissions.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
final class DenyAccessListener
{
private $resourceMetadataFactory;
private $authorizationChecker;

public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, AuthorizationCheckerInterface $authorizationChecker = null)
{
$this->resourceMetadataFactory = $resourceMetadataFactory;
$this->authorizationChecker = $authorizationChecker;
}

/**
* Sets the applicable format to the HttpFoundation Request.
*
* @param GetResponseEvent $event
*
* @throws AccessDeniedException
*/
public function onKernelRequest(GetResponseEvent $event)
{
$request = $event->getRequest();

try {
$attributes = RequestAttributesExtractor::extractAttributes($request);
} catch (RuntimeException $e) {
return;
}

$resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']);

if (isset($attributes['collection_operation_name'])) {
$isGranted = $resourceMetadata->getCollectionOperationAttribute($attributes['collection_operation_name'], 'is_granted', null, true);
} else {
$isGranted = $resourceMetadata->getItemOperationAttribute($attributes['item_operation_name'], 'is_granted', null, true);
}

if (null === $isGranted) {
return;
}

if (null === $this->authorizationChecker) {
throw new \LogicException(sprintf('The "symfony/security" library must be installed to use the "is_granted" attribute on class "%s".', $attributes['resource_class']));
}

if (!class_exists(Expression::class)) {
throw new \LogicException(sprintf('The "symfony/expression-language" library must be installed to use the "is_granted" attribute on class "%s".', $attributes['resource_class']));
}

if (!$this->authorizationChecker->isGranted(new Expression($isGranted), $request->attributes->get('data'))) {
throw new AccessDeniedException();
}
}
}
Expand Up @@ -290,6 +290,7 @@ private function getContainerBuilderProphecy()
'api_platform.listener.view.respond',
'api_platform.listener.view.serialize',
'api_platform.listener.view.validate',
'api_platform.listener.request.deny_access',
'api_platform.metadata.extractor.yaml',
'api_platform.metadata.extractor.xml',
'api_platform.metadata.property.metadata_factory.annotation',
Expand Down
131 changes: 131 additions & 0 deletions tests/EventListener/DenyAccessListenerTest.php
@@ -0,0 +1,131 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace ApiPlatform\Core\Tests\EventListener;

use ApiPlatform\Core\EventListener\DenyAccessListener;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
use Prophecy\Argument;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;

/**
* @author Kévin Dunglas <dunglas@gmail.com>
*/
class DenyAccessListenerTest extends \PHPUnit_Framework_TestCase
{
public function testNoResourceClass()
{
$request = new Request();

$eventProphecy = $this->prophesize(GetResponseEvent::class);
$eventProphecy->getRequest()->willReturn($request)->shouldBeCalled();
$event = $eventProphecy->reveal();

$resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class);
$resourceMetadataFactoryProphecy->create()->shouldNotBeCalled();
$resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal();

$authorizationCheckerProphecy = $this->prophesize(AuthorizationCheckerInterface::class);
$authorizationCheckerProphecy->isGranted()->shouldNotBeCalled();
$authorizationChecker = $authorizationCheckerProphecy->reveal();

$listener = new DenyAccessListener($resourceMetadataFactory, $authorizationChecker);
$listener->onKernelRequest($event);
}

public function testNoIsGrantedAttribute()
{
$request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'get']);

$eventProphecy = $this->prophesize(GetResponseEvent::class);
$eventProphecy->getRequest()->willReturn($request)->shouldBeCalled();
$event = $eventProphecy->reveal();

$resourceMetadata = new ResourceMetadata();

$resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class);
$resourceMetadataFactoryProphecy->create('Foo')->willReturn($resourceMetadata)->shouldBeCalled();

$authorizationCheckerProphecy = $this->prophesize(AuthorizationCheckerInterface::class);
$authorizationCheckerProphecy->isGranted()->shouldNotBeCalled();

$listener = new DenyAccessListener($resourceMetadataFactoryProphecy->reveal(), $authorizationCheckerProphecy->reveal());
$listener->onKernelRequest($event);
}

public function testIsGranted()
{
$data = new \stdClass();
$request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'get', 'data' => $data]);

$eventProphecy = $this->prophesize(GetResponseEvent::class);
$eventProphecy->getRequest()->willReturn($request)->shouldBeCalled();
$event = $eventProphecy->reveal();

$resourceMetadata = new ResourceMetadata(null, null, null, null, null, ['is_granted' => 'has_role("ROLE_ADMIN")']);

$resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class);
$resourceMetadataFactoryProphecy->create('Foo')->willReturn($resourceMetadata)->shouldBeCalled();

$authorizationCheckerProphecy = $this->prophesize(AuthorizationCheckerInterface::class);
$authorizationCheckerProphecy->isGranted(Argument::type(Expression::class), $data)->willReturn(true)->shouldBeCalled();

$listener = new DenyAccessListener($resourceMetadataFactoryProphecy->reveal(), $authorizationCheckerProphecy->reveal());
$listener->onKernelRequest($event);
}

/**
* @expectedException \Symfony\Component\Security\Core\Exception\AccessDeniedException
*/
public function testIsNotGranted()
{
$request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'get']);

$eventProphecy = $this->prophesize(GetResponseEvent::class);
$eventProphecy->getRequest()->willReturn($request)->shouldBeCalled();
$event = $eventProphecy->reveal();

$resourceMetadata = new ResourceMetadata(null, null, null, null, null, ['is_granted' => 'has_role("ROLE_ADMIN")']);

$resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class);
$resourceMetadataFactoryProphecy->create('Foo')->willReturn($resourceMetadata)->shouldBeCalled();

$authorizationCheckerProphecy = $this->prophesize(AuthorizationCheckerInterface::class);
$authorizationCheckerProphecy->isGranted(Argument::type(Expression::class), null)->willReturn(false)->shouldBeCalled();

$listener = new DenyAccessListener($resourceMetadataFactoryProphecy->reveal(), $authorizationCheckerProphecy->reveal());
$listener->onKernelRequest($event);
}

/**
* @expectedException \LogicException
*/
public function testAuthorizationCheckerNotAvailable()
{
$request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'get']);

$eventProphecy = $this->prophesize(GetResponseEvent::class);
$eventProphecy->getRequest()->willReturn($request)->shouldBeCalled();
$event = $eventProphecy->reveal();

$resourceMetadata = new ResourceMetadata(null, null, null, null, null, ['is_granted' => 'has_role("ROLE_ADMIN")']);

$resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class);
$resourceMetadataFactoryProphecy->create('Foo')->willReturn($resourceMetadata)->shouldBeCalled();

$listener = new DenyAccessListener($resourceMetadataFactoryProphecy->reveal(), null);
$listener->onKernelRequest($event);
}
}

0 comments on commit b5323f1

Please sign in to comment.