Login Link (password less) with Symfony
Tags: php, symfony
Published: 2021-04-15 11:23:56

# 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](https://symfony.com/doc/current/security/login_link.html), "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
```yaml
security:
    enable_authenticator_manager: true
```
```yaml
firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            #anonymous: true
```
Now I can tell to Symfony I want to use the login link. In my firewall so I add it :

```yaml
login_link:
         check_route: app_login_check
         signature_properties: [id]
         lifetime: 300
```
* 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

```php
/**
     * @Route("/login-check", name="app_login_check")
     */
    public function check()
    {
        throw new \LogicException('This code should never be reached');
    }
```
Now I create the controller which will send the login link to the user

```php
/**
     * @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');
    }
```

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

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

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

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

            // send the notification to the user
            $notifier->send($notification, $recipient);
```
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 }}`

```php
return $this->render('security/login_link_sent.html.twig', [
                'user_email' => $user->getEmail()
            ]);
```
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
<?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;
    }
}
```

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 :
```twig
<p>Click on the link below to connect, it expires in {{ content }} </p>
<a href="{{ action_url }}">Connect</a>
```

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 :

```yaml
login_link:
                check_route: app_login_check
                signature_properties: [id]
                lifetime: 300
                failure_handler: App\Security\Authentication\AuthenticationFailureHandler
``` 
Next I create my failure's handler :

```php
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'));

    }
}
```
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](https://symfony.com/blog/new-in-symfony-5-2-login-throttling) for example but it will be for an other time...