Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Hash de contraseñas en texto plano y 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

El proceso de guardar la contraseña de un usuario siempre es el siguiente: empieza con una contraseña en texto plano, haz un hash de la misma, y luego guarda la versión hash en el User. Esto es algo que vamos a hacer en los accesorios... pero también lo haremos en un formulario de registro más adelante... y también lo necesitarías en un formulario de cambio de contraseña.

Añadir un campo plainPassword

Para facilitar esto, voy a hacer algo opcional. En User, arriba, añade una nueva propiedad private $plainPassword:

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

La clave es que esta propiedad no se persistirá en la base de datos: es sólo una propiedad temporal que podemos utilizar durante, por ejemplo, el registro, para almacenar la contraseña simple.

A continuación, iré a "Código"->"Generar" -o Command+N en un Mac- para generar el getter y el setter para esto. El getter devolverá un string nulo:

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

Ahora, si tienes una propiedad plainPassword, querrás encontrareraseCredentials() y poner $this->plainPassword en 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
}

Esto... no es realmente tan importante. Después de que la autenticación sea exitosa, Symfony llama a eraseCredentials(). Es... sólo una forma de "borrar cualquier información sensible" de tu objeto User una vez que se ha realizado la autenticación. Técnicamente nunca estableceremos plainPassword durante la autenticación... así que no importa. Pero, de nuevo, es algo seguro.

Hacer un hash de la contraseña en los accesorios

De vuelta a UserFactory, en lugar de establecer la propiedad password, estableceplainPassword como "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
}

Si nos detuviéramos ahora, se establecería esta propiedad... pero entonces la propiedad password seguiría siendo null... y explotaría en la base de datos porque esa columna es necesaria.

Así que, después de que Foundry haya terminado de instanciar el objeto, tenemos que ejecutar algún código adicional que lea el plainPassword y lo someta a hash. Podemos hacerlo aquí abajo, en el métodoinitialize()... mediante un gancho "después de la instanciación":

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

Esto está muy bien: llama a $this->afterInstantiate(), pásale una llamada de retorno y, dentro de digamos si $user->getPlainPassword() -por si acaso lo anulamos anull -, entonces $user->setPassword(). Genera el hash con$this->passwordHasher->hashPassword() pasándole el usuario al que estamos tratando de hacer el hash - así que $user - y luego lo que sea la contraseña simple:$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
}

¡Hecho! Vamos a probar esto. Busca tu terminal y ejecuta:

symfony console doctrine:fixtures:load

Esto te llevará un poco más de tiempo que antes, porque hacer el hash de las contraseñas requiere un uso intensivo de la CPU. Pero... ¡funciona! Comprueba la tabla user:

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

Y... ¡lo tengo! ¡Cada usuario tiene una versión con hash de la contraseña!

Validación de la contraseña: PasswordCredentials

Por último, estamos preparados para comprobar la contraseña del usuario dentro de nuestro autentificador. Para ello, tenemos que hacer un hash de la contraseña simple enviada y luego compararla de forma segura con el hash de la base de datos.

Bueno, no necesitamos hacerlo... porque Symfony lo hará automáticamente. Compruébalo: sustituye CustomCredentials por un nuevo PasswordCredentials y pásale la contraseña en texto plano enviada:

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

¡Ya está! Pruébalo. Accede con nuestro usuario real - abraca_admin@example.com - y copia eso, y luego una contraseña errónea. ¡Muy bien! ¡Contraseña no válida! Ahora introduce la contraseña real tada. ¡Funciona!

¡Es increíble! Cuando pones un PasswordCredentials dentro de tu Passport, Symfony lo utiliza automáticamente para comparar la contraseña enviada con la contraseña con hash del usuario en la base de datos. Eso me encanta.

Todo esto es posible gracias a un potente sistema de escucha de eventos dentro de la seguridad. Vamos a aprender más sobre eso a continuación y veremos cómo podemos aprovecharlo para añadir protección CSRF a nuestro formulario de acceso... con unas dos líneas de código.

Leave a comment!

14
Login or Register to join the conversation
Amine Avatar
Amine Avatar Amine | posted hace 4 meses | 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 hace 5 meses

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 hace 5 meses

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 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!

Reply
Cat in space

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

¡Este tutorial también funciona muy bien para 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
    }
}