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 SubscribeOn our User
entity, this $password
field - which is stored in the database - does not contain a plain-text version of the user's password:
... lines 1 - 19 | |
class User implements UserInterface | |
{ | |
... lines 22 - 47 | |
/** | |
* @ORM\Column(type="string", length=255) | |
*/ | |
private $password; | |
... lines 52 - 283 | |
} |
Next to allowing SQL injection attacks, storing plain-text passwords is just about the worst thing you can do in a web app.
Anyways, what's actually stored on this field is a "hash" or kind of "fingerprint" of the plaintext password and there are multiple hashing algorithms available. The one you're using is configured in config/packages/security.yaml
:
security: | |
encoders: | |
App\Entity\User: | |
algorithm: bcrypt | |
... lines 5 - 61 |
The encoders
section says that whenever we encode, or really, "hash" a password - like when someone registers or when they log in - the bcrypt
algorithm will be used. That's great. But... over time, as processing power of computers get better and better, it becomes more and more possible that if your database of passwords somehow got exposed, someone could use a computer to crack them. It probably won't happen, but it's a security best-practice to change your algorithm over time to one that requires more processing power or memory.
Comment-out the bcrypt
algorithm and replace it with sodium
:
security: | |
encoders: | |
App\Entity\User: | |
#algorithm: bcrypt | |
algorithm: sodium | |
... lines 6 - 62 |
This stuff can be confusing. Sodium is a hashing library that uses the Argon2 algorithm, which is currently considered the best algorithm.
So... great! We just changed from bcrypt
to Argon2 and increased the security of our application. We deserve a donut!
Wait a second... put that donut down. You - usually - can't simply change from one algorithm to another. Why? The problem is that all your existing users already have their passwords hashed with bcrypt
. If those users tried to log in, suddenly Symfony would use sodium
to hash the submitted password and it would not match the hash in the database.
Now, the full truth is that, in this case - going from bcrypt
to sodium
- nothing would break: Sodium is smart enough to detect that the existing passwords are hashed with bcrypt
and use it instead. But in general, you can't change from one algorithm to another without breaking stuff. And even in this case, shouldn't we also re-hash the passwords of all our existing users with the newer algorithm?
Symfony 4.4 comes with a wonderful new feature to help with this - submitted by the amazing Nicolas Grekas, who is also responsible - along with Jérémy Derussé for the secrets management system.
Here's how it works: add a new encoder, it can be called anything, how about legacy_bcrypt
. Make sure it has the exact configuration of your original encoder:
security: | |
encoders: | |
legacy_bcrypt: | |
algorithm: bcrypt | |
... lines 5 - 68 |
Next, under the new encoder - the one that will be used for my User
class - add a new option: migrate_from
. Below that, add a list of all encoders that existing users might be using - for us, just legacy_bcrypt
:
security: | |
encoders: | |
legacy_bcrypt: | |
algorithm: bcrypt | |
App\Entity\User: | |
algorithm: sodium | |
migrate_from: | |
# allow existing bcrypt accounts to log in | |
# and migrate to sodium | |
- legacy_bcrypt | |
... lines 12 - 68 |
That's it! This says:
Hey! When somebody logs in, try to use the
sodium
algorithm. If that doesn't work, try thelegacy_bcrypt
algorithm. If that doesn't work, panic! I mean, if that doesn't work, the password is invalid.
Thanks to this, we can have a database where some passwords are hashed with sodium
and others are hashed with bcrypt
. Let's try it: log out and try to log back in: admin1@thespacebar.com
, password engage
. Got it!
It's also kinda fun to see how this looks in the database. Find your terminal and run:
php bin/console doctrine:query:sql 'SELECT email, password FROM user'
Interesting: every hashed password starts with the same $2y
thing. That's no accident: that's what the bcrypt hashing format looks like.
Let's see what sodium-encoded passwords look like: go back to your browser, log out, and register as a new user: Ryan
, spacecadet@example.com
, the same password - engage
, but that doesn't matter - and register!
Try that query again:
php bin/console doctrine:query:sql 'SELECT email, password FROM user'
Cool! It's pretty obvious the new user's password is hashed with Argon.
We now have a database mixed with passwords hashed with the older algorithm and the newer algorithm. That's fine... but in a perfect world, we would re-hash all the passwords using the newer algorithm.
But... we can't do that. Boo. In order to hash a password, we need the original plain password, which we don't have. So it's not possible to upgrade all existing users to the new algorithm.
Except, hmm, there is one time when we do have the plaintext password: at the moment any old user logs into the site. At that instant, in theory, we could re-hash the password using sodium and save it to the database. That would actually be pretty awesome.
And... that's precisely what migrate_from
does automatically:
security: | |
encoders: | |
... lines 3 - 5 | |
App\Entity\User: | |
... line 7 | |
migrate_from: | |
... lines 9 - 68 |
Well, almost automatically: we need to do two things in our code to enable it.
First, if you're using Guard authentication for your login form, your authenticator needs a new interface. I'll open up src/Security/LoginFormAuthenticator.php
and add implements PasswordAuthenticatedInterface
:
... lines 1 - 17 | |
use Symfony\Component\Security\Guard\PasswordAuthenticatedInterface; | |
... lines 19 - 20 | |
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements PasswordAuthenticatedInterface | |
{ | |
... lines 23 - 93 | |
} |
Basically, we need to tell the system what the plain-text password is. I'll scroll down and then go to the "Code"->"Generator" menu - or Command
+N
on a Mac - to generate the required getPassword()
method:
... lines 1 - 20 | |
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements PasswordAuthenticatedInterface | |
{ | |
... lines 23 - 75 | |
public function getPassword($credentials): ?string | |
{ | |
... line 78 | |
} | |
... lines 80 - 93 | |
} |
Look up at getCredentials()
:
... lines 1 - 20 | |
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements PasswordAuthenticatedInterface | |
{ | |
... lines 23 - 44 | |
public function getCredentials(Request $request) | |
{ | |
$credentials = [ | |
'email' => $request->request->get('email'), | |
'password' => $request->request->get('password'), | |
'csrf_token' => $request->request->get('_csrf_token'), | |
]; | |
$request->getSession()->set( | |
Security::LAST_USERNAME, | |
$credentials['email'] | |
); | |
return $credentials; | |
} | |
... lines 60 - 93 | |
} |
We return an array with the email
, password
, and csrf_token
keys. In getPassword()
, we're passed that array as the $credentials
argument. To get the password, return $credentials['password']
:
... lines 1 - 20 | |
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements PasswordAuthenticatedInterface | |
{ | |
... lines 23 - 75 | |
public function getPassword($credentials): ?string | |
{ | |
return $credentials['password']; | |
} | |
... lines 80 - 93 | |
} |
The second change we need to make is inside src/Repository/UserRepository.php
. Implement a new interface here too called PasswordUpgraderInterface
:
... lines 1 - 7 | |
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; | |
... lines 9 - 16 | |
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface | |
{ | |
... lines 19 - 92 | |
} |
This requires one new method. Go to the "Code"->"Generate" menu - or Command
+N
on a Mac - select "Implement Methods" and choose upgradePassword()
:
... lines 1 - 8 | |
use Symfony\Component\Security\Core\User\UserInterface; | |
... lines 10 - 16 | |
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface | |
{ | |
... lines 19 - 59 | |
public function upgradePassword(UserInterface $user, string $newEncodedPassword): void | |
{ | |
} | |
... lines 64 - 92 | |
} |
Here's the idea: when we log in, if the user's password is hashed with an old algorithm, the security system will call getPassword()
on our authenticator to get the plain-text password and then hash it using the latest algorithm. To save that newly-hashed string to the user
table, it will call this upgradePassword()
method and pass it to us.
So, our job here is to update the database. I'll add a little PHPDoc above this method: we know the $user
variable will be our User
object:
... lines 1 - 16 | |
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface | |
{ | |
... lines 19 - 59 | |
/** | |
* @param User $user | |
*/ | |
public function upgradePassword(UserInterface $user, string $newEncodedPassword): void | |
{ | |
... lines 65 - 66 | |
} | |
... lines 68 - 96 | |
} |
Now add $user->setPassword($newEncodedPassword)
and then $this->getEntityManager()->flush($user)
:
... lines 1 - 16 | |
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface | |
{ | |
... lines 19 - 59 | |
/** | |
* @param User $user | |
*/ | |
public function upgradePassword(UserInterface $user, string $newEncodedPassword): void | |
{ | |
$user->setPassword($newEncodedPassword); | |
$this->getEntityManager()->flush($user); | |
} | |
... lines 68 - 96 | |
} |
That's it! Test drive time! Find your browser and log out. Log back in with admin1@thespacebar.com
, password engage
. It works. But the real test is what the database looks like! Run that query again:
php bin/console doctrine:query:sql 'SELECT email, password FROM user'
Scroll up and... there it is! admin0
still has the bcrypt
format but admin1
- the user we just logged in as - has an argon-hashed password!
So that's it! By adding a few lines of config and two simple methods, our existing users will be upgraded to the latest algorithm safely over time. And we can brag about this cool feature to our friends.
Next, we're just about done with our tour through my favorite new Symfony 5 features. But before we're done, I want to talk about PHP 7.4 preloading and a way to double-check that service wiring across your entire app is working correctly. Because, surprise! We have a hidden bug.
I'm also curious about this. https://symfony.com/blog/ne... shows using "algorithm: auto". Which one should I use?
Hey Kevin B. and @Kevin Bond!
Great question - I should have mentioned that! Algorithm auto is perfect to use. I was focusing more on the case of "how do I upgrade my old algorithm (auto is not an old algorithm) to a new one" and the migrate_from is perfect for that.
To be clear, a few points:
A) auto should be used for new projects... as it manages everything for you. As a bonus, it comes migrate_from "built-in" - but only for a few, specific encoders/algorithms. You can specify another algorithm it should include in its built-in migrate_from via the hash_algorithm
option... but basically I find that "auto" is confusing if you're trying to use it to migrate from an old encoder. But it's perfect for new projects.
B) auto and the migrate_from option are incompatible with each other: you can only use one of them, not both together. If you're migrating passwords from an old algorithm/encoder, the migrate_from is much clearer to use.
Btw, credit to Nicolas Grekas who was answering my original questions about all of this for the tutorial :).
Cheers!
I hate symfony for this "auto and the migrate_from option are incompatible with each other:" I have spent so many hours for simple password hash migration. It should give error. I just does nothing and I have no idea why it is not working. I am just lukcy that I found this comment. I have wasted so many hours for such a simple thing which become not simple when it does not give errors :(
Thanks Ryan,
My app currently uses bcrypt for all users. I want to migrate them to the latest but also never have to worry about upgrading them to the latest again. This is why auto was so appealing to me.
I looked into this a bit further and did some testing. Turns out simply switching my algo from "bcrypt" to "auto" works out of the box. Existing users with bcrypt passwords can still login. Once I implemented the appropriate migration interfaces passwords are properly migrated to "argon2id" on login. I assume, in the future, if a better algo is available, they will be seamlessly migrated to it.
Yo Kevin B.!
You got it - bcrypt works with argon, which is what auto is using right now anyways.
I assume, in the future, if a better algo is available, they will be seamlessly migrated to it.
That's my impression as well :). Though, I believe (this is from talking with Nicolas) that even if you chose Sodium, if you needed to change to something else in the future, that change should be seamless as well. They key thing is that modern encoders embed the algorithm details into the hash, which means that future hashers can handle them. That's precisely why sodium (which uses argon) is able to handle your bcrypt passwords without any issue.
A tl;dr on this might be:
A) Try switching to auto! Can you still log in using your older users! Coolio! Use that - the "migrate_from" is built-in.
B) If not, use migrate_from
so that you can explicitly tell Symfony how to handle your old users.
Cheers!
was not able to migrate with auto. But at least with setting migrate_from and exact new algo it works.
// composer.json
{
"require": {
"php": "^7.3.0",
"ext-iconv": "*",
"antishov/doctrine-extensions-bundle": "^1.4", // v1.4.2
"aws/aws-sdk-php": "^3.87", // 3.110.11
"composer/package-versions-deprecated": "^1.11", // 1.11.99
"doctrine/doctrine-bundle": "^2.0", // 2.0.6
"doctrine/doctrine-migrations-bundle": "^1.3|^2.0", // 2.1.2
"doctrine/orm": "^2.5.11", // v2.7.2
"doctrine/persistence": "^1.3.7", // 1.3.8
"easycorp/easy-log-handler": "^1.0", // v1.0.9
"http-interop/http-factory-guzzle": "^1.0", // 1.0.0
"knplabs/knp-markdown-bundle": "^1.7", // 1.8.1
"knplabs/knp-paginator-bundle": "^5.0", // v5.0.0
"knplabs/knp-snappy-bundle": "^1.6", // v1.7.0
"knplabs/knp-time-bundle": "^1.8", // v1.11.0
"league/flysystem-aws-s3-v3": "^1.0", // 1.0.23
"league/flysystem-cached-adapter": "^1.0", // 1.0.9
"league/html-to-markdown": "^4.8", // 4.8.2
"liip/imagine-bundle": "^2.1", // 2.3.0
"nexylan/slack-bundle": "^2.1", // v2.2.1
"oneup/flysystem-bundle": "^3.0", // 3.3.0
"php-http/guzzle6-adapter": "^2.0", // v2.0.1
"sensio/framework-extra-bundle": "^5.1", // v5.5.3
"symfony/asset": "5.0.*", // v5.0.2
"symfony/console": "5.0.*", // v5.0.2
"symfony/dotenv": "5.0.*", // v5.0.2
"symfony/flex": "^1.0", // v1.17.6
"symfony/form": "5.0.*", // v5.0.2
"symfony/framework-bundle": "5.0.*", // v5.0.2
"symfony/mailer": "5.0.*", // v5.0.2
"symfony/messenger": "5.0.*", // v5.0.2
"symfony/monolog-bundle": "^3.5", // v3.5.0
"symfony/security-bundle": "5.0.*", // v5.0.2
"symfony/sendgrid-mailer": "5.0.*", // v5.0.2
"symfony/serializer-pack": "^1.0", // v1.0.2
"symfony/twig-bundle": "5.0.*", // v5.0.2
"symfony/twig-pack": "^1.0", // v1.0.0
"symfony/validator": "5.0.*", // v5.0.2
"symfony/webpack-encore-bundle": "^1.4", // v1.7.2
"symfony/yaml": "5.0.*", // v5.0.2
"twig/cssinliner-extra": "^2.12", // v2.12.0
"twig/extensions": "^1.5", // v1.5.4
"twig/inky-extra": "^2.12" // v2.12.0
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.0", // 3.3.0
"fzaninotto/faker": "^1.7", // v1.8.0
"symfony/browser-kit": "5.0.*", // v5.0.2
"symfony/debug-bundle": "5.0.*", // v5.0.2
"symfony/maker-bundle": "^1.0", // v1.14.3
"symfony/phpunit-bridge": "5.0.*", // v5.0.2
"symfony/profiler-pack": "^1.0", // v1.0.4
"symfony/var-dumper": "5.0.*" // v5.0.2
}
}
Hi,
Taking a look at my git repository, since Sep 29, 2019 I have selected the option 'algorithm: auto' in my security.yaml (previously had bcrypt),
Should I write sodium instead of auto to migrate the password algorithm, or would the auto option still work?