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 SubscribeAs a few of you have already, and correctly noticed... our POST
operation for /api/users
... doesn't really work yet! I mean, it works... but, for the password
field, we can't POST the plain text password, we have to pass an encoded version of the password... which makes no sense. We are not expecting the users of our API to actually do this.
Great. So, how can we fix this? We know that the deserialization process sees these email
, password
and username
fields and then calls the setter methods for each: setPassword()
, setUsername()
and setEmail()
. That creates a challenge because we need to use a service to encode the plain-text password. And we can't access services from inside an entity.
Nope, we need some way to intercept the process, we need to be able to run code after the JSON is deserialized into a User
object, but before it's saved to the database. One way to do this is via a Doctrine event listener or entity listener, which are more or less the same thing. That's a fine option... though things can get tricky when a user is updating their password. We talk about that on an older Symfony 3 Security Tutorial.
We're going to try a different approach - an approach that's more specific to API Platform.
Before we get there, let's write a test to make sure this works. In the test/Functional/
directory, create a new UserResourceTest
class. Make this extend our nice CustomApiTestCase
and use the ReloadDatabaseTrait
so the database gets emptied before each test.
... lines 1 - 4 | |
use App\Test\CustomApiTestCase; | |
use Hautelook\AliceBundle\PhpUnit\ReloadDatabaseTrait; | |
... line 7 | |
class UserResourceTest extends CustomApiTestCase | |
{ | |
use ReloadDatabaseTrait; | |
... lines 11 - 26 | |
} |
Because we're testing the POST endpoint, add public function testCreateUser()
with our usual start: $client = self::createClient()
.
... lines 1 - 11 | |
public function testCreateUser() | |
{ | |
$client = self::createClient(); | |
... lines 15 - 25 | |
} |
In this case... we don't need to put anything into the database before we start... so we can jump straight to the request: $client->request()
to make a POST
request to /api/users
. And we of course need to send some data via the json
key. If we look at our docs... the three fields we need are email
, password
and username
. Ok: email
set to cheeseplease@example.com
, username
set to cheeseplease
and, here's the big change, password
set not to some crazy encoded password... but to the plain text password. How about: brie
. At the end, toast to our success by asserting that we get this 201 success status code: $this->assertResponseStatusCodeSame(201)
.
... lines 1 - 11 | |
public function testCreateUser() | |
{ | |
... lines 14 - 15 | |
$client->request('POST', '/api/users', [ | |
'json' => [ | |
'email' => 'cheeseplease@example.com', | |
'username' => 'cheeseplease', | |
'password' => 'brie' | |
] | |
]); | |
$this->assertResponseStatusCodeSame(201); | |
... lines 24 - 25 | |
} |
But... this won't be enough to make sure that the password was correctly encoded. Nope, to know for sure, let's try to login: $this->logIn()
passing the $client
, the email and the password: brie
.
... lines 1 - 11 | |
public function testCreateUser() | |
{ | |
... lines 14 - 24 | |
$this->logIn($client, 'cheeseplease@example.com', 'brie'); | |
} |
That's all we need! The logIn()
method has a built-in assertion. So if the password is not correctly encoded, we'll know with a big, giant test failure.
Copy the testCreateUser()
method name and let's go try it!
php bin/phpunit --filter=testCreateUser
Failure! Yay! The login fails with:
Invalid credentials.
Because the password is not being encoded yet.
Let's get to work. According to our test, we want the user to be able to POST a field called password
. But... the password
property on our User
is meant to hold the encoded password... not the plain text password. We could, sort of, use it for both: have API Platform temporarily store the plain text password on the password
field... then encoded it before the user is saved to the database.
But don't do that. First, it's just a bit dirty: using that one property for two purposes. And second, I really, really want to avoid storing plain text passwords in the database... which could happen if, for some reason, we introduced a bug that caused our system to "forget" to encode that field before saving.
A better option is to create a new property below this called $plainPassword
. But this field will not be persisted to Doctrine: it exists just as temporary storage. Make this writable with @Groups({"user:write"})
... then stop exposing the password
field itself.
... lines 1 - 35 | |
class User implements UserInterface | |
{ | |
... lines 38 - 78 | |
/** | |
* @Groups("user:write") | |
*/ | |
private $plainPassword; | |
... lines 83 - 213 | |
} |
So, yes, this will temporarily mean that the POSTed field needs to be called plainPassword - but we'll fix that in a few minutes with @SerializedName
.
Ok, go to the Code -> Generate menu - or Command+N on a Mac - and generate the getter and setter for this field. Oh... except I don't want those up here! I want them all the way at the bottom. And... we can tighten this up a bit: this will return a nullable string, the argument on the setter will be a string and all of my setters return self
- they all have return $this
at the end.
... lines 1 - 35 | |
class User implements UserInterface | |
{ | |
... lines 38 - 204 | |
public function getPlainPassword(): ?string | |
{ | |
return $this->plainPassword; | |
} | |
public function setPlainPassword(string $plainPassword): self | |
{ | |
$this->plainPassword = $plainPassword; | |
return $this; | |
} | |
} |
Great! The new $plainPassword
field is now a writable field in our API instead of $password
. The docs show this... the POST operation... yep! It advertises plainPassword
.
Before we talk about how we can intercept this POST request, read the plainPassword
field, encode it, and set it back on the password
property, there's one teenie, tiny security detail we should handle. If you scroll down in User
... eventually you'll find an eraseCredentials()
method. This is something that UserInterface
forces us to have. After a successful authentication, Symfony calls this method... and the idea is that we're supposed to "clear" any sensitive data that may be stored on the User
- like a plain-text password - just to be safe. It's not that important, but as soon as you're storing a plain-text password on User
, even though it will never be saved to the database, it's a good idea to clear that field here.
If we stopped now... yay! We haven't... really... done anything: we added this new plainPassword
property... but nothing is using it! So, the request would ultimately explode in the database because our $password
field will be null.
Next, we need to hook into the request-handling process: we need to run some code after deserialization but before persisting. We'll do that with a data persister.
"Houston: no signs of life"
Start the conversation!
// 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
}
}