Login attempt in Symfony under 5.2 version

mis0u

Mickaël

Posted on January 10, 2021

Login attempt in Symfony under 5.2 version

Symfony 5.2 has feature which allow us to securise our website against multiple login's attempt.
Here is the docs => Login Throttling
But how to do it with an older version ? This is what I'm going to show you next.

The project

For this project, let's say we just created a blog. This blog is only accessible for registered user. An anonymous user will only have access to '/login' and '/register'.
In the register page, to avoid the bad bots, we use a captcha but one captcha is enough, we don't need it for the login page.

I will go right in the hearth of the project so I let you :

  1. Create the project
  2. Do a make:user and make:auth
  3. Write your logic in your controller

And now let's work.

Create a new Entity

So we want to know each connection (successfull or not) to our website. For each connection we want to know the ip address, the mail used and the date.

Lets create an entity. Call it loginAttempt and answer to the question :

New property name (press <return> to stop adding fields):
 > email

 Field type (enter ? to see all types) [string]:
 >


 Field length [255]:
 >

 Can this field be null in the database (nullable) (yes/no) [no]:
 >

 updated: src/Entity/LoginAttempt.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > ipAddress

 Field type (enter ? to see all types) [string]:
 >


 Field length [255]:
 >

 Can this field be null in the database (nullable) (yes/no) [no]:
 >

 updated: src/Entity/LoginAttempt.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > date

 Field type (enter ? to see all types) [string]:
 > datetime_immutable
datetime_immutable

 Can this field be null in the database (nullable) (yes/no) [no]:
 >
Enter fullscreen mode Exit fullscreen mode

Right, now lets modify the Entity. If you want, you can typehint the variable, I dit it. Delete all the setters, we don't need them and create a constructor. For the constructor we use a string $email and a string $ipAddress, the date will be a new DatetimeImmutable('now'). I personnally added date_default_timezone_set('Europe/Paris') to get the time for France. Your entity should look like this :

class LoginAttempt
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private ?int $id = null;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private string $email;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private string $ipAddress;

    /**
     * @ORM\Column(type="datetime_immutable")
     */
    private \DateTimeImmutable $date;

    public function __construct(string $ipAddress, string $email)
    {
        date_default_timezone_set('Europe/Paris');
        $this->ipAddress = $ipAddress;
        $this->email = $email;
        $this->date = new \DateTimeImmutable('now');
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getEmail(): ?string
    {
        return $this->email;
    }

    public function getIpAddress(): ?string
    {
        return $this->ipAddress;
    }

    public function getDate(): ?\DateTimeImmutable
    {
        return $this->date;
    }

}
Enter fullscreen mode Exit fullscreen mode

The LoginformAuthenticator

If you did a make auth, you created the logic for your login, I called this class LoginformAuthenticator, yours has maybe a different name but whatever, go in it.
First just before the constructor add 2 new private variable and call it $ipAddress and EntityManagerInterface $manager and add the manager in your contructor.I will explain you for $ipAddress later.

In this class, 2 methods interess us :

  • public function getCredentials(Request $request)
  • public function checkCredentials($credentials, UserInterface $user)

The getCredentials

This method get all the connections. You can try to log and die and dump $credentials, you will access to the mail used, the password not hashed and the csrfToken. Perfect ! This is exactly the logic we want.
So in this method just before the return, add a $newLoginAttempt's variable equal to a new LoginAttempt (our entity). Remember we need in parameter the ip adress and the email. Right, look in your method, we have access to the Request ! So the first parameter is $request->getClientIp() and the second is the credentials. As an array, we need to write $credentials['email].
I want a global variable for ip address so use your private variable $this->ipaddress = $request->getClientIp(), it will be usefull for later.
Now we just need to persist and flush the data. Try to log in and watch your database (login_attempt), you should see the first connection.
Perfect !

Your getCredentials method should look like this :

 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']
        );

        $newLoginAttempt = new LoginAttempt($request->getClientIp(), $credentials['email']);

        $this->ipAddress = $request->getClientIp();

        $this->entityManager->persist($newLoginAttempt);
        $this->entityManager->flush();

        return $credentials;
    }
