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 SubscribeIf you need to control how a field like isPublished
is set based on who is logged in, you have two different situations.
First, if you need to prevent certain users from writing to this field entirely, that's what security is for. The easiest option is to use the #[ApiProperty(security:
...)] option that we used earlier above the property. Or you could get fancier and add a dynamic admin:write
group via a context builder. Either way, we're preventing this field from being written entirely.
The second situation is when a user should be allowed to write to a field... but the valid data they're allowed to set depends on who they are. Like maybe a user is allowed to set isPublished
to false
... but they're not allowed to set it to true
unless they're an admin.
Let me give you a different example. Right now, when you create a DragonTreasure
, we force the client to pass an owner
. We can see this in testPostToCreateTreasure()
. We're going to fix this in a few minutes so that we can leave this field off... and then it'll be set automatically to whoever is authenticated.
But right now, the owner
field is allowed and required. But who they are allowed to assign as the owner
depends on who is logged in. For normal users, they should only be allowed to assign themselves as a user. But for admins, they should be able to assign anyone as the owner
. Heck, maybe in the future we get crazier and there are clans of dragons... and you can create treasures and assign them to anyone in your clan The point is: the question isn't if we can set this field, but what data we're allowed to set it to. And that depends on who we are.
Ok, actually, we solved this problem earlier for the Patch()
operation. Let me show you. Find testPatchToUpdateTreasure()
. Then... let's run just that test:
symfony php bin/phpunit --filter=testPatchToUpdateTreasure
And... it passes. This test checks 3 things. First, we log in as the user that owns the DragonTreasure
and make an update. That's the happy case!
Next, we log in as a different user and try to edit the first user's DragonTreasure
. That is not allowed. And that is a proper use of security
: we don't own this DragonTreasure
, so we are not at all allowed to edit it. That's what the security
line is protecting.
For the last part, we log in again as the owner of this DragonTreasure
. But then we try to change the owner to someone else. That's also not allowed and this is the situation we're talking about. It's currently handled by securityPostDenormalize()
. But I want to handle it instead with validation. Why? Because the question we're answering is this:
Is the
owner
data that's sent valid?
And... validating data is... the job of validation!
Remove the securityPostDenormalize()
:
... lines 1 - 28 | |
( | |
... lines 30 - 31 | |
operations: [ | |
... lines 33 - 41 | |
new Patch( | |
... line 43 | |
securityPostDenormalize: 'is_granted("EDIT", object)', | |
), | |
... lines 46 - 48 | |
], | |
... lines 50 - 66 | |
) | |
... lines 68 - 88 | |
class DragonTreasure | |
{ | |
... lines 91 - 249 | |
} |
And to prove this was important, run the test again:
symfony php bin/phpunit --filter=testPatchToUpdateTreasure
Yup! It failed on line 132... which is this one down here. Let's rewrite this with a custom validator, which is actually a lot nicer.
Oh but because this will fail via validation when we're done, change to assertStatus(422)
:
... lines 1 - 12 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 15 - 97 | |
public function testPatchToUpdateTreasure() | |
{ | |
... lines 100 - 126 | |
$this->browser() | |
... lines 128 - 134 | |
->assertStatus(422) | |
; | |
} | |
... lines 138 - 179 | |
} |
The idea is that we are allowed to PATCH this user, but we sent invalid data: we can't set this owner to someone other than ourselves.
Ok, head to the command line and run:
php ./bin/console make:validator
Give it a cool name like IsValidOwnerValidator
. In Symfony, validators are two different classes. Open src/Validator/IsValidOwner.php
first:
... lines 1 - 2 | |
namespace App\Validator; | |
use Symfony\Component\Validator\Constraint; | |
/** | |
* @Annotation | |
* | |
* @Target({"PROPERTY", "METHOD", "ANNOTATION"}) | |
*/ | |
\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE) ( | |
class IsValidOwner extends Constraint | |
{ | |
/* | |
* Any public properties become valid options for the annotation. | |
* Then, use these in your validator class. | |
*/ | |
public $message = 'The value "{{ value }}" is not valid.'; | |
} |
This lightweight class will be used as the attribute... and it just holds options that we can configure, like $message
, which is enough. Let's change the default message to something a bit more helpful:
... lines 1 - 12 | |
class IsValidOwner extends Constraint | |
{ | |
... lines 15 - 18 | |
public string $message = 'You are not allowed to set the owner to this value.'; | |
} |
The second class is the one that will be executed to handle the logic:
... lines 1 - 2 | |
namespace App\Validator; | |
use Symfony\Component\Validator\Constraint; | |
use Symfony\Component\Validator\ConstraintValidator; | |
class IsValidOwnerValidator extends ConstraintValidator | |
{ | |
public function validate($value, Constraint $constraint) | |
{ | |
/* @var App\Validator\IsValidOwner $constraint */ | |
if (null === $value || '' === $value) { | |
return; | |
} | |
// TODO: implement the validation here | |
$this->context->buildViolation($constraint->message) | |
->setParameter('{{ value }}', $value) | |
->addViolation(); | |
} | |
} |
We'll look at that in a moment... but let's use the new constraint first. Over in DragonTreasure
, down on the owner
property... there we go... add the new attribute: IsValidOwner
:
... lines 1 - 19 | |
use App\Validator\IsValidOwner; | |
... lines 21 - 88 | |
class DragonTreasure | |
{ | |
... lines 91 - 135 | |
... line 137 | |
private ?User $owner = null; | |
... lines 139 - 250 | |
} |
Now that we have this, when our object is validated, Symfony will call IsValidOwnerValidator
and pass us the $value
- which will be the User
object - and the constraint, which will be IsValidOwner
.
Let's do some clean up. Remove the var
and replace it with assert($constraint instanceof IsValidOwner)
:
... lines 1 - 8 | |
class IsValidOwnerValidator extends ConstraintValidator | |
{ | |
public function validate($value, Constraint $constraint) | |
{ | |
assert($constraint instanceof IsValidOwner); | |
if (null === $value || '' === $value) { | |
return; | |
} | |
... lines 18 - 23 | |
} | |
} |
That's just to help my editor: we know that Symfony will always pass us that. Next, notice that it's checking to see if the $value
is null or blank. And if is, it does nothing. If the $owner
property is empty, that should really be handled by a different constraint.
Back in DragonTreasure
, add #[Assert\NotNull]
:
... lines 1 - 88 | |
class DragonTreasure | |
{ | |
... lines 91 - 136 | |
... line 138 | |
private ?User $owner = null; | |
... lines 140 - 251 | |
} |
So if they forget to send owner
, this will handle that validation error. Back inside our validator, if we have that situation, we can just return:
... lines 1 - 8 | |
class IsValidOwnerValidator extends ConstraintValidator | |
{ | |
public function validate($value, Constraint $constraint) | |
{ | |
... lines 13 - 14 | |
if (null === $value || '' === $value) { | |
return; | |
} | |
... lines 18 - 23 | |
} | |
} |
Below this, add one more assert()
that $value
is an instanceof User
.
Really, Symfony will pass us whatever value is attached to this property... but we know that this will always be a User
:
... lines 1 - 8 | |
class IsValidOwnerValidator extends ConstraintValidator | |
{ | |
public function validate($value, Constraint $constraint) | |
{ | |
... lines 13 - 14 | |
if (null === $value || '' === $value) { | |
return; | |
} | |
// constraint is only meant to be used above a User property | |
assert($value instanceof User); | |
... lines 21 - 23 | |
} | |
} |
Finally, delete setParameter()
- that's not needed in our case - and $constraint->message
is reading the $message
property:
... lines 1 - 8 | |
class IsValidOwnerValidator extends ConstraintValidator | |
{ | |
public function validate($value, Constraint $constraint) | |
{ | |
assert($constraint instanceof IsValidOwner); | |
if (null === $value || '' === $value) { | |
return; | |
} | |
// constraint is only meant to be used above a User property | |
assert($value instanceof User); | |
$this->context->buildViolation($constraint->message) | |
->addViolation(); | |
} | |
} |
At this point, we have a functional validator! Except... it's going to fail in all situations. Ah, let's at least make sure it's being called. Run our test:
symfony php bin/phpunit --filter=testPatchToUpdateTreasure
Beautiful failure! A 422 coming from DragonTreasureResourceTest
line 110... because our constraint is never satisfied.
Finally we can add our business logic. To do the owner check, we need to know who's logged in. Add a __construct()
method, autowire our favorite Security
class... and I'll put private
in front of that, so it becomes a property:
... lines 1 - 5 | |
use Symfony\Bundle\SecurityBundle\Security; | |
... lines 7 - 9 | |
class IsValidOwnerValidator extends ConstraintValidator | |
{ | |
public function __construct(private Security $security) | |
{ | |
} | |
... lines 15 - 34 | |
} |
Below, set $user = $this->security->getUser()
. And if there is no user for some reason, throw a LogicException
to make things explode:
... lines 1 - 9 | |
class IsValidOwnerValidator extends ConstraintValidator | |
{ | |
... lines 12 - 15 | |
public function validate($value, Constraint $constraint) | |
{ | |
... lines 18 - 23 | |
// constraint is only meant to be used above a User property | |
assert($value instanceof User); | |
$user = $this->security->getUser(); | |
if (!$user) { | |
throw new \LogicException('IsOwnerValidator should only be used when a user is logged in.'); | |
} | |
... lines 31 - 33 | |
} | |
} |
Why not trigger a validation error? We could... but in our app, if an anonymous user is somehow successfully changing a DragonTreasure
... we have some sort of misconfiguration.
Finally, if $value
does not equal $user
- so if the owner
is not the User
- add that validation failure:
... lines 1 - 9 | |
class IsValidOwnerValidator extends ConstraintValidator | |
{ | |
... lines 12 - 15 | |
public function validate($value, Constraint $constraint) | |
{ | |
... lines 18 - 31 | |
if ($value !== $user) { | |
$this->context->buildViolation($constraint->message) | |
->addViolation(); | |
} | |
} | |
} |
That's it! Let's try this thing!
symfony php bin/phpunit --filter=testPatchToUpdateTreasure
And... bingo! Whether we're creating or editing a DragonTreasure
, we are not allowed to set the owner to someone that is not us.
And we can add whatever other fanciness we want. Like if the user is an admin, return so that admin users are allowed to assign the owner
to anyone:
... lines 1 - 9 | |
class IsValidOwnerValidator extends ConstraintValidator | |
{ | |
... lines 12 - 15 | |
public function validate($value, Constraint $constraint) | |
{ | |
... lines 18 - 26 | |
$user = $this->security->getUser(); | |
if (!$user) { | |
throw new \LogicException('IsOwnerValidator should only be used when a user is logged in.'); | |
} | |
if ($this->security->isGranted('ROLE_ADMIN')) { | |
return; | |
} | |
if ($value !== $user) { | |
$this->context->buildViolation($constraint->message) | |
->addViolation(); | |
} | |
} | |
} |
I love this. But... there's still one big security hole: a hole that will allow a user to steal the treasures of someone else! Not cool! Let's find out what that is next and crush it.
"Houston: no signs of life"
Start the conversation!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "^3.0", // v3.1.2
"doctrine/annotations": "^2.0", // 2.0.1
"doctrine/doctrine-bundle": "^2.8", // 2.8.3
"doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
"doctrine/orm": "^2.14", // 2.14.1
"nelmio/cors-bundle": "^2.2", // 2.2.0
"nesbot/carbon": "^2.64", // 2.66.0
"phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
"phpstan/phpdoc-parser": "^1.15", // 1.16.1
"symfony/asset": "6.2.*", // v6.2.5
"symfony/console": "6.2.*", // v6.2.5
"symfony/dotenv": "6.2.*", // v6.2.5
"symfony/expression-language": "6.2.*", // v6.2.5
"symfony/flex": "^2", // v2.2.4
"symfony/framework-bundle": "6.2.*", // v6.2.5
"symfony/property-access": "6.2.*", // v6.2.5
"symfony/property-info": "6.2.*", // v6.2.5
"symfony/runtime": "6.2.*", // v6.2.5
"symfony/security-bundle": "6.2.*", // v6.2.6
"symfony/serializer": "6.2.*", // v6.2.5
"symfony/twig-bundle": "6.2.*", // v6.2.5
"symfony/ux-react": "^2.6", // v2.7.1
"symfony/ux-vue": "^2.7", // v2.7.1
"symfony/validator": "6.2.*", // v6.2.5
"symfony/webpack-encore-bundle": "^1.16", // v1.16.1
"symfony/yaml": "6.2.*" // v6.2.5
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
"mtdowling/jmespath.php": "^2.6", // 2.6.1
"phpunit/phpunit": "^9.5", // 9.6.3
"symfony/browser-kit": "6.2.*", // v6.2.5
"symfony/css-selector": "6.2.*", // v6.2.5
"symfony/debug-bundle": "6.2.*", // v6.2.5
"symfony/maker-bundle": "^1.48", // v1.48.0
"symfony/monolog-bundle": "^3.0", // v3.8.0
"symfony/phpunit-bridge": "^6.2", // v6.2.5
"symfony/stopwatch": "6.2.*", // v6.2.5
"symfony/web-profiler-bundle": "6.2.*", // v6.2.5
"zenstruck/browser": "^1.2", // v1.2.0
"zenstruck/foundry": "^1.26" // v1.28.0
}
}