Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This tutorial has a new version, check it out!

Adding the plainPassword Field

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

As 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.

Testing the POST User Endpoint

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.

Adding plainPassword

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;
}
}

eraseCredentials

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.

Leave a comment!

0
Login or Register to join the conversation
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial works great for Symfony 5 and API Platform 2.5/2.6.

What PHP libraries does this tutorial use?

// 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
    }
}
userVoice