✨ feat(2fa): Implémente l'authentification à deux facteurs pour Artemis.
This commit is contained in:
36
migrations/Version20250721091215.php
Normal file
36
migrations/Version20250721091215.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ namespace App\Entity;
|
|||||||
|
|
||||||
use App\Repository\AccountRepository;
|
use App\Repository\AccountRepository;
|
||||||
use App\VichUploader\DirectoryNamer\Account\AvatarName;
|
use App\VichUploader\DirectoryNamer\Account\AvatarName;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||||
@@ -62,8 +64,15 @@ class Account implements UserInterface, PasswordAuthenticatedUserInterface, \Ser
|
|||||||
#[ORM\Column(nullable: true)]
|
#[ORM\Column(nullable: true)]
|
||||||
private ?\DateTimeImmutable $updateAt;
|
private ?\DateTimeImmutable $updateAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Collection<int, AccountLoginRegister>
|
||||||
|
*/
|
||||||
|
#[ORM\OneToMany(targetEntity: AccountLoginRegister::class, mappedBy: 'account')]
|
||||||
|
private Collection $accountLoginRegisters;
|
||||||
|
|
||||||
public function __construct(private readonly AvatarName $avatarName)
|
public function __construct(private readonly AvatarName $avatarName)
|
||||||
{
|
{
|
||||||
|
$this->accountLoginRegisters = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
@@ -277,4 +286,34 @@ class Account implements UserInterface, PasswordAuthenticatedUserInterface, \Ser
|
|||||||
$this->avatarFileName,
|
$this->avatarFileName,
|
||||||
) = unserialize($data);
|
) = 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
80
src/Entity/AccountLoginRegister.php
Normal file
80
src/Entity/AccountLoginRegister.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ namespace App\EventListener;
|
|||||||
|
|
||||||
use App\Attribute\Mainframe;
|
use App\Attribute\Mainframe;
|
||||||
use App\Entity\Account;
|
use App\Entity\Account;
|
||||||
|
use App\Entity\AccountLoginRegister;
|
||||||
|
use App\Service\Mailer\Mailer;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
@@ -30,7 +32,7 @@ use Twig\Environment;
|
|||||||
#[AsEventListener(event: KernelEvents::REQUEST)] // Listen to the Response event as well
|
#[AsEventListener(event: KernelEvents::REQUEST)] // Listen to the Response event as well
|
||||||
class MainframeAttributeListener
|
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
|
// Logger removed from constructor
|
||||||
}
|
}
|
||||||
@@ -39,7 +41,7 @@ class MainframeAttributeListener
|
|||||||
{
|
{
|
||||||
$request = $event->getRequest();
|
$request = $event->getRequest();
|
||||||
$pathInfo = $request->getPathInfo();
|
$pathInfo = $request->getPathInfo();
|
||||||
if(str_starts_with("/artemis",$pathInfo)) {
|
if(str_contains($pathInfo,"/artemis")) {
|
||||||
if($this->tokenStorage->getToken() instanceof UsernamePasswordToken) {
|
if($this->tokenStorage->getToken() instanceof UsernamePasswordToken) {
|
||||||
$account = $this->tokenStorage->getToken()->getUser();
|
$account = $this->tokenStorage->getToken()->getUser();
|
||||||
if($account instanceof Account) {
|
if($account instanceof Account) {
|
||||||
@@ -69,11 +71,51 @@ class MainframeAttributeListener
|
|||||||
}
|
}
|
||||||
$event->setResponse($response);
|
$event->setResponse($response);
|
||||||
$event->stopPropagation();
|
$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
|
public function onKernelController(ControllerEvent $event): void
|
||||||
{
|
{
|
||||||
|
|||||||
43
src/Repository/AccountLoginRegisterRepository.php
Normal file
43
src/Repository/AccountLoginRegisterRepository.php
Normal 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
35
templates/admin/2fa.twig
Normal 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 %}
|
||||||
21
templates/mails/artemis/2fa.twig
Normal file
21
templates/mails/artemis/2fa.twig
Normal 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 %}
|
||||||
Reference in New Issue
Block a user