Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

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!

14
Login or Register to join the conversation
Amine Avatar
Amine Avatar Amine | posted 9 days ago | edited

Hi All,

I followed the tutorial but I can't connect via my email / tada

I'm on Symfony 5.3

LoginFormAuthenticator.php on line 83:<br />Symfony\Component\Security\Core\Exception\BadCredentialsException {#589 ▼<br /> #message: "The presented password is invalid."<br /> #code: 0<br /> #file: "/home/amine/Projets/Symfony/Symfony6/symfony-doctrine-formation/vendor/symfony/security-http/EventListener/CheckCredentialsListener.php"<br /> #line: 74<br /> -token: null

Best,
Amine

Reply
Amine Avatar

I foun the prob.

My methode getPAssword() in USer entity return null

Good night :)

Reply

Hey Amine,

I'm happy to hear you were able to find the problem yourself, well done! And thanks for sharing your solution with others ;)

Cheers!

Reply
MattWelander Avatar
MattWelander Avatar MattWelander | posted 26 days ago

Another question =) I thought the new hashing system always used sodium, and those hashed passwords always started with "$argon". If I use the symfony console security:encode-password indeed passwords do, but when I use the password hasher

$user->setPassword($this->passwordHasher->hashPassword($user, $user->getPlainPassword()));

I get a completely different string.

Both passwords work, just curious =)

Reply

Hey @MattWelander!

Hmm, that's interesting! The point of the "auto" is that you no longer need to really care what algorithm is being used, because it'll choose whatever the latest and greatest is. That being said, I would definitely expect that security:encode-password and using the UserPasswordHasherInterface service in PHP would use the same algorithm. The algorithm is chosen, iirc, entirely based on the User class (well, the User class is used to look up your hasher config - so basically, it's chosen based on your hasher config, which does not change between php and that console command). So I also would expect to see $argon style hashed passwords in both cases. When I just checked Symfonycasts, indeed, that IS what I see: both ways give me $argon2 style hashed password.

So... that's a mystery why doing that in PHP would result in a non-argon hasher being used - I can't explain that...

Cheers!

Reply
MattWelander Avatar
MattWelander Avatar MattWelander | posted 26 days ago

Hi!
Prior to sym4 (I believe) the config security - firewalls - main - form_login would make it so that any request to a page that required authentication automatically redirected to the login page.

I find that in sym5 instead the user gets an access denied exception. What would be the equivalent config to auto-redirect to the login page?

Reply
MattWelander Avatar

Nevermind - that question is answered a few lessons down the line =) https://symfonycasts.com/screencast/symfony-security/entry-point

2 Reply

This command does not work with PostgeSQL
symfony console doctrine:query:sql "SELECT * FROM user"

returned just

----------
user
----------
postgres
----------

Reply

Solved - need to add -->"
symfony console doctrine:query:sql 'SELECT * FROM "user"'

Reply

Hey Maxim,

Yeah, user might be a reserved keyword, usually we wrap table names with tick. Thank you for sharing your solution!

Cheers!

Reply

Hi, if this is correct way to set password without Factory ?


class UserFixtures extends Fixture
{
private UserPasswordHasherInterface $passwordHasher;

public function __construct(UserPasswordHasherInterface $passwordHasher)
{
$this->passwordHasher = $passwordHasher;
}

public function load(ObjectManager $manager): void
{
$user = new User();
$user->setEmail('panda@example.com');
$user->setRoles(['ROLE_ADMIN', 'ROLE_SUPERADMIN', 'ROLE_USER']);
$user->setPlainPassword('tada');
$user->setPassword('tada');

$manager->persist($user);
$manager->flush();

$user->setPassword($this->passwordHasher->hashPassword($user, $user->getPlainPassword()));

$manager->persist($user);
$manager->flush();
}
}
Reply

Hey Maxim Symfony!

Yes! But we can even do a bit less work:

A) You can skip setting the plain password and just do $this->passwordHasher->hashPassword($user, 'tada')

B) You only need one flush(). Remove the first one and just flush/save once after you set the real password. Also, you can then remove the $user->setPassword('tada'). I'm guessing you had that just so that your database didn't throw an error during the first flush ;).

Cheers!

Reply
Cat in space

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

This tutorial also works great for Symfony 6!

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.6.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
    }
}