Skip to content

Commit 24cdab2

Browse files
committed
Allow to configure auth access from the resource class
1 parent f1d44be commit 24cdab2

File tree

7 files changed

+327
-10
lines changed

7 files changed

+327
-10
lines changed

features/authorization/deny.feature

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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 "/secureds"
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 "/secureds"
16+
Then the response status code should be 200
17+
And the response should be in JSON
18+
19+
@dropSchema
20+
Scenario: Only admins can create a secured resource
21+
When I add "Accept" header equal to "application/ld+json"
22+
And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg=="
23+
And I send a "POST" request to "/secureds" with body:
24+
"""
25+
{
26+
"title": "Title",
27+
"description": "Description"
28+
}
29+
"""
30+
Then the response status code should be 403

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

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

8989
<!-- Event listeners -->
9090

91+
<!-- kernel.request priority must be < 8 to be executed after the Firewall -->
92+
<service id="api_platform.listener.request.deny_access" class="ApiPlatform\Core\EventListener\DenyAccessListener">
93+
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
94+
<argument type="service" id="security.authorization_checker" />
95+
96+
<tag name="kernel.event_listener" event="kernel.request" method="onKernelRequest" priority="7" />
97+
</service>
98+
9199
<service id="api_platform.listener.request.add_format" class="ApiPlatform\Core\EventListener\AddFormatListener">
92100
<argument type="service" id="api_platform.negotiator" />
93101
<argument>%api_platform.formats%</argument>
94102

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

