diff --git a/composer.json b/composer.json
index 66e5bbb..c7e5aaa 100644
--- a/composer.json
+++ b/composer.json
@@ -17,7 +17,7 @@
"doctrine/doctrine-migrations-bundle": "^3.7.0",
"doctrine/orm": "^3.6.1",
"docusealco/docuseal-php": "^1.0.5",
- "endroid/qr-code": ">=6.0.9",
+ "endroid/qr-code": "^6.0",
"exbil/mailcow-php-api": ">=0.15.0",
"fpdf/fpdf": ">=1.86.1",
"google/apiclient": "^2.19.0",
@@ -43,7 +43,9 @@
"phpoffice/phpspreadsheet": "^5.4",
"phpstan/phpdoc-parser": "^2.3.1",
"presta/sitemap-bundle": "^4.2",
+ "scheb/2fa-backup-code": "^7.13",
"scheb/2fa-bundle": "^7.13.1",
+ "scheb/2fa-email": "^7.13",
"scheb/2fa-google-authenticator": "^7.13.1",
"sentry/sentry-symfony": "^5.8.3",
"setasign/fpdi": "^2.6.4",
diff --git a/composer.lock b/composer.lock
index 1cb9597..6830229 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "9c1f1c501a3764fad79d4fb69f99c1eb",
+ "content-hash": "6cc6334636ac6b9f69ebd4f4736d69f4",
"packages": [
{
"name": "async-aws/core",
@@ -8008,6 +8008,55 @@
],
"time": "2025-12-02T15:19:04+00:00"
},
+ {
+ "name": "scheb/2fa-backup-code",
+ "version": "v7.13.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/scheb/2fa-backup-code.git",
+ "reference": "35f1ace4be7be2c10158d2bb8284208499111db8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/scheb/2fa-backup-code/zipball/35f1ace4be7be2c10158d2bb8284208499111db8",
+ "reference": "35f1ace4be7be2c10158d2bb8284208499111db8",
+ "shasum": ""
+ },
+ "require": {
+ "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
+ "scheb/2fa-bundle": "self.version"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Scheb\\TwoFactorBundle\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Christian Scheb",
+ "email": "me@christianscheb.de"
+ }
+ ],
+ "description": "Extends scheb/2fa-bundle with backup codes support",
+ "homepage": "https://github.com/scheb/2fa",
+ "keywords": [
+ "2fa",
+ "Authentication",
+ "backup-codes",
+ "symfony",
+ "two-factor",
+ "two-step"
+ ],
+ "support": {
+ "source": "https://github.com/scheb/2fa-backup-code/tree/v7.13.1"
+ },
+ "time": "2025-11-20T13:35:24+00:00"
+ },
{
"name": "scheb/2fa-bundle",
"version": "v7.13.1",
@@ -8076,6 +8125,58 @@
},
"time": "2025-12-18T15:29:07+00:00"
},
+ {
+ "name": "scheb/2fa-email",
+ "version": "v7.13.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/scheb/2fa-email.git",
+ "reference": "ee70a062dde6aa9d566f99d2b1ae40b544ddbcef"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/scheb/2fa-email/zipball/ee70a062dde6aa9d566f99d2b1ae40b544ddbcef",
+ "reference": "ee70a062dde6aa9d566f99d2b1ae40b544ddbcef",
+ "shasum": ""
+ },
+ "require": {
+ "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
+ "scheb/2fa-bundle": "self.version"
+ },
+ "suggest": {
+ "symfony/mailer": "Needed if you want to use the default mailer implementation"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Scheb\\TwoFactorBundle\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Christian Scheb",
+ "email": "me@christianscheb.de"
+ }
+ ],
+ "description": "Extends scheb/2fa-bundle with two-factor authentication via email",
+ "homepage": "https://github.com/scheb/2fa",
+ "keywords": [
+ "2fa",
+ "Authentication",
+ "email",
+ "symfony",
+ "two-factor",
+ "two-step"
+ ],
+ "support": {
+ "source": "https://github.com/scheb/2fa-email/tree/v7.13.1"
+ },
+ "time": "2025-11-20T13:35:24+00:00"
+ },
{
"name": "scheb/2fa-google-authenticator",
"version": "v7.13.1",
diff --git a/config/packages/scheb_2fa.yaml b/config/packages/scheb_2fa.yaml
index 8a33ebb..194a218 100644
--- a/config/packages/scheb_2fa.yaml
+++ b/config/packages/scheb_2fa.yaml
@@ -3,3 +3,13 @@ scheb_two_factor:
security_tokens:
- Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken
- Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken
+ backup_codes:
+ enabled: true
+ email:
+ enabled: true
+ google:
+ enabled: true
+ server_name: "Intranet Ludikevent"
+ issuer: "Ludikevent"
+ digits: 6
+ leeway: 0
diff --git a/config/packages/security.yaml b/config/packages/security.yaml
index 05d8a21..3677a0c 100644
--- a/config/packages/security.yaml
+++ b/config/packages/security.yaml
@@ -19,6 +19,7 @@ security:
two_factor:
auth_form_path: 2fa_login # Route d'affichage du formulaire
check_path: 2fa_login_check # Route de soumission du code
+ default_target_path: /
# -------------------------------------
form_login:
diff --git a/migrations/Version20260116085657.php b/migrations/Version20260116085657.php
new file mode 100644
index 0000000..62a9903
--- /dev/null
+++ b/migrations/Version20260116085657.php
@@ -0,0 +1,32 @@
+addSql('ALTER TABLE account ADD confirmation_token VARCHAR(255) DEFAULT NULL');
+ }
+
+ 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" DROP confirmation_token');
+ }
+}
diff --git a/src/Controller/Dashboard/AccountController.php b/src/Controller/Dashboard/AccountController.php
index fa6b1f7..982872e 100644
--- a/src/Controller/Dashboard/AccountController.php
+++ b/src/Controller/Dashboard/AccountController.php
@@ -20,6 +20,8 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+use Symfony\Component\Security\Csrf\TokenGenerator\TokenGeneratorInterface;
use Symfony\Component\Uid\Uuid;
class AccountController extends AbstractController
@@ -91,7 +93,8 @@ class AccountController extends AbstractController
AccountLoginRegisterRepository $accountLoginRegisterRepository,
EntityManagerInterface $em,
AuditLogRepository $auditLogRepository,
- UserPasswordHasherInterface $passwordHasher
+ UserPasswordHasherInterface $passwordHasher,
+ TokenGeneratorInterface $tokenGenerator
): Response {
if (!$account) {
$this->addFlash('error', 'Administrateur introuvable.');
@@ -106,15 +109,77 @@ class AccountController extends AbstractController
);
$this->sendSecurityEmail($mailer, $title, $details);
$appLogger->record('SECURITY_ALERT', $details);
- // $this->sendSecurityEmail($mailer, $title, $details); // Activez si la méthode existe
$this->addFlash('error', 'Sécurité : Le compte est protégé');
return $this->redirectToRoute('app_crm_administrateur');
}
+ if ($request->query->get('act') === 'disable2fa') {
+ if($account->isGoogleAuthenticatorEnabled()) {
+ $account->setGoogleAuthenticatorSecret(null);
+ $em->flush();
+
+ // Récupération de l'admin qui fait l'action
+ $currentUser = $this->getUser();
+
+ $mailer->send(
+ $account->getEmail(),
+ $account->getFirstName() . " " . $account->getName(),
+ "[Alerte Sécurité] Désactivation de votre double authentification",
+ "mails/account/2fa-disable.twig",
+ [
+ 'account' => $account,
+ 'who' => $currentUser // On passe l'objet admin connecté
+ ]
+ );
+
+ $logMessage = sprintf(
+ "2FA désactivée pour %s %s par %s %s (ID: %d)",
+ $account->getFirstName(), $account->getName(),
+ $currentUser->getFirstName(), $currentUser->getName(),
+ $account->getId()
+ );
+ $appLogger->record('2FA_DISABLED', $logMessage);
+
+ $this->addFlash('warning', "La protection 2FA de " . $account->getFirstName() . " a été retirée.");
+ return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
+ }
+ }
+
+ if ($request->query->get('act') === 'sendLink2faenable') {
+ $currentUser = $this->getUser();
+
+ $account->setConfirmationTokenName($tokenGenerator->generateToken());
+ $setupUrl = $this->generateUrl('app_2fa_setup_confirm', [
+ 'id' => $account->getId(),
+ 'token' => $account->getConfirmationToken()
+ ], UrlGeneratorInterface::ABSOLUTE_URL);
+ $em->persist($account);
+ $em->flush();
+
+ $mailer->send(
+ $account->getEmail(),
+ $account->getFirstName() . " " . $account->getName(),
+ "[Sécurité] Configuration de votre double authentification (2FA)",
+ "mails/account/2fa-invite.twig",
+ [
+ 'account' => $account,
+ 'setup_url' => $setupUrl,
+ 'who' => $currentUser, // On passe l'objet admin connecté
+ 'expires_at' => (new \DateTime('+1 hour'))->format('H:i')
+ ]
+ );
+
+ $logMessage = sprintf(
+ "Invitation 2FA envoyée à %s %s par %s %s",
+ $account->getFirstName(), $account->getName(),
+ $currentUser->getFirstName(), $currentUser->getName()
+ );
+ $appLogger->record('2FA_INVITE', $logMessage);
+
+ $this->addFlash('success', "Lien envoyé à " . $account->getFirstName());
+ return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
+ }
- // --- 1. LOGIQUE DE MISE À JOUR DU STATUT (Lien Direct) ---
- // --- LOGIQUE DE MISE À JOUR DU STATUT (Lien Direct) ---
-// --- LOGIQUE DE MISE À JOUR DU STATUT (Lien Direct) ---
if ($request->query->get('act') === 'updateStatut') {
$statusParam = $request->query->get('status');
$newStatus = ($statusParam === 'true');
diff --git a/src/Controller/TwoController.php b/src/Controller/TwoController.php
new file mode 100644
index 0000000..0595ca9
--- /dev/null
+++ b/src/Controller/TwoController.php
@@ -0,0 +1,85 @@
+ false], methods: ['GET', 'POST'])]
+ public function index(
+ ?Account $account,
+ string $token,
+ GoogleAuthenticatorInterface $googleAuthenticator,
+ EntityManagerInterface $em,
+ Request $request,
+ AppLogger $appLogger
+ ): Response {
+
+ if (!$account || $account->getConfirmationToken() !== $token) {
+ return $this->render('security/error_2fa.twig', ['message' => 'Lien invalide.']);
+ }
+
+ // Génération du secret si nécessaire
+ if (!$account->getGoogleAuthenticatorSecret()) {
+ $account->setGoogleAuthenticatorSecret($googleAuthenticator->generateSecret());
+ $em->flush();
+ }
+
+ // Logique de validation
+ if ($request->isMethod('POST')) {
+ $checkCode = $request->request->get('_check_code');
+ if ($googleAuthenticator->checkCode($account, $checkCode)) {
+ $account->setConfirmationTokenName(null);
+ $em->flush();
+ $appLogger->record('AUTH', "2FA activée pour " . $account->getEmail());
+ return $this->render('security/success_2fa.twig');
+ }
+ $this->addFlash('error', 'Code invalide.');
+ }
+
+// --- GÉNÉRATION DU QR CODE VIA ENDROID ---
+ $qrContent = $googleAuthenticator->getQRContent($account);
+
+// Chemin vers ton logo (ajuste le chemin si nécessaire)
+ $logoPath = $this->getParameter('kernel.project_dir') . '/public/provider/images/logo.png';
+
+ $builder = new Builder(
+ writer: new PngWriter(),
+ writerOptions: [],
+ validateResult: false,
+ data: $qrContent,
+ encoding: new Encoding('UTF-8'),
+ errorCorrectionLevel: ErrorCorrectionLevel::High,
+ size: 250,
+ margin: 10,
+ roundBlockSizeMode: RoundBlockSizeMode::Margin,
+ // Logo (Optionnel : vérifie si le fichier existe avant)
+ labelAlignment: LabelAlignment::Center,
+ // Label sous le QR Code
+ logoPath: file_exists($logoPath) ? $logoPath : null,
+ logoResizeToWidth: 50, // NotoSans est souvent inclus par défaut
+ logoPunchoutBackground: true
+ );
+
+ $result = $builder->build();
+
+ return $this->render('security/setup_2fa.twig', [
+ 'account' => $account,
+ 'qrCodeUrl' => $result->getDataUri() // Génère le Base64 pour le
+ ]);
+ }
+}
diff --git a/src/Entity/Account.php b/src/Entity/Account.php
index bb5817c..dee39d1 100644
--- a/src/Entity/Account.php
+++ b/src/Entity/Account.php
@@ -8,6 +8,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
+use Scheb\TwoFactorBundle\Model\Google\TwoFactorInterface;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
@@ -18,7 +19,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_USERNAME', fields: ['username'])]
#[UniqueEntity(fields: ['email'], message: 'Cette adresse e-mail est déjà utilisée.')]
#[UniqueEntity(fields: ['uuid'], message: 'Cet identifiant unique (UUID) est déjà utilisé.')]
-class Account implements UserInterface, PasswordAuthenticatedUserInterface
+class Account implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -74,6 +75,8 @@ class Account implements UserInterface, PasswordAuthenticatedUserInterface
*/
#[ORM\OneToMany(targetEntity: AuditLog::class, mappedBy: 'account')]
private Collection $auditLogs;
+ #[ORM\Column(type: 'string', nullable: true)]
+ private ?string $confirmationToken;
public function __construct()
{
@@ -350,4 +353,16 @@ class Account implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
+
+ public function getConfirmationToken(): ?string
+ {
+ return $this->confirmationToken;
+ }
+
+ public function setConfirmationTokenName(?string $token): static
+ {
+ $this->confirmationToken = $token;
+
+ return $this;
+ }
}
diff --git a/templates/dashboard/administrateur/view.twig b/templates/dashboard/administrateur/view.twig
index 44c5d19..b098e8f 100644
--- a/templates/dashboard/administrateur/view.twig
+++ b/templates/dashboard/administrateur/view.twig
@@ -12,27 +12,23 @@
{% block body %}
+ {% if admin.googleAuthenticatorSecret %} + ● Activée — Accès hautement sécurisé + {% else %} + ○ Désactivée — Risque de sécurité + {% endif %} +
+