If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeThere are two big parts to security in any app. First, how does your user authenticate? How do they log in? Honestly, that is the trickiest part... and it has really nothing to do with API Platform. We're authenticating via the json_login
authenticator and a session cookie. That's a great solution for many applications. But in the bonus part 2 of the security tutorial, we'll talk about other types of applications and solutions.
Regardless of how your users authenticate, step two of security - authorization - will look the same. Authorization is all about denying access to read or perform different operations... and this is enforced in a way that's independent of how you log in. So even if the way clients of your API authenticate is much different than what we're doing, all this authorization stuff will still be relevant.
When a user logs in - no matter how they authenticate or where your user data is stored - your login mechanism assigns that user a set of roles. In our app, those roles are stored in the database and we'll eventually let admin users modify them via our API. The simplest way to prevent access to an endpoint is by making sure the user has some role. And the easiest way to do that is via access_control
in security.yaml
.
We could, for example, say that every URL that matches the ^/api/cheeses
regular expression - so anything that starts with /api/cheeses
- requires ROLE_ADMIN
. This is normal, boring Symfony security... and I love it!
access_control
is great for some situations, but most of the time you'll need more flexibility. In a traditional Symfony app, I typically add security to my controllers. But... in API Platform... um... we don't have any controllers! Ok, so instead of thinking about protecting each controller, we'll think about protecting each API operation. Maybe we want this collection GET
operation to be accessible anonymously but we want to require a user to be authenticated in order to POST
and create a new CheeseListing
.
Open up that entity: src/Entity/CheeseListing.php
. We already have an itemOperations
key, which we used to remove the delete
operation and also to customize the normalization groups of get
. We can do something similar with a collectionOperations
option. Start by setting this to get
and post
.
... lines 1 - 16 | |
/** | |
* @ApiResource( | |
... lines 19 - 24 | |
* collectionOperations={ | |
* "get", | |
* "post" | |
* }, | |
... lines 29 - 35 | |
* ) | |
... lines 37 - 46 | |
*/ | |
class CheeseListing | |
... lines 49 - 207 |
If we stopped here, this would change nothing. Oh... except that I have a syntax error! Silly comma! Anyways, API Platform adds two collection operations by default - get
and post
- so we're simply repeating what it was already doing. But now we can customize these operations.
For the post
operation - that's how we create new cheese listings - we really need the user to be authenticated to do this. Set post
to {}
with a new access_control
option inside. We're going to set this to a mini-expression: is_granted()
passing that, inside single quotes ROLE_USER
- that's the role that our app gives to every user.
... lines 1 - 24 | |
* collectionOperations={ | |
... line 26 | |
* "post"={"access_control"="is_granted('ROLE_USER')"} | |
* }, | |
... lines 29 - 207 |
Tip
access_control
option was replaced by security
in api-platform/core
v2.5.0.
If you're on a newer version - use it instead:
/**
* ...
* @ApiResource(
* collectionOperations={
* "get",
* "post"={"security"="is_granted('ROLE_USER')"}
* },
* ...
* )
* ...
*/
class CheeseListing
{
// ...
}
Let's try that! The web debug toolbar tells me that I'm not logged in right now. Let's make a POST
request... set the owner to /api/users/6
- that's my user id... though the value doesn't matter yet... nor do any of the others fields. Hit Execute and... perfect! A 401
error:
Full authentication is required to access this resource.
If we logged in, this would work.
Let's tighten up our itemOperations
too. The PUT
operations - editing a CheeseListing
- is an interesting case... we definitely need the user to be logged in... but we probably also only want the "owner" of a CheeseListing
to be able to edit it. Let's just handle the first part now. I'll copy my whole access_control
config and paste. While we're here, let's also re-add the delete operation... but maybe only admin users can do this. Check for some ROLE_ADMIN
role.
... lines 1 - 18 | |
* itemOperations={ | |
... lines 20 - 22 | |
* "put"={"access_control"="is_granted('ROLE_USER')"}, | |
* "delete"={"access_control"="is_granted('ROLE_ADMIN')"} | |
* }, | |
... lines 26 - 208 |
Go refresh the docs... yes! The DELETE operation is back! Notice that the docs are... basically "static" as far as security is concerned: it documents the whole API, including operations that you might not have access to: it doesn't magically read your roles and hide or show different operations. That's done on purpose, but I wanted to point it out.
Try the PUT
endpoint... I think I have a CheeseListing
with id 1 and... just send the title
field. Another 401!
Next, our security setup is about to get smarter and more complex. The goal is to make sure that only the "owner" of a CheeseListing
can update that CheeseListing
... and maybe also admin users. To really know that things are working, I think it's time to bootstrap a basic system to functionally test our API.
// composer.json
{
"require": {
"php": "^7.1.3, <8.0",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "^2.1", // v2.4.5
"composer/package-versions-deprecated": "^1.11", // 1.11.99
"doctrine/annotations": "^1.0", // 1.13.2
"doctrine/doctrine-bundle": "^1.6", // 1.11.2
"doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
"doctrine/orm": "^2.4.5", // v2.7.2
"nelmio/cors-bundle": "^1.5", // 1.5.6
"nesbot/carbon": "^2.17", // 2.21.3
"phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
"symfony/asset": "4.3.*", // v4.3.2
"symfony/console": "4.3.*", // v4.3.2
"symfony/dotenv": "4.3.*", // v4.3.2
"symfony/expression-language": "4.3.*", // v4.3.2
"symfony/flex": "^1.1", // v1.18.7
"symfony/framework-bundle": "4.3.*", // v4.3.2
"symfony/http-client": "4.3.*", // v4.3.3
"symfony/monolog-bundle": "^3.4", // v3.4.0
"symfony/security-bundle": "4.3.*", // v4.3.2
"symfony/twig-bundle": "4.3.*", // v4.3.2
"symfony/validator": "4.3.*", // v4.3.2
"symfony/webpack-encore-bundle": "^1.6", // v1.6.2
"symfony/yaml": "4.3.*" // v4.3.2
},
"require-dev": {
"hautelook/alice-bundle": "^2.5", // 2.7.3
"symfony/browser-kit": "4.3.*", // v4.3.3
"symfony/css-selector": "4.3.*", // v4.3.3
"symfony/maker-bundle": "^1.11", // v1.12.0
"symfony/phpunit-bridge": "^4.3", // v4.3.3
"symfony/stopwatch": "4.3.*", // v4.3.2
"symfony/web-profiler-bundle": "4.3.*" // v4.3.2
}
}