Hashing Plain Passwords & PasswordCredentials

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

The process of saving a user's password always looks like this: start with a plain-text password, hash that, then save the hashed version onto the User. This is something we're going to do in the fixtures... but we'll also do this on a registration form later... and you would also need it on a change password form.

Adding a plainPassword Field

To make this easier, I'm going to do something optional. In User, up on top, add a new private $plainPassword property:

... lines 1 - 12
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 15 - 41
private $plainPassword;
... lines 43 - 154
}

The key thing is that this property will not be persisted to the database: it's just a temporary property that we can use during, for example, registration, to store the plain password.

Below, I'll go to "Code"->"Generate" - or Command+N on a Mac - to generate the getter and setter for this. The getter will return a nullable string:

... lines 1 - 12
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 15 - 143
public function getPlainPassword(): ?string
{
return $this->plainPassword;
}
public function setPlainPassword(string $plainPassword): self
{
$this->plainPassword = $plainPassword;
return $this;
}
}

Now, if you do have a plainPassword property, you'll want to find eraseCredentials() and set $this->plainPassword to null:

... lines 1 - 12
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 15 - 118
public function eraseCredentials()
{
// If you store any temporary, sensitive data on the user, clear it here
$this->plainPassword = null;
}
... lines 124 - 154
}

This... is not really that important. After authentication is successful, Symfony calls eraseCredentials(). It's... just a way for you to "clear out any sensitive information" on your User object once authentication is done. Technically we will never set plainPassword during authentication... so it doesn't matter. But, again, it's a safe thing to do.

Hashing the Password in the Fixtures

Back inside UserFactory, instead of setting the password property, set plainPassword to "tada":

... lines 1 - 28
final class UserFactory extends ModelFactory
{
... lines 31 - 37
protected function getDefaults(): array
{
return [
... lines 41 - 42
'plainPassword' => 'tada',
];
}
... lines 46 - 58
}

If we just stopped now, it would set this property... but then the password property would stay null... and it would explode in the database because that column is required.

So after Foundry has finished instantiating the object, we need to run some extra code that reads the plainPassword and hashes it. We can do that down here in the initialize() method... via an "after instantiation" hook:

... lines 1 - 28
final class UserFactory extends ModelFactory
{
... lines 31 - 46
protected function initialize(): self
{
// see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization
return $this
// ->afterInstantiate(function(User $user) {})
;
}
... lines 54 - 58
}

This is pretty cool: call $this->afterInstantiate(), pass it a callback and, inside say if $user->getPlainPassword() - just in case we override that to null - then $user->setPassword(). Generate the hash with $this->passwordHasher->hashPassword() passing the user that we're trying to hash - so $user - and then whatever the plain password is: $user->getPlainPassword():

... lines 1 - 29
final class UserFactory extends ModelFactory
{
... lines 32 - 49
protected function initialize(): self
{
// see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization
return $this
->afterInstantiate(function(User $user) {
if ($user->getPlainPassword()) {
$user->setPassword(
$this->passwordHasher->hashPassword($user, $user->getPlainPassword())
);
}
})
;
}
... lines 63 - 67
}

Done! Let's try this. Find your terminal and run:

symfony console doctrine:fixtures:load

This will take a bit longer than before because hashing passwords is actually CPU intensive. But... it works! Check the user table:

symfony console doctrine:query:sql 'SELECT * FROM user'

And... got it! Every user has a hashed version of the password!

Validating the Password: PasswordCredentials

Finally we're ready to check the user's password inside our authenticator. To do this, we need to hash the submitted plain password then safely compare that with the hash in the database.

Well we don't need to do this... because Symfony is going to do it automatically. Check it out: replace CustomCredentials with a new PasswordCredentials and pass it the plain-text submitted password:

... lines 1 - 17
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
... lines 19 - 21
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 24 - 37
public function authenticate(Request $request): PassportInterface
{
... lines 40 - 42
return new Passport(
... lines 44 - 53
new PasswordCredentials($password)
);
}
... lines 57 - 83
}

That's it! Try it. Log in using our real user - abraca_admin@example.com - I'll copy that, then some wrong password. Nice! Invalid password! Now enter the real password tada. It works!

That's awesome! When you put a PasswordCredentials inside your Passport, Symfony automatically uses that to compare the submitted password to the hashed password of the user in the database. I love that.

This is all possible thanks to a powerful event listener system inside of security. Let's learn more about that next and see how we can leverage it to add CSRF protection to our login form... with about two lines of code.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.4.1 || ^8.0.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.3", // v3.3.0
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^2.1", // 2.4.3
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
        "doctrine/orm": "^2.7", // 2.10.1
        "knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
        "knplabs/knp-time-bundle": "^1.11", // v1.16.1
        "pagerfanta/doctrine-orm-adapter": "^3.3", // v3.3.0
        "pagerfanta/twig": "^3.3", // v3.3.0
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "scheb/2fa-bundle": "^5.12", // v5.12.1
        "scheb/2fa-qr-code": "^5.12", // v5.12.1
        "scheb/2fa-totp": "^5.12", // v5.12.1
        "sensio/framework-extra-bundle": "^6.0", // v6.2.0
        "stof/doctrine-extensions-bundle": "^1.4", // v1.6.0
        "symfony/asset": "5.3.*", // v5.3.4
        "symfony/console": "5.3.*", // v5.3.7
        "symfony/dotenv": "5.3.*", // v5.3.8
        "symfony/flex": "^1.3.1", // v1.17.5
        "symfony/form": "5.3.*", // v5.3.8
        "symfony/framework-bundle": "5.3.*", // v5.3.8
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/property-access": "5.3.*", // v5.3.8
        "symfony/property-info": "5.3.*", // v5.3.8
        "symfony/rate-limiter": "5.3.*", // v5.3.4
        "symfony/runtime": "5.3.*", // v5.3.4
        "symfony/security-bundle": "5.3.*", // v5.3.8
        "symfony/serializer": "5.3.*", // v5.3.8
        "symfony/stopwatch": "5.3.*", // v5.3.4
        "symfony/twig-bundle": "5.3.*", // v5.3.4
        "symfony/ux-chartjs": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.8
        "symfony/webpack-encore-bundle": "^1.7", // v1.12.0
        "symfony/yaml": "5.3.*", // v5.3.6
        "symfonycasts/verify-email-bundle": "^1.5", // v1.5.0
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.3
        "twig/string-extra": "^3.3", // v3.3.3
        "twig/twig": "^2.12|^3.0" // v3.3.3
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
        "symfony/debug-bundle": "5.3.*", // v5.3.4
        "symfony/maker-bundle": "^1.15", // v1.34.0
        "symfony/var-dumper": "5.3.*", // v5.3.8
        "symfony/web-profiler-bundle": "5.3.*", // v5.3.8
        "zenstruck/foundry": "^1.1" // v1.13.3
    }
}