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 SubscribeEach ApiToken
has an array of scopes, though we're not using that yet. The idea is cool: when a token is created, you can select which permissions it has. Like maybe a token gives the permission to create new treasures but not edit existing treasures. To allow that, we're going to map the scopes of a token to roles in Symfony.
Right now in ApiTokenHandler
, we're basically returning the user... and then the system authenticates fully as that user. This means we get whatever roles are on that User
object. How could we change that so that we authenticate as this user... but with a different set of roles? A set based on the scopes from the token?
We're using the access_token
security system. Hit Shift
+Shift
and open a core class called AccessTokenAuthenticator
. This is cool: it's the actual code behind that authentication system! For example, this is where it grabs the token off of the request and calls our token handler's getUserBadgeFrom()
method.
The roles the user will have are also determined here: down inside createToken()
. The "token" is, sort of, a "wrapper" around the User
object in the security system. And this is where we pass it the roles it should have. As you can see, no matter what, the roles will be $passport->getUser()->getRoles()
. In other words, we always get the roles by calling getRoles()
on the User
class... which just returns the roles
property.
So there's no great hook point. We could create a custom authenticator class and implement our own createToken()
method. But that's a bummer because we would need to completely reimplement the logic form this authenticator class. So, instead we can... kind of cheat.
Start in User
. Scroll up to the top where we have our properties. Add a new one: private ?array
called $accessTokenScopes
and initialize it to null
:
... lines 1 - 38 | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 41 - 54 | |
/* Scopes given during API authentication */ | |
private ?array $accessTokenScopes = null; | |
... lines 57 - 248 | |
} |
Notice that this is not a persisted column. It's just a place to temporarily store the scopes that the user should have. Next, down at the bottom add a new public method called markAsTokenAuthenticated()
with an array $scopes
argument. We're going to call this during authentication. Inside, say $this->accessTokenScopes = $scopes
:
... lines 1 - 38 | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 41 - 244 | |
public function markAsTokenAuthenticated(array $scopes) | |
{ | |
$this->accessTokenScopes = $scopes; | |
} | |
} |
Here's where things get interesting. Search for the getRoles()
method. We know that, no matter what, Symfony will call this during authentication and whatever this returns, that's the roles the user will have. We're going to "sneak in" our scope roles.
First if the $accessTokenScopes
property is null
, that means we're logging in as a normal user. In this case, set $roles
to $this->roles
so that we get all the $roles
on the User
. Then add an extra role called ROLE_FULL_USER
:
... lines 1 - 38 | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 41 - 113 | |
public function getRoles(): array | |
{ | |
if (null === $this->accessTokenScopes) { | |
// logged in via the full user mechanism | |
$roles = $this->roles; | |
$roles[] = 'ROLE_FULL_USER'; | |
} else { | |
... line 121 | |
} | |
... lines 123 - 127 | |
} | |
... lines 129 - 248 | |
} |
We'll talk about that in a minute.
Else, if we did log in via an access token, say $roles = $this->accessTokenScopes
:
... lines 1 - 38 | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 41 - 113 | |
public function getRoles(): array | |
{ | |
if (null === $this->accessTokenScopes) { | |
// logged in via the full user mechanism | |
$roles = $this->roles; | |
$roles[] = 'ROLE_FULL_USER'; | |
} else { | |
$roles = $this->accessTokenScopes; | |
} | |
... lines 123 - 127 | |
} | |
... lines 129 - 248 | |
} |
And, in both cases, make sure that we always have ROLE_USER
:
... lines 1 - 38 | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 41 - 113 | |
public function getRoles(): array | |
{ | |
if (null === $this->accessTokenScopes) { | |
// logged in via the full user mechanism | |
$roles = $this->roles; | |
$roles[] = 'ROLE_FULL_USER'; | |
} else { | |
$roles = $this->accessTokenScopes; | |
} | |
// guarantee every user at least has ROLE_USER | |
$roles[] = 'ROLE_USER'; | |
return array_unique($roles); | |
} | |
... lines 129 - 248 | |
} |
With this in place, head over to ApiTokenHandler
. Right before we return UserBadge
, add $token->getOwnedBy()->markAsTokenAuthenticated()
and pass $token->getScopes()
:
... lines 1 - 10 | |
class ApiTokenHandler implements AccessTokenHandlerInterface | |
{ | |
... lines 13 - 16 | |
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge | |
{ | |
... lines 19 - 28 | |
$token->getOwnedBy()->markAsTokenAuthenticated($token->getScopes()); | |
return new UserBadge($token->getOwnedBy()->getUserIdentifier()); | |
} | |
} |
Done! Let's take it for a test drive! Back over on Swagger, it already has our API token... so we can just re-execute the request. Cool: we see the Authorization
header. Did it authenticate with the correct scopes?
Click to open the profiler for that request... and head down to "Security". It did! Look: we're logged in as that user, but with ROLE_USER
, ROLE_USER_EDIT
and ROLE_TREASURE_CREATE
: the two scopes from the token. But if we were to log in via the login form, instead of these scopes, we would have whatever roles the user normally has, plus ROLE_FULL_USER
.
In the next chapter, we'll use these roles to protect different API operations. For example, to use the POST treasures endpoint, we'll require ROLE_TREASURE_CREATE
. But we also need to make sure that if a user logs in via the login form, they can still use this operation, even though they won't have that exact role. That is where ROLE_FULL_USER
comes in handy.
Open config/packages/security.yaml
and, anywhere, add role_hierarchy
... I recommend spelling it correctly. Say ROLE_FULL_USER
. So, if you're logged in as a full user, we're going to give you all possible scopes that a token could have. Copy the three scope roles: ROLE_USER_EDIT
, ROLE_TREASURE_CREATE
and ROLE_TREASURE_EDIT
:
security: | |
... lines 2 - 12 | |
role_hierarchy: | |
ROLE_FULL_USER: [ROLE_USER_EDIT, ROLE_TREASURE_CREATE, ROLE_TREASURE_EDIT] | |
... lines 15 - 56 |
We do need to be careful to make sure that if we add more scopes, we add them here too.
Thanks to this, if we protect something by requiring ROLE_USER_EDIT
, users that are logged in via the login form will have access.
Ok team, we are done with authentication! Woo! Next, let's start into "authorization" by learning how to lock down operations so that only certain users can access them.
"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
}
}