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 SubscribeThe 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.
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.
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!
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.
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!
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 =)
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!
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?
Nevermind - that question is answered a few lessons down the line =) https://symfonycasts.com/screencast/symfony-security/entry-point
This command does not work with PostgeSQL symfony console doctrine:query:sql "SELECT * FROM user"
returned just
`
user
postgres
`
Hey Maxim,
Yeah, user might be a reserved keyword, usually we wrap table names with tick. Thank you for sharing your solution!
Cheers!
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();
}
}
Hey Mepcuk!
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!
// composer.json
{
"require": {
"php": ">=8.1",
"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
}
}
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