Verificación de la URL firmada del correo electrónico de confirmación
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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeAhora estamos generando una URL firmada que normalmente incluiríamos en un correo electrónico de "confirmación de la dirección de correo electrónico" que enviamos al usuario tras el registro. Para simplificar las cosas, sólo vamos a renderizar esa URL en la página después del registro.
Eliminando nuestro Bind no utilizado
Vamos a ver qué aspecto tiene. Refresca y... ¡ah! ¡Un error de aspecto terrible!
Se ha configurado un enlace para un argumento llamado
$formLoginAuthenticatoren_defaults, pero no se ha encontrado el argumento correspondiente.
Así que, hasta hace unos minutos, teníamos un argumento para nuestra acción register() que se llamaba $formLoginAuthenticator. En config/services.yaml, hemos configurado un "bind" global que decía
Siempre que un servicio autocableado tenga un argumento llamado
$formLoginAuthenticator, por favor, pasa este servicio.
| // ... lines 1 - 8 | |
| services: | |
| # default configuration for services in *this* file | |
| _defaults: | |
| // ... lines 12 - 13 | |
| bind: | |
| // ... line 15 | |
| $formLoginAuthenticator: '@security.authenticator.form_login.main' | |
| // ... lines 17 - 32 |
Una de las cosas buenas de bind es que si no hay un argumento que coincida en ninguna parte de nuestra aplicación, lanza una excepción. Intenta asegurarse de que no estamos cometiendo una errata accidental.
En nuestra situación, ya no necesitamos ese argumento. Así que elimínalo. Y ahora... ¡nuestra página de registro está viva!
Comprobando la URL de verificación
¡Hagamos esto! Introduce un correo electrónico, una contraseña, acepta las condiciones y pulsa registrar. ¡Genial! Aquí está nuestra URL de confirmación por correo electrónico. Puedes ver que va a/verify: que dará a nuestra nueva acción verifyUserEmail(). También incluye una caducidad. Eso es algo que puedes configurar... es el tiempo de validez del enlace. Y tiene un signature: que es algo que ayudará a demostrar que el usuario no se ha inventado esta URL: definitivamente viene de nosotros.
También incluye un id=18: nuestro identificador de usuario.
Verificar la URL firmada
Así que nuestro trabajo ahora es ir al método del controlador verifyUserEmail aquí abajo y validar esa URL firmada. Para ello, necesitamos unos cuantos argumentos: el objeto Request -para poder leer los datos de la URL-, unVerifyEmailHelperInterface para ayudarnos a validar la URL y, por último, nuestro UserRepository -para poder consultar el objeto User:
| // ... lines 1 - 6 | |
| use App\Repository\UserRepository; | |
| // ... line 8 | |
| use Symfony\Component\HttpFoundation\Request; | |
| // ... lines 10 - 13 | |
| use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface; | |
| class RegistrationController extends AbstractController | |
| { | |
| // ... lines 18 - 60 | |
| public function verifyUserEmail(Request $request, VerifyEmailHelperInterface $verifyEmailHelper, UserRepository $userRepository): Response | |
| { | |
| // ... lines 63 - 80 | |
| } | |
| } |
Y en realidad, ese es nuestro primer trabajo. Digamos que $user = $userRepository->find() y encontrar el usuario al que pertenece este enlace de confirmación leyendo el parámetro de consulta id. Así que, $request->query->get('id'). Y si, por alguna razón, no podemos encontrar el User, vamos a lanzar una página 404 lanzando$this->createNotFoundException():
| // ... lines 1 - 15 | |
| class RegistrationController extends AbstractController | |
| { | |
| // ... lines 18 - 60 | |
| public function verifyUserEmail(Request $request, VerifyEmailHelperInterface $verifyEmailHelper, UserRepository $userRepository): Response | |
| { | |
| $user = $userRepository->find($request->query->get('id')); | |
| if (!$user) { | |
| throw $this->createNotFoundException(); | |
| } | |
| // ... lines 67 - 80 | |
| } | |
| } |
Ahora podemos asegurarnos de que la URL firmada no ha sido manipulada. Para ello, añade un bloque try-catch. Dentro, di $verifyEmailHelper->validateEmailConfirmation()y pasa un par de cosas. Primero, la URL firmada, que... es la URL actual. Obténla con $request->getUri(). A continuación, pasa el identificador del usuario - $user->getId() y luego el correo electrónico del usuario - $user->getEmail():
| // ... lines 1 - 15 | |
| class RegistrationController extends AbstractController | |
| { | |
| // ... lines 18 - 60 | |
| public function verifyUserEmail(Request $request, VerifyEmailHelperInterface $verifyEmailHelper, UserRepository $userRepository): Response | |
| { | |
| $user = $userRepository->find($request->query->get('id')); | |
| if (!$user) { | |
| throw $this->createNotFoundException(); | |
| } | |
| try { | |
| $verifyEmailHelper->validateEmailConfirmation( | |
| $request->getUri(), | |
| $user->getId(), | |
| $user->getEmail(), | |
| ); | |
| // ... lines 74 - 77 | |
| } | |
| // ... lines 79 - 80 | |
| } | |
| } |
Esto asegura que la identificación y el correo electrónico no han cambiado en la base de datos desde que se envió el correo de verificación. Bueno, el id definitivamente no ha cambiado... ya que lo acabamos de utilizar para la consulta. Esta parte sólo se aplica realmente si confías en que el usuario esté conectado para verificar su correo electrónico.
De todos modos, si esto tiene éxito... ¡no pasará nada! Si falla, lanzará una excepción especial que implementa VerifyEmailExceptionInterface:
| // ... lines 1 - 15 | |
| class RegistrationController extends AbstractController | |
| { | |
| // ... lines 18 - 60 | |
| public function verifyUserEmail(Request $request, VerifyEmailHelperInterface $verifyEmailHelper, UserRepository $userRepository): Response | |
| { | |
| // ... lines 63 - 67 | |
| try { | |
| $verifyEmailHelper->validateEmailConfirmation( | |
| $request->getUri(), | |
| $user->getId(), | |
| $user->getEmail(), | |
| ); | |
| } catch (VerifyEmailExceptionInterface $e) { | |
| // ... lines 75 - 77 | |
| } | |
| // ... lines 79 - 80 | |
| } | |
| } |
Así que, aquí abajo, sabemos que la verificación de la URL ha fallado... tal vez alguien se ha equivocado. O, más probablemente, el enlace ha caducado. Digamos al usuario la razón aprovechando de nuevo el sistema flash. Digamos $this->addFlash(), pero esta vez poniéndolo en una categoría diferente llamada error. Luego, para decir lo que ha ido mal, utiliza $e->getReason(). Por último, utiliza redirectToRoute() para enviarlos a algún sitio. ¿Qué tal la página de registro?
| // ... lines 1 - 15 | |
| class RegistrationController extends AbstractController | |
| { | |
| // ... lines 18 - 60 | |
| public function verifyUserEmail(Request $request, VerifyEmailHelperInterface $verifyEmailHelper, UserRepository $userRepository): Response | |
| { | |
| // ... lines 63 - 67 | |
| try { | |
| $verifyEmailHelper->validateEmailConfirmation( | |
| $request->getUri(), | |
| $user->getId(), | |
| $user->getEmail(), | |
| ); | |
| } catch (VerifyEmailExceptionInterface $e) { | |
| $this->addFlash('error', $e->getReason()); | |
| return $this->redirectToRoute('app_register'); | |
| } | |
| dd('TODO'); | |
| } | |
| } |
Para mostrar el error, vuelve a base.html.twig, duplica todo este bloque, pero busca los mensajes de error y utiliza alert-danger:
| <html> | |
| // ... lines 3 - 14 | |
| <body | |
| // ... lines 16 - 81 | |
| {% for flash in app.flashes('success') %} | |
| <div class="alert alert-success">{{ flash }}</div> | |
| {% endfor %} | |
| {% for flash in app.flashes('error') %} | |
| <div class="alert alert-danger">{{ flash }}</div> | |
| {% endfor %} | |
| // ... lines 88 - 92 | |
| </body> | |
| </html> |
¡Uf! Probemos el caso del error. Copia la URL y luego abre una nueva pestaña y pégala. Si voy a esta URL real... funciona. Bueno, todavía tenemos que hacer algo más de codificación, pero llega a nuestro TODO en la parte inferior del controlador. Ahora juega con la URL, como eliminar algunos caracteres... o ajustar la caducidad o cambiar el id. Ahora... ¡sí! Ha fallado porque nuestro enlace no es válido. Si el enlace estuviera caducado, verías un mensaje al respecto.
Así que, por fin, ¡acabemos con el caso feliz! En la parte inferior de nuestro controlador, ahora que sabemos que el enlace de verificación es válido, hemos terminado. Para nuestra aplicación, podemos decir $user->isVerified(true) y almacenarlo en la base de datos:
| // ... lines 1 - 16 | |
| class RegistrationController extends AbstractController | |
| { | |
| // ... lines 19 - 61 | |
| public function verifyUserEmail(Request $request, VerifyEmailHelperInterface $verifyEmailHelper, UserRepository $userRepository, EntityManagerInterface $entityManager): Response | |
| { | |
| // ... lines 64 - 68 | |
| try { | |
| // ... lines 70 - 78 | |
| } | |
| $user->setIsVerified(true); | |
| // ... lines 82 - 86 | |
| } | |
| } |
Veamos... necesitamos un argumento más: EntityManagerInterface $entityManager:
| // ... lines 1 - 7 | |
| use Doctrine\ORM\EntityManagerInterface; | |
| // ... lines 9 - 16 | |
| class RegistrationController extends AbstractController | |
| { | |
| // ... lines 19 - 61 | |
| public function verifyUserEmail(Request $request, VerifyEmailHelperInterface $verifyEmailHelper, UserRepository $userRepository, EntityManagerInterface $entityManager): Response | |
| { | |
| // ... lines 64 - 86 | |
| } | |
| } |
Aquí abajo, utiliza $entityManager->flush() para guardar ese cambio:
| // ... lines 1 - 16 | |
| class RegistrationController extends AbstractController | |
| { | |
| // ... lines 19 - 61 | |
| public function verifyUserEmail(Request $request, VerifyEmailHelperInterface $verifyEmailHelper, UserRepository $userRepository, EntityManagerInterface $entityManager): Response | |
| { | |
| // ... lines 64 - 80 | |
| $user->setIsVerified(true); | |
| $entityManager->flush(); | |
| // ... lines 83 - 86 | |
| } | |
| } |
Y demos a esto un feliz mensaje de éxito:
¡Cuenta verificada! Ya puedes conectarte.
Bueno, la verdad es que todavía no impedimos que se conecten antes de verificar su correo electrónico. Pero lo haremos pronto. De todos modos, termina redirigiendo a la página de inicio de sesión: app_login:
| // ... lines 1 - 16 | |
| class RegistrationController extends AbstractController | |
| { | |
| // ... lines 19 - 61 | |
| public function verifyUserEmail(Request $request, VerifyEmailHelperInterface $verifyEmailHelper, UserRepository $userRepository, EntityManagerInterface $entityManager): Response | |
| { | |
| // ... lines 64 - 80 | |
| $user->setIsVerified(true); | |
| $entityManager->flush(); | |
| $this->addFlash('success', 'Account Verified! You can now log in.'); | |
| return $this->redirectToRoute('app_login'); | |
| } | |
| } |
Si quieres ser aún más genial, podrías autenticar manualmente al usuario de la misma manera que lo hicimos antes en nuestro controlador de registro. Eso está totalmente bien y depende de ti.
De vuelta a mi pestaña principal... copia ese enlace de nuevo, pégalo y... ¡estamos verificados! ¡Qué bien!
Lo único que queda por hacer es impedir que el usuario se registre hasta que haya verificado su correo electrónico. Para ello, primero tenemos que conocer los eventos que ocurren dentro del sistema de seguridad. Y para mostrarlos, aprovecharemos una nueva función muy interesante: el estrangulamiento del inicio de sesión.
19 Comments
Howdy, I have set this up just like in the tutorial but every time I try to validate the link it throws an exception with the message that the link is invalid. I read that there was a problem in the maker-bundle to output the signedUrl with a raw selector, but that doesn't work either. Is there a solution for this?
I run my app with docker-compose, with Symfony 6.3 and with PHP 8.2.
Hey @Rsteuber!
Sorry for the slow reply! Hmm, this kind of failure is tough - it's hard to debug. What we need to know is why is it invalid? And the bundle doesn't give better errors because, for production, sometimes we want to hide that info. To figure out the problem, exactly which exception class is being thrown? That would show us which part of this function - https://github.com/SymfonyCasts/verify-email-bundle/blob/main/src/VerifyEmailHelper.php#L62-L77 - is failing. You mentioned that it says that the "link is invalid", which makes me think it's the
InvalidSignatureException. If that's true, then there's a problem with the "signed url", which is odd. We use aUriSignerclass from Symfony and the logic is pretty simple:A) We ask it to sign the URL - https://github.com/SymfonyCasts/verify-email-bundle/blob/main/src/VerifyEmailHelper.php#L56
B) We as it to verify that signature - https://github.com/SymfonyCasts/verify-email-bundle/blob/main/src/VerifyEmailHelper.php#L64
I would put a dump when the request is signed (dump
$signaturein A) then compare that to the URL when you click the link. It may be that we're losing part of the URL... or there is some URL or HTML encoding that is slightly changing it. It needs to be exactly the same.Let me know if this helps!
Cheers!
Hey Ryan,
No problem for replying so late. We are busy too so totally understandable :)
I've dumped the $signature and compared it with the link in the email. Turns out they are exactly the same:
------ dump ------
verify?expires=1701263583&id=27&signature=eJHNOlI3HkWXhZOJfpJrTeIOEjdYR2pJuOtE8l0ktFQ%3D&token=UGMyl2OXc8HLSCU9qRZ9Pn3%2BRn1ovX%2BVXXq8AQmz5gI%3D
------ link ------
verify?expires=1701263583&id=27&signature=eJHNOlI3HkWXhZOJfpJrTeIOEjdYR2pJuOtE8l0ktFQ%3D&token=UGMyl2OXc8HLSCU9qRZ9Pn3%2BRn1ovX%2BVXXq8AQmz5gI%3D
That seems a bit awkward to me.
Is there somehow a way to get the real exception message?
Cheers! 🍻
Hey @Rsteuber!
Hmm, that is mysterious! To get more info, I would:
A) First, triple-check that this line is the problem - https://github.com/SymfonyCasts/verify-email-bundle/blob/main/src/VerifyEmailHelper.php#L69 - e.g. put a
diein front of the exception. Better, add($signedUrl)to be sure.B) To debug further, inside of Symfony's core itself, find the
UriSignerclass and put some debugging inside thecheck()method - https://github.com/symfony/symfony/blob/7.1/src/Symfony/Component/HttpFoundation/UriSigner.php#L59-L75I'm hoping this will help figure out what difference the
UriSigneris seeing that's causing the difference. I'd compare, specifically, what the$uriand$this->secretlook like both when the URL is created and when it's checked in this method https://github.com/symfony/symfony/blob/7.1/src/Symfony/Component/HttpFoundation/UriSigner.php#L86-L89Let me know what you find out - it's gotta be something small...
Cheers!
Ok, I have found something weird...
The query parameters is giving me this:
"query" => "%3Fexpires=1701721532&id=30&signature=C37Z9HCzWS5GuCanWzEYexMuvrpAJkKE2yuHQs9Rllk%3D&token=P%2BzNwEnF%2FBhMD%2B24xuU9N9I9bqJuxbB3YuyxgEuz89c%3D"
But when I dd($params), it is showing me a weird token like it has been decoded:
array:3 [▼ "?expires" => "1701721532" "id" => "30" "token" => "P+zNwEnF/BhMD+24xuU9N9I9bqJuxbB3YuyxgEuz89c=" ]
The %2B = transformed into a "+" character. Could that be the problem to this case?
Cheers!
That would be enough to cause the signing to fail. Or it could be a false clue. If you, temporarily, manually hacked in code to make the + go back to
%2Band tried it (or, more generally, you couldurldecodethat part), and it works, then I think we might have something. Otherwise, this could just be a false alarm an we just happen to be comparing the urlencoded and urldecoded strings. But even if this is the cause, I'm not really sure what's going on yet...Ok, I am trying to figure it out today and think I see the problem now!
When i use the
$request->getUri()method, it seems that it adds some extra characters into the uri right before the "expires" query param. (%3F)So.... If I use the following code in my controller, it goes well:
Wondering if other people got this same problem?
Cheers!
Hey @Rsteuber!
Really glad you figured it out! And yea, this is strange! You said:
That
%3Fis the?character. thegetUri()method does add this character - https://github.com/symfony/symfony/blob/aa8e74b1c27890850b541c27ab7b05203cf06524/src/Symfony/Component/HttpFoundation/Request.php#L955 - but it shouldn't be adding an extra one. The$this->getQueryString()should return something likeexpires=...&foo=bar, and so adding the?should be proper to reconstruct the URL. It also wouldn't show as URL encoded as%3F. It feels like there is an extra%3Fin the URL when you come to the page... and so you're stripping effectively stripping that, but I also have a feeling I may be misreading things. If this were a bug, I'm sure we would have heard about it, but I'm not sure what you're doing differently.However, I do think there may be a problem. Notice
UriSignerhas acheckRequest()helper method - https://github.com/symfony/symfony/blob/7.1/src/Symfony/Component/HttpFoundation/UriSigner.php#L83 - and it gets the URL in a different way, purposely not usinggetUri(). That may or may not be describing your problem, but I'm thinking we should use that method in the bundle to url signing validation. https://github.com/SymfonyCasts/verify-email-bundle/issues/155Cheers!
Hello I have tried a slightly different approach. I actually have created a verification system which sends a random code via email and following your reccomendation in this chapter "If you wanted to be even cooler, you could manually authenticate the user in the same way that we did earlier in our registration controller. That's totally ok and up to you." I have tried to authenticate the user:
My security.yaml file:
After the redirection $this->getUser() returns null thus the session is not mantained, so seems that validation did not work or at least the session was not retained and I would need to send the verified user to login which is possible but not desired in my case. How can I find out what has happened during that redirection? What would be the best debugging strategy?
Hey Juan E.!
Hmm, that's a mystery! And you've asked the correct questions: how to debug / find out what happened during that redirection.
Here's what I like to do:
A) In
config/packages/dev/web_profiler.yaml, you should have aintercept_redirects: falseline. Change that tointercept_redirects: true.B) Now, whenever Symfony redirects you, instead of redirecting, you will see a page that says something like "About to redirect you to http://...". But it hasn't actually redirected you.
How is that useful? Because now you can do this:
1) Click the link, which will hit this URL, submit the form and (should) authenticate the user (make sure you put your redirectToRoute() back)
2) But, instead of redirecting, it will stop and show you the message. The useful thing is that, on this screen, you will see the web debug toolbar for this page. The question is: do you see yourself as authenticated in the web debug toolbar?
The answer to this question will tell us where to look next. If you ARE authenticated (but then when you click the link to "follow the redirect" suddenly you are not authenticated), then you are "losing" authentication, probably because some data on your User object is being seen as "changed" (if you have this situation, I can tell you more - it is common if you, for some reason, implement a custom \Serializable interface on your User class, which you probably shouldn't have).
Let me know what you find out.
Cheers!
Hi I just managed to make it work using the rememberMe Handler Interface and adding this line before the redirect:
$rememberMe->createRememberMeCookie($this->getUser());Hi, as I said in the previous reply I still get "kicked out" this is my User.php entity, I wonder if the libPhoneNumber object could be the problem.
`<?php
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Adamski\Symfony\PhoneNumberBundle\Model\PhoneNumber;
use Adamski\Symfony\PhoneNumberBundle\Validator\Constraints\PhoneNumber as AssertPhoneNumber;
/**
*/
class User implements UserInterface
{
}`
Hi thank you, I have proceeded following your indications and so far I find my self in the place we expected. By preventing direct redirection I got to that "intermediate" page and the user was still logged in, but after redirection it disappeared. Since the project I am managing "inherits" many elements from a former project I will clean up the data model of the user class and then test again, if I get no progress I may send here the User.php entity class with the hope you may find something I couldn't, I will try tomorrow and whatever the outcome I will post it here. Thanks so much.
Hey Juan!
I see that you got this working, I just wanted to apologize for not replying - I'm not sure how I lost this message! Overall, your solution is probably fine. If you'd like to learn more about the underlying issue and solution, you can read about it on this thread: https://symfonycasts.com/sc...
Cheers!
Hi,
How to translate $e->getReason() ? I don't see any domain in debug bar.
Thank you.
Hey Ruslan,
I think you need to translate the exact text if the error is from core Symfony. If the error message is your custom - you can use a translation message key, translate it in translation files, and use the translator in that spot to translate.
I hope this helps!
Cheers!
Hi Victor,
In this tutorial, we have changed Error messages for Login form (lesson 10) in translations\security.en.yaml. As I understand "security" is domain and I can see it in debug bar "Translation".
$e->getReason() is from VerifyEmailExceptionInterface. - but here I miss understand how to translate (I mean translation file name)
Thank you.
Looks like I understand my fault.
in this code I need to pass $error
-----
} catch (VerifyEmailExceptionInterface $e){
$this->addFlash('error', $e->getReason());
return $this->redirectToRoute('app_register');
}
-----
and in twig use the same construction like in login.html.twig.
<div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
Am I right?
Hey Ruslan,
I'm not sure you can pass the error object, IIRC you can only pass string to the addFlash() method. So, either you need to translate the message in PHP using trans() method before pass the string to the addFlash(), or you can pass the untranslated string to the addFlash() and translate it later in the template, i.e. "<div class="alert alert-success">{{ flash|trans }}</div>" as you mentioned, but yeah, if the message requires some messageData - it you do need to pass it into the template somehow, so probably better to translate in PHP where you have access to the object.
But if it's possible to pass the object - yeah, you can try your solution as well then. But you would need one more check in the template - you would need to know if you passed a string or an object and so render it a bit differently.
Cheers!
"Houston: no signs of life"
Start the conversation!