Skip to content

Commit b5323f1

Browse files
authored
Merge pull request #938 from dunglas/security
Allow to configure auth access from the resource class
2 parents 9f0b155 + 3d12f34 commit b5323f1

File tree

9 files changed

+423
-11
lines changed

9 files changed

+423
-11
lines changed

composer.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"symfony/config": "^3.2",
4747
"symfony/dependency-injection": "^2.7 || ^3.0",
4848
"symfony/doctrine-bridge": "^2.8 || ^3.0",
49+
"symfony/expression-language": "^2.8 || ^3.0",
4950
"symfony/phpunit-bridge": "^2.7 || ^3.0",
5051
"symfony/security": "^2.7 || ^3.0",
5152
"symfony/templating": "^2.7 || ^3.0",
@@ -59,7 +60,9 @@
5960
"phpdocumentor/reflection-docblock": "To support extracting metadata from PHPDoc.",
6061
"psr/cache-implementation": "To use metadata caching.",
6162
"symfony/cache": "To have metadata caching when using Symfony integration.",
63+
"symfony/expression-language": "To use authorization features.",
6264
"symfony/config": "To load XML configuration files.",
65+
"symfony/security": "To use authorization features.",
6366
"symfony/twig-bundle": "To use the Swagger UI integration."
6467
},
6568
"autoload": {

features/authorization/deny.feature

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
Feature: Authorization checking
2+
In order to use the API
3+
As a client software developer
4+
I need to be authorized to access a given resource.
5+
6+
@createSchema
7+
Scenario: An anonymous user retrieve a secured resource
8+
When I add "Accept" header equal to "application/ld+json"
9+
And I send a "GET" request to "/secured_dummies"
10+
Then the response status code should be 401
11+
12+
Scenario: An authenticated user retrieve a secured resource
13+
When I add "Accept" header equal to "application/ld+json"
14+
And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg=="
15+
And I send a "GET" request to "/secured_dummies"
16+
Then the response status code should be 200
17+
And the response should be in JSON
18+
19+
20+
Scenario: A standard user cannot create a secured resource
21+
When I add "Accept" header equal to "application/ld+json"
22+
And I add "Content-Type" header equal to "application/ld+json"
23+
And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg=="
24+
And I send a "POST" request to "/secured_dummies" with body:
25+
"""
26+
{
27+
"title": "Title",
28+
"description": "Description",
29+
"owner": "foo"
30+
}
31+
"""
32+
Then the response status code should be 403
33+
34+
Scenario: An admin can create a secured resource
35+
When I add "Accept" header equal to "application/ld+json"
36+
And I add "Content-Type" header equal to "application/ld+json"
37+
And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu"
38+
And I send a "POST" request to "/secured_dummies" with body:
39+
"""
40+
{
41+
"title": "Title",
42+
"description": "Description",
43+
"owner": "someone"
44+
}
45+
"""
46+
Then the response status code should be 201
47+
48+
Scenario: An admin can create another secured resource
49+
When I add "Accept" header equal to "application/ld+json"
50+
And I add "Content-Type" header equal to "application/ld+json"
51+
And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu"
52+
And I send a "POST" request to "/secured_dummies" with body:
53+
"""
54+
{
55+
"title": "Special Title",
56+
"description": "Description",
57+
"owner": "dunglas"
58+
}
59+
"""
60+
Then the response status code should be 201
61+
62+
Scenario: An user retrieve cannot retrieve an item he doesn't own
63+
When I add "Accept" header equal to "application/ld+json"
64+
And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg=="
65+
And I send a "GET" request to "/secured_dummies/1"
66+
Then the response status code should be 403
67+
And the response should be in JSON
68+
69+
@dropSchema
70+
Scenario: An user can retrieve an item he owns
71+
When I add "Accept" header equal to "application/ld+json"
72+
And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg=="
73+
And I send a "GET" request to "/secured_dummies/2"
74+
Then the response status code should be 200

src/Bridge/Doctrine/EventListener/WriteListener.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ public function onKernelView(GetResponseForControllerResultEvent $event)
7979
private function getManager(string $resourceClass, $data)
8080
{
8181
$objectManager = $this->managerRegistry->getManagerForClass($resourceClass);
82-
8382
if (null === $objectManager || !is_object($data)) {
8483
return;
8584
}

src/Bridge/Symfony/Bundle/Resources/config/api.xml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,14 @@
8888

8989
<!-- Event listeners -->
9090

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

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

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

114+
<!-- This listener must be executed only when the current object is available -->
115+
<service id="api_platform.listener.request.deny_access" class="ApiPlatform\Core\EventListener\DenyAccessListener">
116+
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
117+
<argument type="service" id="security.authorization_checker" on-invalid="ignore" />
118+
119+
<tag name="kernel.event_listener" event="kernel.request" method="onKernelRequest" priority="1" />
120+
</service>
121+
114122
<service id="api_platform.listener.view.validate" class="ApiPlatform\Core\Bridge\Symfony\Validator\EventListener\ValidateListener">
115123
<argument type="service" id="validator" />
116124
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace ApiPlatform\Core\EventListener;
13+
14+
use ApiPlatform\Core\Exception\RuntimeException;
15+
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
16+
use ApiPlatform\Core\Util\RequestAttributesExtractor;
17+
use Symfony\Component\ExpressionLanguage\Expression;
18+
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
19+
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
20+
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
21+
22+
/**
23+
* Denies access to the current resource if the logged user doesn't have sufficient permissions.
24+
*
25+
* @author Kévin Dunglas <[email protected]>
26+
*/
27+
final class DenyAccessListener
28+
{
29+
private $resourceMetadataFactory;
30+
private $authorizationChecker;
31+
32+
public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, AuthorizationCheckerInterface $authorizationChecker = null)
33+
{
34+
$this->resourceMetadataFactory = $resourceMetadataFactory;
35+
$this->authorizationChecker = $authorizationChecker;
36+
}
37+
38+
/**
39+
* Sets the applicable format to the HttpFoundation Request.
40+
*
41+
* @param GetResponseEvent $event
42+
*
43+
* @throws AccessDeniedException
44+
*/
45+
public function onKernelRequest(GetResponseEvent $event)
46+
{
47+
$request = $event->getRequest();
48+
49+
try {
50+
$attributes = RequestAttributesExtractor::extractAttributes($request);
51+
} catch (RuntimeException $e) {
52+
return;
53+
}
54+
55+
$resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']);
56+
57+
if (isset($attributes['collection_operation_name'])) {
58+
$isGranted = $resourceMetadata->getCollectionOperationAttribute($attributes['collection_operation_name'], 'is_granted', null, true);
59+
} else {
60+
$isGranted = $resourceMetadata->getItemOperationAttribute($attributes['item_operation_name'], 'is_granted', null, true);
61+
}
62+
63+
if (null === $isGranted) {
64+
return;
65+
}
66+
67+
if (null === $this->authorizationChecker) {
68+
throw new \LogicException(sprintf('The "symfony/security" library must be installed to use the "is_granted" attribute on class "%s".', $attributes['resource_class']));
69+
}
70+
71+
if (!class_exists(Expression::class)) {
72+
throw new \LogicException(sprintf('The "symfony/expression-language" library must be installed to use the "is_granted" attribute on class "%s".', $attributes['resource_class']));
73+
}
74+
75+
if (!$this->authorizationChecker->isGranted(new Expression($isGranted), $request->attributes->get('data'))) {
76+
throw new AccessDeniedException();
77+
}
78+
}
79+
}

tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ private function getContainerBuilderProphecy()
290290
'api_platform.listener.view.respond',
291291
'api_platform.listener.view.serialize',
292292
'api_platform.listener.view.validate',
293+
'api_platform.listener.request.deny_access',
293294
'api_platform.metadata.extractor.yaml',
294295
'api_platform.metadata.extractor.xml',
295296
'api_platform.metadata.property.metadata_factory.annotation',
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace ApiPlatform\Core\Tests\EventListener;
13+
14+
use ApiPlatform\Core\EventListener\DenyAccessListener;
15+
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
16+
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
17+
use Prophecy\Argument;
18+
use Symfony\Component\ExpressionLanguage\Expression;
19+
use Symfony\Component\HttpFoundation\Request;
20+
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
21+
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
22+
23+
/**
24+
* @author Kévin Dunglas <[email protected]>
25+
*/
26+
class DenyAccessListenerTest extends \PHPUnit_Framework_TestCase
27+
{
28+
public function testNoResourceClass()
29+
{
30+
$request = new Request();
31+
32+
$eventProphecy = $this->prophesize(GetResponseEvent::class);
33+
$eventProphecy->getRequest()->willReturn($request)->shouldBeCalled();
34+
$event = $eventProphecy->reveal();
35+
36+
$resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class);
37+
$resourceMetadataFactoryProphecy->create()->shouldNotBeCalled();
38+
$resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal();
39+
40+
$authorizationCheckerProphecy = $this->prophesize(AuthorizationCheckerInterface::class);
41+
$authorizationCheckerProphecy->isGranted()->shouldNotBeCalled();
42+
$authorizationChecker = $authorizationCheckerProphecy->reveal();
43+
44+
$listener = new DenyAccessListener($resourceMetadataFactory, $authorizationChecker);
45+
$listener->onKernelRequest($event);
46+
}
47+
48+
public function testNoIsGrantedAttribute()
49+
{
50+
$request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'get']);
51+
52+
$eventProphecy = $this->prophesize(GetResponseEvent::class);
53+
$eventProphecy->getRequest()->willReturn($request)->shouldBeCalled();
54+
$event = $eventProphecy->reveal();
55+
56+
$resourceMetadata = new ResourceMetadata();
57+
58+
$resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class);
59+
$resourceMetadataFactoryProphecy->create('Foo')->willReturn($resourceMetadata)->shouldBeCalled();
60+
61+
$authorizationCheckerProphecy = $this->prophesize(AuthorizationCheckerInterface::class);
62+
$authorizationCheckerProphecy->isGranted()->shouldNotBeCalled();
63+
64+
$listener = new DenyAccessListener($resourceMetadataFactoryProphecy->reveal(), $authorizationCheckerProphecy->reveal());
65+
$listener->onKernelRequest($event);
66+
}
67+
68+
public function testIsGranted()
69+
{
70+
$data = new \stdClass();
71+
$request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'get', 'data' => $data]);
72+
73+
$eventProphecy = $this->prophesize(GetResponseEvent::class);
74+
$eventProphecy->getRequest()->willReturn($request)->shouldBeCalled();
75+
$event = $eventProphecy->reveal();
76+
77+
$resourceMetadata = new ResourceMetadata(null, null, null, null, null, ['is_granted' => 'has_role("ROLE_ADMIN")']);
78+
79+
$resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class);
80+
$resourceMetadataFactoryProphecy->create('Foo')->willReturn($resourceMetadata)->shouldBeCalled();
81+
82+
$authorizationCheckerProphecy = $this->prophesize(AuthorizationCheckerInterface::class);
83+
$authorizationCheckerProphecy->isGranted(Argument::type(Expression::class), $data)->willReturn(true)->shouldBeCalled();
84+
85+
$listener = new DenyAccessListener($resourceMetadataFactoryProphecy->reveal(), $authorizationCheckerProphecy->reveal());
86+
$listener->onKernelRequest($event);
87+
}
88+
89+
/**
90+
* @expectedException \Symfony\Component\Security\Core\Exception\AccessDeniedException
91+
*/
92+
public function testIsNotGranted()
93+
{
94+
$request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'get']);
95+
96+
$eventProphecy = $this->prophesize(GetResponseEvent::class);
97+
$eventProphecy->getRequest()->willReturn($request)->shouldBeCalled();
98+
$event = $eventProphecy->reveal();
99+
100+
$resourceMetadata = new ResourceMetadata(null, null, null, null, null, ['is_granted' => 'has_role("ROLE_ADMIN")']);
101+
102+
$resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class);
103+
$resourceMetadataFactoryProphecy->create('Foo')->willReturn($resourceMetadata)->shouldBeCalled();
104+
105+
$authorizationCheckerProphecy = $this->prophesize(AuthorizationCheckerInterface::class);
106+
$authorizationCheckerProphecy->isGranted(Argument::type(Expression::class), null)->willReturn(false)->shouldBeCalled();
107+
108+
$listener = new DenyAccessListener($resourceMetadataFactoryProphecy->reveal(), $authorizationCheckerProphecy->reveal());
109+
$listener->onKernelRequest($event);
110+
}
111+
112+
/**
113+
* @expectedException \LogicException
114+
*/
115+
public function testAuthorizationCheckerNotAvailable()
116+
{
117+
$request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'get']);
118+
119+
$eventProphecy = $this->prophesize(GetResponseEvent::class);
120+
$eventProphecy->getRequest()->willReturn($request)->shouldBeCalled();
121+
$event = $eventProphecy->reveal();
122+
123+
$resourceMetadata = new ResourceMetadata(null, null, null, null, null, ['is_granted' => 'has_role("ROLE_ADMIN")']);
124+
125+
$resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class);
126+
$resourceMetadataFactoryProphecy->create('Foo')->willReturn($resourceMetadata)->shouldBeCalled();
127+
128+
$listener = new DenyAccessListener($resourceMetadataFactoryProphecy->reveal(), null);
129+
$listener->onKernelRequest($event);
130+
}
131+
}

0 commit comments

Comments
 (0)