Enter fullscreen mode Exit fullscreen mode

Create a Trait

The logic in our checkCredentials method is a bit too long so I prefer to create a Trait. Call it SecurityLoginAttemptTrait and use it directly in your LoginFormAuthenticator's class just above use TargetPathTrait; to not forget it !.
Back in our Trait, start to create a secure's method with as parameter :

  • a LoginAttemptRepository $repo
  • a string $email
  • a string $ipAddress
  • an EntityManagerInterface $manager
  • an UrlGeneratorInterface $url

Then we want to prevent the user from 2 attempts with an error message. The 4th try will be the last and the 5th try will block his Ip address. Start by writting the condition :

if(count($repo->recentLoginAttempts($email)) === 2){
            throw new CustomUserMessageAuthenticationException('3 attempts left');
        }
        if(count($repo->recentLoginAttempts($email)) === 3){
            throw new CustomUserMessageAuthenticationException('2 attempts left');
        }
        if(count($repo->recentLoginAttempts($email)) === 4){
            throw new CustomUserMessageAuthenticationException('Be carefull its your last attempt');
        }
Enter fullscreen mode Exit fullscreen mode

Wow wait, what is the recentLoginAttempts method, the CustomUserMessageAuthenticationException and where is the last attempt ?
Ok hold on, step by step.

The CustomUserMessageAuthenticationException as his name says it is a custom message error for the user. Don't forget to use the 'use statement'

Now let's go in our LoginAttemptRepository and create our recentLoginAttempts method.

The method

We want to know all the connection for an email address from minus 3 minutes to now, so let's write it :

class LoginAttemptRepository extends ServiceEntityRepository
{
    const DELAY_IN_MINUTES = 3;

    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, LoginAttempt::class);
    }

    public function recentLoginAttempts(string $email)
    {
        date_default_timezone_set('Europe/Paris');
        $timeAgo = new \DateTimeImmutable(sprintf('-%d minutes', self::DELAY_IN_MINUTES));

        return $this->createQueryBuilder('l')
            ->andWhere('l.date >= :date')
            ->andWhere('l.email = :email')
            ->setParameter('date', $timeAgo)
            ->setParameter('email', $email)
            ->getQuery()
            ->getResult()
        ;
    }
Enter fullscreen mode Exit fullscreen mode

Now you should understand ours conditions. For example :

if(count($this->repo->recentLoginAttempts($email)) === 3){
            throw new CustomUserMessageAuthenticationException('2 attempts left');
        }
Enter fullscreen mode Exit fullscreen mode

I will count all the times an user try to connect to our website between minus 3 minutes to now. If the total is equal to 3 then he will get a new custom message.
Let's write the last condition but before to do it, we need to create a new Entity.

The last Entity

If an user try to connect 5 times in 3 minutes or less then his ip address will be block in our site. So create an entity IpBlocked with 2 properties :

  1. ipAddress (string)
  2. blockedAt (datetime_immutable)

Delete all the setters and make a constructor with a string $ipAddress and make automatic the property's blockedAt.

Your entity should look like this :

<?php

namespace App\Entity;

use App\Repository\IpBlockedRepository;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass=IpBlockedRepository::class)
 */
class IpBlocked
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private ?int $id = null;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private string $ipAddress;

    /**
     * @ORM\Column(type="datetime_immutable")
     */
    private \DateTimeImmutable $blockedAt;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getIpAddress(): ?string
    {
        return $this->ipAddress;
    }

    public function __construct(string $ipAddress)
    {
        date_default_timezone_set('Europe/Paris');
        $this->ipAddress = $ipAddress;
        $this->blockedAt = new \DateTimeImmutable('now');
    }

    public function getBlockedAt(): ?\DateTimeImmutable
    {
        return $this->blockedAt;
    }

}

Enter fullscreen mode Exit fullscreen mode

Back in our trait we can now write the last condition :

