feat(2fa): Implémente l'authentification à deux facteurs pour Artemis.

This commit is contained in:
Serreau Jovann
2025-07-21 11:16:05 +02:00
parent 98dbe1a9de
commit 8f96e1c2fb
7 changed files with 299 additions and 3 deletions

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250721091215 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE account_login_register (id SERIAL NOT NULL, account_id INT DEFAULT NULL, login_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_agent VARCHAR(255) NOT NULL, ip VARCHAR(255) NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_23AAA4819B6B5FBA ON account_login_register (account_id)');
$this->addSql('COMMENT ON COLUMN account_login_register.login_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE account_login_register ADD CONSTRAINT FK_23AAA4819B6B5FBA FOREIGN KEY (account_id) REFERENCES "account" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE account_login_register DROP CONSTRAINT FK_23AAA4819B6B5FBA');
$this->addSql('DROP TABLE account_login_register');
}
}

View File

@@ -4,6 +4,8 @@ namespace App\Entity;
use App\Repository\AccountRepository;
use App\VichUploader\DirectoryNamer\Account\AvatarName;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
@@ -62,8 +64,15 @@ class Account implements UserInterface, PasswordAuthenticatedUserInterface, \Ser
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updateAt;
/**
* @var Collection<int, AccountLoginRegister>
*/
#[ORM\OneToMany(targetEntity: AccountLoginRegister::class, mappedBy: 'account')]
private Collection $accountLoginRegisters;
public function __construct(private readonly AvatarName $avatarName)
{
$this->accountLoginRegisters = new ArrayCollection();
}
public function getId(): ?int
@@ -277,4 +286,34 @@ class Account implements UserInterface, PasswordAuthenticatedUserInterface, \Ser
$this->avatarFileName,
) = unserialize($data);
}
/**
* @return Collection<int, AccountLoginRegister>
*/
public function getAccountLoginRegisters(): Collection
{
return $this->accountLoginRegisters;
}
public function addAccountLoginRegister(AccountLoginRegister $accountLoginRegister): static
{
if (!$this->accountLoginRegisters->contains($accountLoginRegister)) {
$this->accountLoginRegisters->add($accountLoginRegister);
$accountLoginRegister->setAccount($this);
}
return $this;
}
public function removeAccountLoginRegister(AccountLoginRegister $accountLoginRegister): static
{
if ($this->accountLoginRegisters->removeElement($accountLoginRegister)) {
// set the owning side to null (unless already changed)
if ($accountLoginRegister->getAccount() === $this) {
$accountLoginRegister->setAccount(null);
}
}
return $this;
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Entity;
use App\Repository\AccountLoginRegisterRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AccountLoginRegisterRepository::class)]
class AccountLoginRegister
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'accountLoginRegisters')]
private ?Account $account = null;
#[ORM\Column]
private ?\DateTimeImmutable $loginAt = null;
#[ORM\Column(length: 255)]
private ?string $userAgent = null;
#[ORM\Column(length: 255)]
private ?string $ip = null;
public function getId(): ?int
{
return $this->id;
}
public function getAccount(): ?Account
{
return $this->account;
}
public function setAccount(?Account $account): static
{
$this->account = $account;
return $this;
}
public function getLoginAt(): ?\DateTimeImmutable
{
return $this->loginAt;
}
public function setLoginAt(\DateTimeImmutable $loginAt): static
{
$this->loginAt = $loginAt;
return $this;
}
public function getUserAgent(): ?string
{
return $this->userAgent;
}
public function setUserAgent(string $userAgent): static
{
$this->userAgent = $userAgent;
return $this;
}
public function getIp(): ?string
{
return $this->ip;
}
public function setIp(string $ip): static
{
$this->ip = $ip;
return $this;
}
}

View File

