DEV Community

Cover image for Login Link (password less) with Symfony
Mickaël
Mickaël

Posted on

Login Link (password less) with Symfony

New Security's component

Symfony 6 will get a new or maybe I should say an update of the Security Component. New feature will come with it and good news we can already use it 😊

Login Link

As says the doc, "the login link also called “magic link”, is a passwordless authentication mechanism. Whenever a user wants to login, a new link is generated and sent to them (e.g. using an email). The link fully authenticates the user in the application when clicking on it."

Let's start

I have a new symfony's project and I've already configurated the authentication. Now to use the login link feature, I have to enable the authenticator manager in my security.yaml and disable the anonymous in my firewall

security:
    enable_authenticator_manager: true
Enter fullscreen mode Exit fullscreen mode
firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            #anonymous: true
Enter fullscreen mode Exit fullscreen mode

Now I can tell to Symfony I want to use the login link. In my firewall so I add it :

login_link:
         check_route: app_login_check
         signature_properties: [id]
         lifetime: 300
Enter fullscreen mode Exit fullscreen mode
  • Check_route is the name of the route that Symfony need to generate the login link to authenticate the user.
  • Signature_properties are used to create a signed URL. This must contain at least one property of your User object that uniquely identifies this user
  • Lifetime is the link's lifetime in seconds. Here I said 5 minutes

Then in the SecurityController, I write a new function called "check" with the route "/login-check". This function can be empty

/**
     * @Route("/login-check", name="app_login_check")
     */
    public function check()
    {
        throw new \LogicException('This code should never be reached');
    }
Enter fullscreen mode Exit fullscreen mode

Now I create the controller which will send the login link to the user

/**
     * @Route("/forgotten-password", name="app_login_link")
     */
    public function requestLoginLink(
        NotifierInterface $notifier,
        LoginLinkHandlerInterface $loginLinkHandler,
        UserRepository $userRepository,
        Request $request
    )
    {
        if ($request->isMethod('POST')) {
            $email = $request->request->get('email');
            $user = $userRepository->findOneBy(['email' => $email]);

            if ($user === null){
                $this->addFlash('danger', 'This email does not exist ');
                return $this->redirectToRoute('app_login_link');;
            }

            $loginLinkDetails = $loginLinkHandler->createLoginLink($user);

            // create a notification based on the login link details
            $notification = new CustomLoginLinkNotification(
                $loginLinkDetails,
                'Link to Connect' // email subject
            );
            // create a recipient for this user
            $recipient = new Recipient($user->getEmail());

            // send the notification to the user
            $notifier->send($notification, $recipient);

            // render a "Login link is sent!" page
            return $this->render('security/login_link_sent.html.twig', [
                'user_email' => $user->getEmail()
            ]);
        }

        return $this->render('security/login_link_form.html.twig');
    }
Enter fullscreen mode Exit fullscreen mode

First I check if the method is "POST" then I get the email. If the user is not in my database then I redirect him in the same page with error message, here it's a flashbag

if ($user === null){
                $this->addFlash('danger', 'This mail does not exist ');
                return $this->redirectToRoute('app_login_link');;
            }
Enter fullscreen mode Exit fullscreen mode

I create the login link $loginLinkDetails = $loginLinkHandler->createLoginLink($user); and the notification. In my case I use a CustomLoginLinkNotification but if you don't want you can use the LoginLinkNotification.
Then I create the recipient for the user and I send the notification

// create a recipient for this user
            $recipient = new Recipient($user->getEmail());

            // send the notification to the user
            $notifier->send($notification, $recipient);
Enter fullscreen mode Exit fullscreen mode

When the link is send, a new template appear with a custom message. You can write what you want, here I wrote "The link is send to your email address : {{ user_email }}

return $this->render('security/login_link_sent.html.twig', [
                'user_email' => $user->getEmail()
            ]);
Enter fullscreen mode Exit fullscreen mode

For the email to be correctly send , I need to install :

  • Mailer
  • twig/cssinliner-extra
  • twig/inky-extra

