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 %}
{# --- HEADER GÉNÉRAL --- #} - {# --- HEADER GÉNÉRAL : DESIGN ADMINISTRATEUR PRINCIPAL --- #} -
+
- {# Avatar stylisé #}
{{ admin.firstName|first|upper }}{{ admin.name|first|upper }}
- {# Badge Administrateur Principal #} {% if 'ROLE_CLIENT_MAIN' in admin.roles %}
- - Administrateur Principal - + + Administrateur Principal +
{% endif %} - -

+

{{ admin.firstName }} {{ admin.name }}

@@ -40,18 +36,17 @@
- {{ admin.email }} + {{ admin.email }}
-
+
System ID: #{{ admin.id }}
- {# --- MODULE DE STATUT INTÉGRÉ --- #} {% if not admin.actif %} -
+ -
- {# --- COLONNE GAUCHE : FORMULAIRE IDENTITÉ (SANS LE TOGGLE ACTIF) --- #} +
+ {# --- COLONNE GAUCHE : IDENTITÉ --- #}
{{ form_start(form) }}
@@ -101,9 +96,7 @@ {{ form_widget(form.name, { 'attr': {'class': 'w-full px-4 py-3 bg-slate-50 dark:bg-slate-900 border-none rounded-xl focus:ring-2 focus:ring-indigo-500 transition-all text-sm font-medium dark:text-white'} }) }}
-
-
{{ form_widget(form.username, { 'attr': {'class': 'w-full px-4 py-3 bg-slate-50 dark:bg-slate-900 border-none rounded-xl focus:ring-2 focus:ring-amber-500 transition-all text-sm font-mono dark:text-white'} }) }} @@ -112,7 +105,6 @@ {{ form_widget(form.email, { 'attr': {'class': 'w-full px-4 py-3 bg-slate-50 dark:bg-slate-900 border-none rounded-xl focus:ring-2 focus:ring-amber-500 transition-all text-sm dark:text-white'} }) }}
-
- {# --- COLONNE DROITE : DROITS & HISTORIQUE --- #} + {# --- COLONNE DROITE : SÉCURITÉ & DROITS --- #}
- {# --- CARTE SÉCURITÉ & MOT DE PASSE --- #} + {# CARTE SÉCURITÉ #}

@@ -142,22 +134,46 @@ {{ form_start(passwordForm) }}
- + {{ form_widget(passwordForm.password.first, { 'attr': {'class': 'w-full px-4 py-3 bg-slate-50 dark:bg-slate-900 border-none rounded-xl focus:ring-2 focus:ring-indigo-500 transition-all text-sm dark:text-white'} }) }}
- + {{ form_widget(passwordForm.password.second, { 'attr': {'class': 'w-full px-4 py-3 bg-slate-50 dark:bg-slate-900 border-none rounded-xl focus:ring-2 focus:ring-indigo-500 transition-all text-sm dark:text-white'} }) }}
-
{{ form_end(passwordForm) }}

- {# HABILITATIONS #} + + {# BLOC 2FA #} +
+
+
+ +
+
+

Authentification 2FA

+

+ {% if admin.googleAuthenticatorSecret %} + ● Activée — Accès hautement sécurisé + {% else %} + ○ Désactivée — Risque de sécurité + {% endif %} +

+
+
+ + {{ admin.googleAuthenticatorSecret ? 'Désactiver' : 'Envoyer le lien' }} + +
+ + {# HABILITATIONS OPÉRATIONNELLES #} {% if 'ROLE_CLIENT_MAIN' not in admin.roles %}
@@ -207,28 +223,27 @@

Habilitation Totale Active

-

Le rang Client Principal accorde nativement toutes les permissions système.

+

Le rang Client Principal accorde nativement toutes les permissions système.

{% endif %} - {# PRIVILÈGE DE STRUCTURE (ROOT) #} + {# PRIVILÈGE ROOT #} {% if is_granted('ROLE_ROOT') %}

- - - + + + Privilège de Structure

- -
+