@@ -4,6 +4,8 @@ namespace App\EventListener;
use App\Attribute\Mainframe;
use App\Entity\Account;
use App\Entity\AccountLoginRegister;
use App\Service\Mailer\Mailer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Form\FormBuilderInterface;
@@ -30,7 +32,7 @@ use Twig\Environment;
#[AsEventListener(event: KernelEvents::REQUEST)] // Listen to the Response event as well
class MainframeAttributeListener
{
public function __construct(private readonly Environment $environment,private readonly TokenStorageInterface $tokenStorage,private readonly UserPasswordHasherInterface $userPasswordHasher,private readonly ?EntityManagerInterface $entityManager)
public function __construct(private readonly Mailer $mailer,private readonly Environment $environment,private readonly TokenStorageInterface $tokenStorage,private readonly UserPasswordHasherInterface $userPasswordHasher,private readonly ?EntityManagerInterface $entityManager)
{
// Logger removed from constructor
}
@@ -39,7 +41,7 @@ class MainframeAttributeListener
{
$request = $event->getRequest();
$pathInfo = $request->getPathInfo();
if(str_starts_with("/artemis",$pathInfo)) {
if(str_contains($pathInfo,"/artemis")) {
if($this->tokenStorage->getToken() instanceof UsernamePasswordToken) {
$account = $this->tokenStorage->getToken()->getUser();
if($account instanceof Account) {
@@ -69,11 +71,51 @@ class MainframeAttributeListener
}
$event->setResponse($response);
$event->stopPropagation();
} else {
$session = $request->getSession();
if(!$session->has('2fa_valid')) {
if(!$session->has('2fa_code')) {
$code = random_int(1000, 9999);
$session->set('2fa_code', random_int(1000, 9999));
$this->mailer->send($account->getEmail(),$account->getUsername(),"[Mainframe] - Double authentication","mails/artemis/2fa.twig",[
'code' => $code,
'account' => $account,
]);
}
if($request->isMethod('POST')) {
$code = $request->request->get('code');
if((int)$code == $session->get('2fa_code')) {
$login = new AccountLoginRegister();
$login->setAccount($account);
$login->setLoginAt(new \DateTimeImmutable());
$login->setIp($request->getClientIp());
$login->setUserAgent($request->headers->get('user-agent'));
$this->entityManager->persist($login);
$this->entityManager->flush();
$session->remove('2fa_code');
$session->set('2fa_valid',true);
$redirect = new RedirectResponse("/artemis");
$redirect->setStatusCode(302);
$event->setResponse($redirect);
$event->stopPropagation();
} else {
$response = new Response($this->environment->render('admin/2fa.twig', [
'account' => $account,
'error' => 'Code non valide !'
]));
}
} else {
$response = new Response($this->environment->render('admin/2fa.twig', [
'account' => $account,
]));
}
$event->setResponse($response);
$event->stopPropagation();
}
}
}
}
}
}
public function onKernelController(ControllerEvent $event): void
{

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\AccountLoginRegister;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AccountLoginRegister>
*/
class AccountLoginRegisterRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AccountLoginRegister::class);
}
// /**
// * @return AccountLoginRegister[] Returns an array of AccountLoginRegister objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('a')
// ->andWhere('a.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('a.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?AccountLoginRegister
// {
// return $this->createQueryBuilder('a')
// ->andWhere('a.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

35
templates/admin/2fa.twig Normal file
View File

@@ -0,0 +1,35 @@
{% extends 'admin/base.twig' %}
{% block title %}Mot de passe perdu{% endblock %}
{% block content %}
<div class="max-w-md w-full space-y-8 p-10 bg-gray-800 rounded-xl shadow-xl z-10">
<div class="text-center">
<img class="mx-auto h-12 w-auto" src="{{ asset('assets/logo.png') | imagine_filter('webp') }}" alt="Logo Mainframe">
<h2 class="mt-6 text-3xl font-extrabold text-white">
Compte: {{ account.username }}
</h2>
<h2 class="mt-6 text-2xl font-extrabold text-white">
Double authentificatation
</h2>
</div>
{% if error is defined %}
<div class="w-full px-4 py-3 rounded-md text-sm font-medium text-red-100 bg-red-600">
{{ error }}
</div>
{% endif %}
<form data-turbo="false" method="POST">
<div class="mb-5">
<label for="code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Code de double authentication</label>
<input type="text" name="code" id="code" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required />
</div>
<button
type="submit"
class="w-full px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 dark:bg-indigo-500 dark:hover:bg-indigo-600"
>
Valider
</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,21 @@
{% extends 'mails/base.twig' %}
{% block content %}
<mj-text>Bonjour {{ datas.account.username }},</mj-text>
<mj-text>Nous avons reçu une demande de connexion à votre compte Mainframe.</mj-text>
<mj-text>Votre code de vérification à deux facteurs (2FA) est :</mj-text>
<mj-text font-size="24px" font-weight="bold" align="center" padding-top="20px" padding-bottom="20px">
{{ datas.code }}
</mj-text>
<mj-text>Veuillez entrer ce code sur la page de connexion pour continuer. Ce code est valable pour une durée limitée.</mj-text>
<mj-text>Si vous n'avez pas demandé cette connexion, veuillez ignorer cet e-mail ou contacter le support si vous pensez qu'il s'agit d'une activité suspecte.</mj-text>
<mj-text padding-top="20px">Cordialement,</mj-text>
<mj-text>L'équipe Mainframe</mj-text>
{% endblock %}