gstreamer0.10-ffmpeg
gstreamer0.10-plugins-good
packages.
The most important property on ApiToken
is the token string... which needs to be something random. Create a construct method with a string $tokenType
argument:
... lines 1 - 8 | |
class ApiToken | |
{ | |
... lines 11 - 30 | |
public function __construct(string $tokenType = self::PERSONAL_ACCESS_TOKEN_PREFIX) | |
{ | |
... line 33 | |
} | |
... lines 35 - 87 | |
} |
This isn't mandatory, but GitHub has caught onto something neat - since they have different types of tokens, like personal access tokens and OAuth tokens - they give each token type its own prefix. It just helps figure out where each comes from.
We're only going to have one type, but we'll follow the idea. On top, to store the type prefix, add private const PERSONAL_ACCESS_TOKEN_PREFIX = 'tcp_'
:
... lines 1 - 8 | |
class ApiToken | |
{ | |
private const PERSONAL_ACCESS_TOKEN_PREFIX = 'tcp_'; | |
... lines 12 - 87 | |
} |
I... just made up that prefix. Our site is called Treasure Connect... and this is a personal access token, so tcp_
.
Below, for string $tokenType =
default it to self::PERSONAL_ACCESS_TOKEN_PREFIX
:
... lines 1 - 8 | |
class ApiToken | |
{ | |
... lines 11 - 30 | |
public function __construct(string $tokenType = self::PERSONAL_ACCESS_TOKEN_PREFIX) | |
{ | |
... line 33 | |
} | |
... lines 35 - 87 | |
} |
For the token itself, say $this->token = $tokenType.
and then I'll use some code that will generate a random string that's 64 characters long:
... lines 1 - 8 | |
class ApiToken | |
{ | |
... lines 11 - 30 | |
public function __construct(string $tokenType = self::PERSONAL_ACCESS_TOKEN_PREFIX) | |
{ | |
$this->token = $tokenType.bin2hex(random_bytes(32)); | |
} | |
... lines 35 - 87 | |
} |
So that's 64 characters here plus the 4 character prefix equals 68. That's why I chose that length. And because we're setting the $token
in the constructor, this doesn't need to = null
or be nullable anymore. It will always be a string
.
Ok! This is set up! So let's add some API tokens to the database. At your terminal, run
php ./bin/console make:factory
so we can generate a Foundry factory for ApiToken
. Go check out the new class: src/Factory/ApiTokenFactory.php
. Down in getDefaults()
:
... lines 1 - 29 | |
final class ApiTokenFactory extends ModelFactory | |
{ | |
... lines 32 - 46 | |
protected function getDefaults(): array | |
{ | |
return [ | |
'ownedBy' => UserFactory::new(), | |
'scopes' => [], | |
'token' => self::faker()->text(64), | |
]; | |
} | |
... lines 55 - 69 | |
} |
This looks mostly fine, though we don't need to pass in the token
. Oh, and I want to tweak the scopes:
... lines 1 - 29 | |
final class ApiTokenFactory extends ModelFactory | |
{ | |
... lines 32 - 46 | |
protected function getDefaults(): array | |
{ | |
return [ | |
'ownedBy' => UserFactory::new(), | |
'scopes' => [ | |
... lines 52 - 53 | |
], | |
]; | |
} | |
... lines 57 - 71 | |
} |
Typically, when you create an access token - whether it's a personal access token or one created through OAuth - you're able to choose which permissions that token will have: it does not automatically have all the permissions that a normal user would. I want to add that into our system as well.
Back over in ApiToken
, at the top, after the first constant, I'll paste in a few more:
... lines 1 - 8 | |
class ApiToken | |
{ | |
... lines 11 - 12 | |
public const SCOPE_USER_EDIT = 'ROLE_USER_EDIT'; | |
public const SCOPE_TREASURE_CREATE = 'ROLE_TREASURE_CREATE'; | |
public const SCOPE_TREASURE_EDIT = 'ROLE_TREASURE_EDIT'; | |
... lines 16 - 97 | |
} |
This defines three different scopes that a token can have. This isn't all the scopes we could imagine, but it's enough to make things realistic. So, when you create a token, you can choose whether that token should have permission to edit user data, or whether it can create treasures on behalf of the user or whether it can edit treasures on behalf of the user. I also added a public const SCOPES
to describes them:
... lines 1 - 8 | |
class ApiToken | |
{ | |
... lines 11 - 16 | |
public const SCOPES = [ | |
self::SCOPE_USER_EDIT => 'Edit User', | |
self::SCOPE_TREASURE_CREATE => 'Create Treasures', | |
self::SCOPE_TREASURE_EDIT => 'Edit Treasures', | |
]; | |
... lines 22 - 97 | |
} |
Back over in our ApiTokenFactory
, let's, by default, give each ApiToken
two of those three scopes:
... lines 1 - 29 | |
final class ApiTokenFactory extends ModelFactory | |
{ | |
... lines 32 - 46 | |
protected function getDefaults(): array | |
{ | |
return [ | |
'ownedBy' => UserFactory::new(), | |
'scopes' => [ | |
ApiToken::SCOPE_TREASURE_CREATE, | |
ApiToken::SCOPE_USER_EDIT, | |
], | |
]; | |
} | |
... lines 57 - 71 | |
} |
Ok! ApiTokenFactory
is ready. Last step: open AppFixtures
so we can create some ApiToken
fixtures. I want to make sure that, in our dummy data, each user has at least one or two API tokens. An easy way to do that, down here is to say ApiTokenFactory::createMany()
. Since we have 10 users, let's create 30 tokens. Then pass that a callback function and, inside, return an override for the default data. We're going to override the ownedBy
to be UserFactory::random()
:
... lines 1 - 4 | |
use App\Factory\ApiTokenFactory; | |
... lines 6 - 10 | |
class AppFixtures extends Fixture | |
{ | |
public function load(ObjectManager $manager): void | |
{ | |
... lines 15 - 26 | |
ApiTokenFactory::createMany(30, function () { | |
return [ | |
'ownedBy' => UserFactory::random(), | |
]; | |
}); | |
} | |
} |
So this will create 30 tokens and assign them randomly to the 10, well really 11, users in the database. So on average, each user should have about three API tokens assigned to them. I'm doing this because, to keep life simple, we're not going to build a user interface where the user can actually click and create access tokens and select scopes. We're going to skip all that. Instead, since every user will already have some API tokens in the database, we can jump straight to learning how to read and validate those tokens.
Reload the fixtures with:
symfony console doctrine:fixtures:load
And... beautiful! But since we're not going to build an interface for creating tokens, we at least need an easy way to see the tokens for a user... so we can test them in our API. When we're authenticated, we can show them right here.
This isn't a very important detail, so I'll do it real quick. Over in User
, at the bottom, I'll paste in a function that returns an array of the valid API token strings for this user:
... lines 1 - 38 | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 41 - 222 | |
/** | |
* @return string[] | |
*/ | |
public function getValidTokenStrings(): array | |
{ | |
return $this->getApiTokens() | |
->filter(fn (ApiToken $token) => $token->isValid()) | |
->map(fn (ApiToken $token) => $token->getToken()) | |
->toArray() | |
; | |
} | |
} |
In ApiToken
, we also need an isValid()
method... so I'll paste that as well:
... lines 1 - 8 | |
class ApiToken | |
{ | |
... lines 11 - 98 | |
public function isValid(): bool | |
{ | |
return $this->expiresAt === null || $this->expiresAt > new \DateTimeImmutable(); | |
} | |
} |
You can get all of this from the code blocks on this page.
Next, open up assets/vue/controllers/TreasureConnectApp.vue
... and add a new prop that can be passed in: tokens
:
... lines 1 - 34 | |
<script setup> | |
... lines 36 - 40 | |
const props = defineProps(['entrypoint', 'user', 'tokens']) | |
... lines 42 - 47 | |
</script> |
Thanks to that, we'll have a new tokens
variable in the template. After the "Log Out" link, I'll paste in some code that renders those:
<template> | |
<div class="purple flex flex-col min-h-screen"> | |
... lines 3 - 5 | |
<div class="flex-auto flex flex-col sm:flex-row justify-center px-8"> | |
<LoginForm | |
v-on:user-authenticated="onUserAuthenticated"></LoginForm> | |
<div | |
class="book shadow-md rounded sm:ml-3 px-8 pt-8 pb-8 mb-4 sm:w-1/2 md:w-1/3 text-center"> | |
<div v-if="user"> | |
... lines 12 - 13 | |
| <a href="/logout" class="underline">Log out</a> | |
<br> | |
<h3 class="text-left font-semibold mt-2">Tokens</h3> | |
<div v-if="null === tokens">Refresh to see tokens...</div> | |
<dl v-else class="text-left max-w-md text-gray-900 divide-y divide-gray-200 dark:divide-gray-700"> | |
<div class="flex flex-col py-3" v-for="token in tokens" :key="token"> | |
<dd class="text-xs whitespace-normal break-words">{{ token }}</dd> | |
</div> | |
</dl> | |
</div> | |
... lines 24 - 28 | |
</div> | |
</div> | |
... line 31 | |
</div> | |
</template> | |
... lines 34 - 49 |
Last step: open templates/main/homepage.html.twig
. This is where we're passing props to our Vue app. Pass a new one called tokens
set to, if app.user
, then app.user.validTokenStrings
, else null
:
... lines 1 - 2 | |
{% block body %} | |
<div {{ vue_component('TreasureConnectApp', { | |
... lines 5 - 6 | |
tokens: app.user ? app.user.validTokenStrings : null | |
}) }}></div> | |
{% endblock %} |
Let's try this! If we refresh, right now we are not logged in. Use our cheater links to log in. Notice that it doesn't show them immediately... we could improve our code to do that... but it's not a big deal. Refresh and... there they are! We have two tokens!
Next: let's write a system so that can read these tokens and authenticate the user instead of using session authentication.
// 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
}
}