I use a full project symfony and if you don't, you will need to instal notifier and twig

Now I create a new Notifier's folder in 'App' and I create my new class : CustomLoginLinkNotification

<?php


namespace App\Notifier;

use Symfony\Component\Notifier\Message\EmailMessage;
use Symfony\Component\Notifier\Recipient\EmailRecipientInterface;
use Symfony\Component\Security\Http\LoginLink\LoginLinkDetails;
use Symfony\Component\Security\Http\LoginLink\LoginLinkNotification;

class CustomLoginLinkNotification extends LoginLinkNotification
{
    private LoginLinkDetails $loginLinkDetails;

    public function __construct(LoginLinkDetails $loginLinkDetails, string $subject, array $channels = [])
    {
        parent::__construct($loginLinkDetails, $subject, $channels);

        $this->loginLinkDetails = $loginLinkDetails;
    }

    public function asEmailMessage(EmailRecipientInterface $recipient, string $transport = null): ?EmailMessage
    {
        $emailMessage = parent::asEmailMessage($recipient, $transport);

        // get the NotificationEmail object and override the template
        $email = $emailMessage->getMessage();
        $email->from('admin@example.com');
        $email->content($this->getLeftTime());
        $email->htmlTemplate('email/_custom_login_link_email.html.twig');

        return $emailMessage;
    }

    public function getLeftTime(): string
    {
        $duration = $this->loginLinkDetails->getExpiresAt()->getTimestamp() - time();
        $durationString = floor($duration / 60).' minute'.($duration > 60 ? 's' : '');
        if (($hours = $duration / 3600) >= 1) {
            $durationString = floor($hours).' hour'.($hours >= 2 ?'s' : '');
        }

        return $durationString;
    }
}
Enter fullscreen mode Exit fullscreen mode

Here I extends the LoginLinkNotification and I override the function asEmailMessage to send a custom mail. The function getLeftTime is the code from the doc to get the lifetime of the link and to be able to send it in the email. My email look like this :

<p>Click on the link below to connect, it expires in {{ content }} </p>
<a href="{{ action_url }}">Connect</a>
Enter fullscreen mode Exit fullscreen mode

When the link is send, the user can click on the button and he will be connect. In my case when an user is connect (after login) he is redirect to the page /{user} and it's the same thing when he clicks on the link. I like this so I will not change this behaviour but if you want to do something else like a new route or maybe persist some information in your database, you can create a success_handler. You could find how to do it in the doc.

If the link is expires and the user click on it, by default he will be redirect to "/login". I don't want that because my route to the login is '/'. I will create then a failure_handler.
First in the security.yaml I need to tell to Symfony I want to handle the failure myself :

login_link:
                check_route: app_login_check
                signature_properties: [id]
                lifetime: 300
                failure_handler: App\Security\Authentication\AuthenticationFailureHandler
Enter fullscreen mode Exit fullscreen mode

Next I create my failure's handler :

namespace App\Security\Authentication;

use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;

class AuthenticationFailureHandler implements AuthenticationFailureHandlerInterface
{
    private UrlGeneratorInterface $urlGenerator;
    private FlashBagInterface $flash;

    public function __construct(UrlGeneratorInterface $urlGenerator, FlashBagInterface $flash)
    {
        $this->urlGenerator = $urlGenerator;
        $this->flash = $flash;
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
    {
        $this->flash->add('danger','this link is expires 😮😮😮');
        return new RedirectResponse($this->urlGenerator->generate('app_login'));

    }
}
Enter fullscreen mode Exit fullscreen mode

The goal of this handler is to redirect the user to the route I want in case the link is expires with a flash message error.

That's it guys !

Of course I didn't show you everything, there are differents custom you can do. For me this feature is a better way for the users who has forgotten their password

There are new features to explore with the "new" Security component like the Login Throttling for example but it will be for an other time...

Top comments (1)

Collapse
 
stingmu profile image
Albert Matevosov

Thanks for nice article! How can I add "rememberme" functionality for login by link logic? Thanks!