if(count($repo->recentLoginAttempts($email)) === 5){
            $blocked = new IpBlocked($ipAddress);
            $manager->persist($blocked);
            $manager->flush();
            return $url->generate('app_account_blocked');
        }
Enter fullscreen mode Exit fullscreen mode

So if an user try to connect for the 5th times then we call a new Ipblocked with $ipAddress in parameter, then we persist and flush $blocked and we return a redirection ( we are going to create it).

Your Trait should look like this :

<?php


namespace App\helper;


use App\Entity\IpBlocked;
use App\Repository\LoginAttemptRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;

trait SecurityLoginAttemptTrait
{

    public function secure(LoginAttemptRepository $repo,string $email, string $ipAddress, EntityManagerInterface $manager, UrlGeneratorInterface $url)
    {
        if(count($repo->recentLoginAttempts($email)) === 2){
            throw new CustomUserMessageAuthenticationException('Il vous reste 3 essais');
        }
        if(count($repo->recentLoginAttempts($email)) === 3){
            throw new CustomUserMessageAuthenticationException('Il vous reste 2 essais');
        }
        if(count($repo->recentLoginAttempts($email)) === 4){
            throw new CustomUserMessageAuthenticationException('Attention c\'est votre dernier essai !');
        }
        if(count($repo->recentLoginAttempts($email)) === 5){
            $blocked = new IpBlocked($ipAddress);
            $manager->persist($blocked);
            $manager->flush();
            return $url->generate('app_account_blocked');
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

Back to the LoginFormAuthenticator

Before working in our checkCredentials method, create a new private LoginAttemptRepository $repo and add it in the constructor.
Now lets go in the method and before the return call our secure method. Remember we need in parameters :

  1. the LoginAttemptRepository
  2. the email
  3. the ip address
  4. the EntityManagerInterface
  5. the UrlGeneratorInterface

So we can write $this->secure($this->repo,$credentials['email'], $this->ipAddress, $this->entityManager, $this->urlGenerator);

And that's it, our logic is done. If an user try to connect 5 times in 3 minutes or under then his ip address will be in our database.

Finally

We want to block the user's access from our website. Remember an anonymous user can only access to '/login' and '/register'. Then go in your login and register's controller and in paramaters add:
IpBlockedRepository $ipBlockedRepo
Request $request
then as first line write your condition :

if ($ipBlockedRepo->findOneBy(['ipAddress' => $request->getClientIp()])){
            return $this->redirectToRoute('app_account_blocked');
        }
Enter fullscreen mode Exit fullscreen mode

With this logic, the user will not be able to connect or register. Of course you need to create the new route 'app_account_blocked' but that's the easiest thing to do.
First start by creating a new Controller by making symfony console make:controller AccountBlockedController
Then we don't want people can access to this page if their IP is not blocked, so add the same parameters than RegisterController and LoginController and this time in first line write :
if (!$ipBlockedRepo->findOneBy(['ipAddress' => $request->getClientIp()])){
return $this->redirectToRoute('app_login');
}

Your controller should look like this :

<?php

namespace App\Controller;

use App\Repository\IpBlockedRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class AccountBlockedController extends AbstractController
{
    /**
     * @Route("/access-blocked", name="app_account_blocked")
     */
    public function index(IpBlockedRepository $ipBlockedRepo, Request $request): Response
    {
        if (!$ipBlockedRepo->findOneBy(['ipAddress' => $request->getClientIp()])){
            return $this->redirectToRoute('app_login');
        }
        return $this->render('account_blocked/index.html.twig', [
            'controller_name' => 'AccountBlockedController',
        ]);
    }
}

Enter fullscreen mode Exit fullscreen mode

And don't forget to personalize your template and let the user know why his access is forbidden.
For example you could say :" You don't have access to this website, please contact the admin to find a solution "
And you can of course let all the user blocked have access to your contact page.

I hope you liked this article and if you have any question or any improvement, just let me know

Cheers !

💖 💪 🙅 🚩
mis0u
Mickaël

Posted on January 10, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related