98-
<!-- kernel.request priority must be < 8 to be executed after the Firewall -->
99106
<service id="api_platform.listener.request.read" class="ApiPlatform\Core\EventListener\ReadListener">
100107
<argument type="service" id="api_platform.collection_data_provider" />
101108
<argument type="service" id="api_platform.item_data_provider" />
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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\HttpKernel\Event\GetResponseEvent;
18+
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
19+
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
20+
21+
/**
22+
* Denies access to the current resource if the logged user doesn't have sufficient permissions.
23+
*
24+
* @author Kévin Dunglas <[email protected]>
25+
*/
26+
final class DenyAccessListener
27+
{
28+
private $resourceMetadataFactory;
29+
private $authorizationChecker;
30+
31+
public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, AuthorizationCheckerInterface $authorizationChecker)
32+
{
33+
$this->resourceMetadataFactory = $resourceMetadataFactory;
34+
$this->authorizationChecker = $authorizationChecker;
35+
}
36+
37+
/**
38+
* Sets the applicable format to the HttpFoundation Request.
39+
*
40+
* @param GetResponseEvent $event
41+
*
42+
* @throws AccessDeniedException
43+
*/
44+
public function onKernelRequest(GetResponseEvent $event)
45+
{
46+
try {
47+
$attributes = RequestAttributesExtractor::extractAttributes($event->getRequest());
48+
} catch (RuntimeException $e) {
49+
return;
50+
}
51+
52+
$resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']);
53+
54+
if (isset($attributes['collection_operation_name'])) {
55+
$isGranted = $resourceMetadata->getCollectionOperationAttribute($attributes['collection_operation_name'], 'is_granted', null, true);
56+
} else {
57+
$isGranted = $resourceMetadata->getItemOperationAttribute($attributes['item_operation_name'], 'is_granted', null, true);
58+
}
59+
60+
if (null === $isGranted) {
61+
return;
62+
}
63+
64+
foreach ($isGranted as $attribute) {
65+
if ($this->authorizationChecker->isGranted($attribute)) {
66+
return;
67+
}
68+
}
69+
70+
throw new AccessDeniedException();
71+
}
72+
}

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: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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 Symfony\Component\HttpFoundation\Request;
18+
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
19+
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
20+
21+
/**
22+
* @author Kévin Dunglas <[email protected]>
23+
*/
24+
class DenyAccessListenerTest extends \PHPUnit_Framework_TestCase
25+
{
26+
public function testNoResourceClass()
27+
{
28+
$request = new Request();
29+
30+
$eventProphecy = $this->prophesize(GetResponseEvent::class);
31+
$eventProphecy->getRequest()->willReturn($request)->shouldBeCalled();
32+
$event = $eventProphecy->reveal();
33+
34+
$resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class);
35+
$resourceMetadataFactoryProphecy->create()->shouldNotBeCalled();
36+
$resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal();
37+
38+
$authorizationCheckerProphecy = $this->prophesize(AuthorizationCheckerInterface::class);
39+
$authorizationCheckerProphecy->isGranted()->shouldNotBeCalled();
40+
$authorizationChecker = $authorizationCheckerProphecy->reveal();
41+
42+
$listener = new DenyAccessListener($resourceMetadataFactory, $authorizationChecker);
43+
$listener->onKernelRequest($event);
44+
}
45+
46+
public function testNoIsGrantedAttribute()
47+
{
48+
$request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'get']);
49+
50+
$eventProphecy = $this->prophesize(GetResponseEvent::class);
51+
$eventProphecy->getRequest()->willReturn($request)->shouldBeCalled();
52+
$event = $eventProphecy->reveal();
53+
54+
$resourceMetadata = new ResourceMetadata();
55+
56+
$resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class);
57+
$resourceMetadataFactoryProphecy->create('Foo')->willReturn($resourceMetadata)->shouldBeCalled();
58+
59+
$authorizationCheckerProphecy = $this->prophesize(AuthorizationCheckerInterface::class);
60+
$authorizationCheckerProphecy->isGranted()->shouldNotBeCalled();
61+
62+
$listener = new DenyAccessListener($resourceMetadataFactoryProphecy->reveal(), $authorizationCheckerProphecy->reveal());
63+
$listener->onKernelRequest($event);
64+
}
65+
66+
public function testIsGranted()
67+
{
68+
$request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'get']);
69+
70+
$eventProphecy = $this->prophesize(GetResponseEvent::class);
71+
$eventProphecy->getRequest()->willReturn($request)->shouldBeCalled();
72+
$event = $eventProphecy->reveal();
73+
74+
$resourceMetadata = new ResourceMetadata(null, null, null, null, null, ['is_granted' => ['ROLE_ADMIN']]);
75+
76+
$resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class);
77+
$resourceMetadataFactoryProphecy->create('Foo')->willReturn($resourceMetadata)->shouldBeCalled();
78+
79+
$authorizationCheckerProphecy = $this->prophesize(AuthorizationCheckerInterface::class);
80+
$authorizationCheckerProphecy->isGranted('ROLE_ADMIN')->willReturn(true)->shouldBeCalled();
81+
82+
$listener = new DenyAccessListener($resourceMetadataFactoryProphecy->reveal(), $authorizationCheckerProphecy->reveal());
83+
$listener->onKernelRequest($event);
84+
}
85+
86+
/**
87+
* @expectedException \Symfony\Component\Security\Core\Exception\AccessDeniedException
88+
*/
89+
public function testIsNotGranted()
90+
{
91+
$request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'get']);
92+
93+
$eventProphecy = $this->prophesize(GetResponseEvent::class);
94+
$eventProphecy->getRequest()->willReturn($request)->shouldBeCalled();
95+
$event = $eventProphecy->reveal();
96+
97+
$resourceMetadata = new ResourceMetadata(null, null, null, null, null, ['is_granted' => ['ROLE_ADMIN']]);
98+
99+
$resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class);
100+
$resourceMetadataFactoryProphecy->create('Foo')->willReturn($resourceMetadata)->shouldBeCalled();
101+
102+
$authorizationCheckerProphecy = $this->prophesize(AuthorizationCheckerInterface::class);
103+
$authorizationCheckerProphecy->isGranted('ROLE_ADMIN')->willReturn(true)->shouldBeCalled();
104+
105+
$listener = new DenyAccessListener($resourceMetadataFactoryProphecy->reveal(), $authorizationCheckerProphecy->reveal());
106+
$listener->onKernelRequest($event);
107+
}
108+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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 Fixtures\TestBundle\Entity;
13+
14+
use ApiPlatform\Core\Annotation\ApiResource;
15+
use Doctrine\ORM\Mapping as ORM;
16+
use Symfony\Component\Validator\Constraints as Assert;
17+
18+
/**
19+
* Secured resource.
20+
*
21+
* @author Kévin Dunglas <[email protected]>
22+
*
23+
* @ApiResource(
24+
* attributes={"is_granted"={"ROLE_USER"}},
25+
* collectionOperations={
26+
* "get"={"method"="GET"},
27+
* "post"={"method"="POST", "is_granted"={"ROLE_ADMIN"}}
28+
* },
29+
* itemOperations={
30+
* "get"={"method"="GET"}
31+
* }
32+
* )
33+
* @ORM\Entity
34+
*/
35+
class Secured
36+
{
37+
/**
38+
* @var int
39+
*
40+
* @ORM\Column(type="integer")
41+
* @ORM\Id
42+
* @ORM\GeneratedValue(strategy="AUTO")
43+
*/
44+
private $id;
45+
46+
/**
47+
* @var string The title
48+
*
49+
* @ORM\Column
50+
* @Assert\NotBlank
51+
*/
52+
private $title;
53+
54+
/**
55+
* @var string The description
56+
*
57+
* @ORM\Column
58+
*/
59+
private $description;
60+
61+
/**
62+
* @return int
63+
*/
64+
public function getId(): int
65+
{
66+
return $this->id;
67+
}
68+
69+
public function getTitle(): string
70+
{
71+
return $this->title;
72+
}
73+
74+
public function setTitle(string $title)
75+
{
76+
$this->title = $title;
77+
}
78+
79+
public function getDescription()
80+
{
81+
return $this->description;
82+
}
83+
84+
public function setDescription(string $description)
85+
{
86+
$this->description = $description;
87+
}
88+
}

tests/Fixtures/app/config/security.yml

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,34 @@
11
security:
22
encoders:
3-
FOS\UserBundle\Model\UserInterface: plaintext
3+
# Don't use plaintext in production!
4+
Symfony\Component\Security\Core\User\UserInterface: plaintext
45

56
providers:
7+
chain_provider:
8+
chain:
9+
providers: [in_memory, fos_userbundle]
10+
11+
in_memory:
12+
memory:
13+
users:
14+
dunglas:
15+
password: kevin
16+
roles: 'ROLE_USER'
17+
admin:
18+
password: kitten
19+
roles: 'ROLE_ADMIN'
20+
621
fos_userbundle:
722
id: fos_user.user_provider.username_email
823

924
firewalls:
1025
dev:
11-
pattern: ^/(_(profiler|wdt|error)|css|images|js)/
12-
security: false
13-
14-
api:
15-
pattern: ^/
26+
pattern: ^/(_(profiler|wdt|error)|css|images|js)/
1627
security: false
17-
stateless: true
18-
anonymous: true
1928

20-
anonymous:
29+
default:
30+
provider: in_memory
31+
http_basic: ~
2132
anonymous: ~
2233

2334
access_control:

0 commit comments

Comments
 (0)