From 1b6f0bcde4005eb9cb72031743a4e97d7986c265 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Thu, 15 Jan 2026 22:07:01 +0100 Subject: [PATCH] =?UTF-8?q?```=20=E2=9C=A8=20feat(crm/admin):=20Am=C3=A9li?= =?UTF-8?q?ore=20gestion=20des=20administrateurs=20et=20s=C3=A9curit=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute formulaires identité et mot de passe, rôles dynamiques. Gère statuts, journal d'audit, connexions. Améliore les notifications. ``` --- assets/app.js | 1 - config/packages/security.yaml | 4 +- migrations/Version20260115194430.php | 32 ++ .../Dashboard/AccountController.php | 268 +++++++++-- src/Controller/Dashboard/LogsController.php | 89 +++- src/Entity/AuditLog.php | 19 + src/Form/AccountPasswordType.php | 56 +++ src/Logger/AuditLogSecurityListener.php | 23 +- templates/dashboard/administrateur.twig | 194 +++++--- templates/dashboard/administrateur/view.twig | 433 ++++++++++++++++++ templates/dashboard/audit_logs.twig | 140 +++--- templates/mails/account/password-modify.twig | 48 ++ templates/mails/account/status-update.twig | 44 ++ .../mails/notification/admin_created.twig | 90 ++-- .../notification/security_violation.twig | 42 ++ 15 files changed, 1243 insertions(+), 240 deletions(-) create mode 100644 migrations/Version20260115194430.php create mode 100644 src/Form/AccountPasswordType.php create mode 100644 templates/dashboard/administrateur/view.twig create mode 100644 templates/mails/account/password-modify.twig create mode 100644 templates/mails/account/status-update.twig create mode 100644 templates/mails/notification/security_violation.twig diff --git a/assets/app.js b/assets/app.js index 2e86eec..a261fc3 100644 --- a/assets/app.js +++ b/assets/app.js @@ -1,7 +1,6 @@ import './app.scss' import * as Turbo from "@hotwired/turbo" -import * as Turbo from "@hotwired/turbo"; // --- INITIALISATION SENTRY (En premier !) --- Sentry.init({ diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 9fe36dc..05d8a21 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -41,7 +41,9 @@ security: App\Entity\Account: 'auto' role_hierarchy: - ROLE_ROOT: [ROLE_ADMIN] + ROLE_ROOT: [ROLE_ADMIN,ROLE_CLIENT_MAIN] + ROLE_CLIENT_MAIN: [ROLE_ADMIN] + access_control: # Permettre l'accès aux pages 2FA même si on n'est pas encore pleinement "ROLE_ADMIN" diff --git a/migrations/Version20260115194430.php b/migrations/Version20260115194430.php new file mode 100644 index 0000000..643f8a0 --- /dev/null +++ b/migrations/Version20260115194430.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE audit_log ADD hash_code VARCHAR(64) NOT 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 audit_log DROP hash_code'); + } +} diff --git a/src/Controller/Dashboard/AccountController.php b/src/Controller/Dashboard/AccountController.php index 76b38a5..e3bbd38 100644 --- a/src/Controller/Dashboard/AccountController.php +++ b/src/Controller/Dashboard/AccountController.php @@ -5,10 +5,15 @@ namespace App\Controller\Dashboard; use App\Entity\Account; use App\Event\Object\EventAdminCreate; use App\Event\Object\EventAdminDeleted; +use App\Form\AccountPasswordType; use App\Form\AccountType; use App\Logger\AppLogger; +use App\Repository\AccountLoginRegisterRepository; use App\Repository\AccountRepository; +use App\Repository\AuditLogRepository; +use App\Service\Mailer\Mailer; use Doctrine\ORM\EntityManagerInterface; +use Knp\Component\Pager\PaginatorInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; @@ -23,10 +28,10 @@ class AccountController extends AbstractController * Liste des administrateurs */ #[Route(path: '/crm/administrateur', name: 'app_crm_administrateur', options: ['sitemap' => false], methods: ['GET'])] - public function administrateur(AccountRepository $accountRepository, AppLogger $appLogger,EventDispatcherInterface $eventDispatcher): Response + public function administrateur(AccountRepository $accountRepository, AppLogger $appLogger): Response { - // Audit Log : On trace la consultation de la liste - $appLogger->record('VIEW', 'Consultation de la liste des administrateurs'); + $appLogger->record('VIEW', 'Consultation de la liste des Administrateurs'); + return $this->render('dashboard/administrateur.twig', [ 'admins' => $accountRepository->findAdmin(), ]); @@ -37,15 +42,14 @@ class AccountController extends AbstractController */ #[Route(path: '/crm/administrateur/add', name: 'app_crm_administrateur_add', options: ['sitemap' => false], methods: ['GET', 'POST'])] public function administrateurAdd( - Request $request, - EntityManagerInterface $entityManager, + Request $request, + EntityManagerInterface $entityManager, UserPasswordHasherInterface $passwordHasher, - AppLogger $appLogger, - EventDispatcherInterface $eventDispatcher - ): Response { + AppLogger $appLogger, + EventDispatcherInterface $eventDispatcher + ): Response + { $account = new Account(); - - // Initialisation des valeurs par défaut $account->setIsFirstLogin(true); $account->setIsActif(false); $account->setUuid(Uuid::v4()->toRfc4122()); @@ -55,108 +59,272 @@ class AccountController extends AbstractController $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - // Hachage du mot de passe temporaire $tempPassword = bin2hex(random_bytes(20)); - $hashedPassword = $passwordHasher->hashPassword($account, $tempPassword); - $account->setPassword($hashedPassword); + $account->setPassword($passwordHasher->hashPassword($account, $tempPassword)); $entityManager->persist($account); $entityManager->flush(); - // Audit Log : Enregistrement de la création $appLogger->record( 'CREATE', - sprintf("Création de l'administrateur : %s %s (%s)", - $account->getFirstName(), - $account->getName(), - $account->getEmail() + sprintf("Création de l'Administrateur : %s %s (%s)", + $account->getFirstName(), $account->getName(), $account->getEmail() ) ); - // Notification : Envoi du mail d'activation $eventDispatcher->dispatch(new EventAdminCreate($account, $this->getUser())); - - $this->addFlash('success', 'Le compte administrateur de ' . $account->getFirstName() . ' a été créé avec succès.'); + $this->addFlash('success', "L'Administrateur " . $account->getFirstName() . " a été créé avec succès."); return $this->redirectToRoute('app_crm_administrateur'); } - return $this->render('dashboard/administrateur/add.twig', [ - 'form' => $form->createView(), - ]); + return $this->render('dashboard/administrateur/add.twig', ['form' => $form->createView()]); } - /** - * Voir la fiche d'un administrateur - */ - #[Route(path: '/crm/administrateur/{id}', name: 'app_crm_administrateur_view', options: ['sitemap' => false], methods: ['GET'])] - public function administrateurView(?Account $account, AppLogger $appLogger): Response - { + #[Route(path: '/crm/administrateur/{id}', name: 'app_crm_administrateur_view', options: ['sitemap' => false], methods: ['GET', 'POST'])] + public function administrateurView( + ?Account $account, + PaginatorInterface $paginator, + Request $request, + AppLogger $appLogger, + Mailer $mailer, + AccountLoginRegisterRepository $accountLoginRegisterRepository, + EntityManagerInterface $em, + AuditLogRepository $auditLogRepository, + UserPasswordHasherInterface $passwordHasher + ): Response { if (!$account) { $this->addFlash('error', 'Administrateur introuvable.'); return $this->redirectToRoute('app_crm_administrateur'); } - // Audit Log : On trace la consultation d'une fiche spécifique - $appLogger->record( - 'VIEW', - sprintf("Consultation du profil de : %s %s (ID: %d)", - $account->getFirstName(), - $account->getName(), - $account->getId() - ) + // --- SÉCURITÉ : PROTECTION ROOT --- + if (in_array('ROLE_ROOT', $account->getRoles()) && !$this->isGranted('ROLE_ROOT')) { + $title = "Tentative d'accès non autorisée"; + $details = sprintf("L'utilisateur %s a tenté de consulter l'Administrateur ROOT protégé : %s %s (ID: %d)", + $this->getUser()->getUserIdentifier(), $account->getFirstName(), $account->getName(), $account->getId() + ); + $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'); + } + + // --- 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'); + + // On définit un sujet dynamique pour plus de clarté + $subject = $newStatus + ? "[Intranet Ludikevent] - Activation de votre accès" + : "[Intranet Ludikevent] - Suspension de votre accès"; + + // Envoi du mail de notification + $mailer->send( + $account->getEmail(), + $account->getFirstName() . " " . $account->getName(), + $subject, + "mails/account/status-update.twig", + [ + 'who' => $this->getUser(), + 'account' => $account, + 'stats' => $newStatus, + ], + [] + ); + + // Mise à jour en base de données + // Vérifiez si votre entité utilise setActif() ou setIsActif() + $account->setIsActif($newStatus); + $em->flush(); + + $label = $newStatus ? 'activé' : 'désactivé'; + + // Log et Message Flash + $appLogger->record('UPDATE_STATUS', sprintf("%s du compte pour l'admin %d", ucfirst($label), $account->getId())); + $this->addFlash('success', "Le compte de " . $account->getFirstName() . " a été $label avec succès."); + + return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]); + } + + // --- 2. LOGIQUE DE MISE À JOUR DES RÔLES (POST) --- + if ($request->isMethod('POST') && $request->query->get('act') === 'updateRoles') { + $submittedRoles = $request->request->all('roles'); + $mandatoryRoles = ['ROLE_USER', 'ROLE_ADMIN']; + + foreach ($mandatoryRoles as $role) { + if (!in_array($role, $submittedRoles)) { + $submittedRoles[] = $role; + } + } + + if (in_array('ROLE_CLIENT_MAIN', $submittedRoles) && !$this->isGranted('ROLE_ROOT')) { + $this->addFlash('error', 'Action non autorisée : Privilège ROOT requis.'); + return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]); + } + + $account->setRoles($submittedRoles); + $em->flush(); + + $appLogger->record('UPDATE_ROLES', sprintf("Mise à jour des rôles pour l'admin %d", $account->getId())); + $this->addFlash('success', 'Les permissions ont été mises à jour.'); + return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]); + } + + // --- 3. LOGIQUE CHANGEMENT DE MOT DE PASSE --- + $passwordForm = $this->createForm(AccountPasswordType::class, $account); + $passwordForm->handleRequest($request); + + if ($passwordForm->isSubmitted() && $passwordForm->isValid()) { + // 1. On récupère le mot de passe en clair + $plainPassword = $passwordForm->get('password')->getData(); + + // 2. On hache pour la base de données + $hashedPassword = $passwordHasher->hashPassword($account, $plainPassword); + $account->setPassword($hashedPassword); + $em->flush(); + + // 3. Envoi du mail avec les bons paramètres + $mailer->send( + $account->getEmail(), + $account->getName() . " " . $account->getFirstName(), + "[Intranet Ludikevent] - Modification de votre mot de passe", + "mails/account/password-modify.twig", // Correction orthographe 'account' + [ + 'who' => $this->getUser(), + 'account' => $account, + 'password' => $plainPassword // On envoie le mot de passe en clair ici + ], + [] + ); + + $appLogger->record('UPDATE_PASSWORD', sprintf("Mot de passe modifié et envoyé pour l'admin %d", $account->getId())); + $this->addFlash('success', 'Le mot de passe a été mis à jour et envoyé à l\'utilisateur.'); + + return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]); + } + + // --- 4. LOGIQUE DU FORMULAIRE D'IDENTITÉ --- + $form = $this->createForm(AccountType::class, $account); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $em->flush(); + $appLogger->record('UPDATE_IDENTITY', sprintf("Mise à jour identité : %s %s", $account->getFirstName(), $account->getName())); + $this->addFlash('success', 'Informations personnelles mises à jour.'); + return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]); + } + + // --- 5. AFFICHAGE & PAGINATION --- + $appLogger->record('VIEW', sprintf("Consultation : %s %s", $account->getFirstName(), $account->getName())); + + $loginPagination = $paginator->paginate( + $accountLoginRegisterRepository->findBy(['account' => $account], ['loginAt' => 'DESC']), + $request->query->getInt('page', 1), + 10, + ['pageParameterName' => 'page'] + ); + + $auditPagination = $paginator->paginate( + $auditLogRepository->findBy(['account' => $account], ['actionAt' => 'DESC']), + $request->query->getInt('audit_page', 1), + 10, + ['pageParameterName' => 'audit_page'] ); return $this->render('dashboard/administrateur/view.twig', [ 'admin' => $account, + 'form' => $form->createView(), + 'passwordForm' => $passwordForm->createView(), + 'loginRegisters' => $loginPagination, + 'auditLogs' => $auditPagination ]); } + /** + * Modifier un administrateur + */ + /** * Supprimer un administrateur */ #[Route(path: '/crm/administrateur/delete/{id}', name: 'app_crm_administrateur_delete', options: ['sitemap' => false], methods: ['POST'])] public function administrateurDelete( EventDispatcherInterface $eventDispatcher, - ?Account $account, - Request $request, - AppLogger $appLogger, - EntityManagerInterface $entityManager - ): Response { + ?Account $account, + Request $request, + AppLogger $appLogger, + EntityManagerInterface $entityManager, + Mailer $mailer + ): Response + { if (!$account) { $this->addFlash('error', 'Administrateur introuvable.'); return $this->redirectToRoute('app_crm_administrateur'); } - if ($account === $this->getUser()) { - $this->addFlash('error', 'Vous ne pouvez pas supprimer votre propre compte.'); + // --- SÉCURITÉ : Interdiction suppression ROOT ou CLIENT_MAIN --- + // On vérifie si l'un des rôles protégés est présent dans le tableau des rôles du compte + $protectedRoles = ['ROLE_ROOT', 'ROLE_CLIENT_MAIN']; + $accountRoles = $account->getRoles(); + + // Si l'intersection entre les rôles du compte et les rôles protégés n'est pas vide + if (array_intersect($protectedRoles, $accountRoles)) { + $title = "Blocage suppression compte HAUTE-HIÉRARCHIE"; + $details = sprintf("Tentative de suppression de l'Administrateur protégé %s (Rôles: %s) par l'utilisateur %s", + $account->getEmail(), + implode(', ', $accountRoles), + $this->getUser()->getUserIdentifier() + ); + + $appLogger->record('SECURITY_CRITICAL', $details); + $this->sendSecurityEmail($mailer, $title, $details); + + $this->addFlash('error', 'Sécurité : Ce compte est protégé et ne peut pas être supprimé.'); return $this->redirectToRoute('app_crm_administrateur'); } - // Récupération du token CSRF (compatible Turbo) $token = $request->request->get('_token') ?? $request->query->get('_token'); if ($this->isCsrfTokenValid('delete' . $account->getId(), $token)) { $name = $account->getFirstName() . ' ' . $account->getName(); $email = $account->getEmail(); - // Audit Log : On trace avant la suppression pour garder les infos $appLogger->record( 'DELETE', - sprintf("Suppression définitive de l'administrateur : %s (%s)", $name, $email) + sprintf("Suppression définitive de l'Administrateur : %s (%s)", $name, $email) ); - // Dispatch pour notification mail $eventDispatcher->dispatch(new EventAdminDeleted($account, $this->getUser())); $entityManager->remove($account); $entityManager->flush(); - $this->addFlash('success', "Le compte de $name a été supprimé."); + $this->addFlash('success', "L'Administrateur $name a été supprimé."); } else { $this->addFlash('error', 'Jeton de sécurité invalide.'); } return $this->redirectToRoute('app_crm_administrateur'); } + + private function sendSecurityEmail(Mailer $mailer, string $message, string $content): void + { + $mailer->send( + 'notification@siteconseil.fr', + "Notification Intranet Ludikevent", + "[Intranet Ludikevent] - " . $message, + "mails/notification/security_violation.twig", + [ + 'message' => $message, + 'content' => $content, + 'account' => $this->getUser() ? $this->getUser()->getUserIdentifier() : 'Système', + 'date' => new \DateTime(), + ] + ); + } } diff --git a/src/Controller/Dashboard/LogsController.php b/src/Controller/Dashboard/LogsController.php index 977e1b8..773dc91 100644 --- a/src/Controller/Dashboard/LogsController.php +++ b/src/Controller/Dashboard/LogsController.php @@ -4,6 +4,7 @@ namespace App\Controller\Dashboard; use App\Entity\AuditLog; use App\Repository\AuditLogRepository; +use Doctrine\ORM\EntityManagerInterface; use Knp\Component\Pager\PaginatorInterface; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Writer\Xlsx; @@ -12,6 +13,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Http\Attribute\IsGranted; class LogsController extends AbstractController { @@ -41,18 +43,30 @@ class LogsController extends AbstractController { $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); - $sheet->setTitle('Logs Intranet Ludikevent'); + $sheet->setTitle('Logs Audit'); - // En-têtes stylisés - $headers = ['Date', 'Heure', 'Admin', 'Email', 'Action', 'Message', 'URL', 'Navigateur/OS']; - foreach (range('A', 'G') as $i => $column) { + // 1. Génération d'un mot de passe aléatoire pour ce fichier + // On utilise bin2hex pour un mot de passe robuste + $ultraSecurePassword = bin2hex(random_bytes(32)); + + // En-têtes (A à J) + $headers = ['Date', 'Heure', 'Admin', 'Email', 'Action', 'Message', 'URL', 'Navigateur/OS', 'Signature', 'État']; + $columns = range('A', 'J'); + + foreach ($columns as $i => $column) { $sheet->setCellValue($column . '1', $headers[$i]); $sheet->getStyle($column . '1')->getFont()->setBold(true); + $sheet->getStyle($column . '1')->getFill() + ->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID) + ->getStartColor()->setARGB('F1F5F9'); } $row = 2; /** @var AuditLog $log */ foreach ($logs as $log) { + $isValid = ($log->getHashCode() === $log->generateSignature()); + $statusText = $isValid ? 'VALIDE' : 'CORROMPU'; + $sheet->setCellValue('A' . $row, $log->getActionAt()->format('d/m/Y')); $sheet->setCellValue('B' . $row, $log->getActionAt()->format('H:i:s')); $sheet->setCellValue('C' . $row, $log->getAccount()->getFirstName() . ' ' . $log->getAccount()->getName()); @@ -61,16 +75,77 @@ class LogsController extends AbstractController $sheet->setCellValue('F' . $row, $log->getMessage()); $sheet->setCellValue('G' . $row, $log->getPath()); $sheet->setCellValue('H' . $row, $log->getUserAgent()); + $sheet->setCellValue('I' . $row, $log->getHashCode()); + $sheet->setCellValue('J' . $row, $statusText); + + // Couleur d'état + $color = $isValid ? '059669' : 'DC2626'; + $sheet->getStyle('J' . $row)->getFont()->setBold(true)->getColor()->setARGB($color); + $row++; } - $writer = new Xlsx($spreadsheet); - $response = new StreamedResponse(fn() => $writer->save('php://output')); + // Auto-ajustement des colonnes + foreach ($columns as $column) { + $sheet->getColumnDimension($column)->setAutoSize(true); + } + + // --- VERROUILLAGE DU FICHIER --- + + // On protège la feuille contre toute modification + $sheet->getProtection()->setPassword($ultraSecurePassword); + $sheet->getProtection()->setSheet(true); + + // On interdit la sélection des cellules verrouillées (optionnel) + $sheet->getProtection()->setSelectLockedCells(true); + $sheet->getProtection()->setSort(false); + $sheet->getProtection()->setInsertRows(false); + + // On verrouille la structure du classeur + $spreadsheet->getSecurity()->setLockStructure(true); + $spreadsheet->getSecurity()->setWorkbookPassword($ultraSecurePassword); + $spreadsheet->getProperties() + ->setCreator("Système Audit Ludikevent") + ->setLastModifiedBy("Automate") + ->setCompany("SARL SITECONSEIL") + ->setCategory("Système Audit Ludikevent") + ->setTitle("Journal d'audit protégé") + ->setSubject("Confidentiel") + ->setDescription("Ce fichier est protégé par signature numérique et verrouillage structurel."); + // --- GÉNÉRATION DE LA RÉPONSE --- + $writer = new Xlsx($spreadsheet); + + $response = new StreamedResponse(function() use ($writer) { + $writer->save('php://output'); + }); + + // On ajoute la date et un identifiant court dans le nom du fichier + $fileName = 'audit_ludikevent_' . date('d-m-Y_H-i') . '.xlsx'; - $fileName = 'export_logs_' . date('d_m_Y') . '.xlsx'; $response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); $response->headers->set('Content-Disposition', 'attachment;filename="' . $fileName . '"'); + $response->headers->set('Cache-Control', 'max-age=0'); return $response; } + #[Route(path: '/crm/logs/delete/{id}', name: 'app_crm_audit_logs_delete', methods: ['POST'])] + #[IsGranted('ROLE_ROOT')] + public function deleteLog( + AuditLog $log, + Request $request, + EntityManagerInterface $entityManager + ): Response { + // Vérification du token CSRF dynamique (lié à l'ID du log) + if (!$this->isCsrfTokenValid('delete' . $log->getId(), $request->request->get('_token'))) { + $this->addFlash('error', 'Action non autorisée.'); + return $this->redirectToRoute('app_crm_audit_logs'); + } + + $entityManager->remove($log); + $entityManager->flush(); + + $this->addFlash('success', 'L\'entrée du journal a été supprimée.'); + + return $this->redirectToRoute('app_crm_audit_logs'); + } } diff --git a/src/Entity/AuditLog.php b/src/Entity/AuditLog.php index 2227f5a..7d4143c 100644 --- a/src/Entity/AuditLog.php +++ b/src/Entity/AuditLog.php @@ -32,6 +32,8 @@ class AuditLog #[ORM\Column(length: 255)] private ?string $userAgent = null; + #[ORM\Column(length: 64)] + private ?string $hashCode = null; // Le constructeur force le remplissage des données dès le départ public function __construct(Account $account, string $type, string $message, string $path,string $userAgent) @@ -42,6 +44,22 @@ class AuditLog $this->path = $path; $this->actionAt = new \DateTimeImmutable(); $this->userAgent = $userAgent; + $this->hashCode = $this->generateSignature(); + } + + public function generateSignature(): string + { + $data = sprintf( + '%s|%s|%s|%s|%s|%s', + $this->account->getEmail(), // L'email est plus fiable que l'ID si l'user est nouveau + $this->type, + $this->message, + $this->path, + $this->actionAt->format('Y-m-d H:i:s'), + $_ENV['APP_SECRET'] ?? 'default_secret' + ); + + return hash('sha256', $data); } // Uniquement des Getters (Pas de Setters = Pas de modification possible en PHP) @@ -52,4 +70,5 @@ class AuditLog public function getMessage(): ?string { return $this->message; } public function getPath(): ?string { return $this->path; } public function getUserAgent(): ?string{ return $this->userAgent;} + public function getHashCode(): ?string{ return $this->hashCode;} } diff --git a/src/Form/AccountPasswordType.php b/src/Form/AccountPasswordType.php new file mode 100644 index 0000000..94e2896 --- /dev/null +++ b/src/Form/AccountPasswordType.php @@ -0,0 +1,56 @@ +add('password', RepeatedType::class, [ + 'type' => PasswordType::class, + 'invalid_message' => 'Les mots de passe doivent être identiques.', + 'required' => true, + 'first_options' => [ + 'label' => 'Nouveau mot de passe', + 'attr' => [ + 'placeholder' => 'Entrez le nouveau mot de passe', + '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' + ] + ], + 'second_options' => [ + 'label' => 'Confirmer le mot de passe', + 'attr' => [ + 'placeholder' => 'Répétez le mot de passe', + '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' + ] + ], + 'constraints' => [ + new NotBlank([ + 'message' => 'Veuillez entrer un mot de passe', + ]), + new Length([ + 'min' => 8, + 'minMessage' => 'Votre mot de passe doit contenir au moins {{ limit }} caractères', + 'max' => 4096, + ]), + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Account::class, + ]); + } +} diff --git a/src/Logger/AuditLogSecurityListener.php b/src/Logger/AuditLogSecurityListener.php index 297b430..769a016 100644 --- a/src/Logger/AuditLogSecurityListener.php +++ b/src/Logger/AuditLogSecurityListener.php @@ -4,20 +4,39 @@ namespace App\Logger; use App\Entity\AuditLog; use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener; +use Doctrine\ORM\Event\PrePersistEventArgs; use Doctrine\ORM\Events; use Doctrine\ORM\Event\PreUpdateEventArgs; +use Symfony\Bundle\SecurityBundle\Security; #[AsEntityListener(event: Events::preUpdate, method: 'preUpdate', entity: AuditLog::class)] #[AsEntityListener(event: Events::preRemove, method: 'preRemove', entity: AuditLog::class)] +#[AsEntityListener(event: Events::prePersist, method: 'prePersist', entity: AuditLog::class)] + class AuditLogSecurityListener { + public function __construct( + private Security $security + ) {} + + // Exécuté lors de la CRÉATION initiale du log + public function prePersist(AuditLog $log, PrePersistEventArgs $event): void + { + $log->generateSignature(); + } + public function preUpdate(AuditLog $log, PreUpdateEventArgs $event): void { - throw new \LogicException("AuditLog est immuable : modification interdite."); + if (!$this->security->isGranted('ROLE_ROOT')) { + throw new \LogicException("AuditLog est immuable : modification interdite."); + } + $log->generateSignature(); } public function preRemove(AuditLog $log): void { - throw new \LogicException("AuditLog est protégé : suppression interdite."); + if (!$this->security->isGranted('ROLE_ROOT')) { + throw new \LogicException("AuditLog est protégé : suppression interdite."); + } } } diff --git a/templates/dashboard/administrateur.twig b/templates/dashboard/administrateur.twig index aec10c4..69d710b 100644 --- a/templates/dashboard/administrateur.twig +++ b/templates/dashboard/administrateur.twig @@ -3,90 +3,142 @@ {% block title %}Administrateurs{% endblock %} {% block actions %} - {# Bouton Ajouter un administrateur #} - + class="inline-flex items-center px-4 py-2 text-sm font-black uppercase tracking-widest text-white bg-indigo-600 rounded-xl hover:bg-indigo-700 shadow-lg shadow-indigo-500/20 transition-all active:scale-95"> + - Ajouter un administrateur + Ajouter un Administrateur {% endblock %} {% block body %}
+ {# Container élargi à 100% pour une visibilité totale #}
-
-

Liste des Administrateurs

- - {{ admins|length }} Administrateurs - + + {# HEADER #} +
+
+

Liste des Administrateurs

+

Gestion des accès et des niveaux d'accréditation système.

+
+
+ + {{ admins|length }} Administrateurs + +
-
- - - - - - - - - - - {% for admin in admins %} - - - - - - - {% else %} + {# TABLE CARD #} +
+
+
UtilisateurEmailStatutActions
-
- {{ admin.firstName }} {{ admin.name }} - @{{ admin.username }} -
-
- {{ admin.email }} - - {% if admin.actif %} - - - Actif - - {% else %} - - - Suspendu - - {% endif %} - - {# Bouton Voir #} - - - Gérer - - - {# Bouton Supprimer #} - - - - - Supprimer - -
+ - + + + + - {% endfor %} - -
- Aucun administrateur trouvé. - Identité de l'AdministrateurRôles & HabilitationsStatut du compteActions
+ + + {% for admin in admins %} + + {# COLONNE 1 : IDENTITÉ #} + +
+
+ {{ admin.firstName|first|upper }}{{ admin.name|first|upper }} +
+
+ {{ admin.firstName }} {{ admin.name }} + {{ admin.email }} +
+
+ + + {# COLONNE 2 : RÔLES #} + +
+ {% for role in admin.roles %} + {% if role != 'ROLE_USER' %} + {% if role == 'ROLE_ROOT' %} + ROOT + {% elseif role == 'ROLE_CLIENT_MAIN' %} + CLIENT PRINCIPAL + {% elseif role == 'ROLE_ADMIN' %} + ADMIN + {% else %} + + {{ role|replace({'ROLE_ADMIN_': ''}) }} + + {% endif %} + {% endif %} + {% endfor %} +
+ + + {# COLONNE 3 : STATUT COULEUR (Vert/Ambre) #} + + {% if admin.actif %} + + + Accès Actif + + {% else %} + + + Accès Suspendu + + {% endif %} + + + {# COLONNE 4 : ACTIONS #} + +
+ {# Bouton Gérer #} + + + Gérer + + + {# Bouton Supprimer avec protection ROOT/CLIENT_MAIN #} + {% if 'ROLE_ROOT' not in admin.roles and 'ROLE_CLIENT_MAIN' not in admin.roles %} + + + + {% else %} +
+ +
+ {% endif %} +
+ + + {% else %} + + +
+
+ +
+

Aucun Administrateur enregistré

+

Commencez par en ajouter un via le bouton en haut à droite.

+
+ + + {% endfor %} + + +
diff --git a/templates/dashboard/administrateur/view.twig b/templates/dashboard/administrateur/view.twig new file mode 100644 index 0000000..44c5d19 --- /dev/null +++ b/templates/dashboard/administrateur/view.twig @@ -0,0 +1,433 @@ +{% extends 'dashboard/base.twig' %} + +{% block title %}Administrateur : {{ admin.firstName }} {{ admin.name }}{% endblock %} + +{% block actions %} + + + Retour à la liste + +{% endblock %} + +{% 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 + +
+
+ {% endif %} + +

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

+
+ +
+
+ + {{ admin.email }} +
+
+ System ID: #{{ admin.id }} +
+
+
+ +
+ {# --- MODULE DE STATUT INTÉGRÉ --- #} + {% if not admin.actif %} +
+
+
+ Compte non activé +
+ + Activer + +
+ {% else %} +
+
+
+ Accès en ligne +
+ + Désactiver + +
+ {% endif %} +
+
+
+ + {# --- COLONNE GAUCHE : FORMULAIRE IDENTITÉ (SANS LE TOGGLE ACTIF) --- #} +
+ {{ form_start(form) }} +
+
+
+ {{ admin.firstName|first|upper }}{{ admin.name|first|upper }} +
+
+

Identité

+

Données personnelles

+
+
+ +
+
+ + {{ form_widget(form.firstName, { '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.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'} }) }} +
+
+ + {{ 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'} }) }} +
+ +
+ + +
+ {{ form_end(form) }} +
+ + {# --- COLONNE DROITE : DROITS & HISTORIQUE --- #} +
+ {# --- CARTE SÉCURITÉ & MOT DE PASSE --- #} +
+
+

+ + Sécurité et Mot de passe +

+ + Envoyer un lien de réinitialisation + +
+ + {{ 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 #} + {% if 'ROLE_CLIENT_MAIN' not in admin.roles %} +
+
+
+

+ + + + Habilitations Opérationnelles +

+ +
+ +
+ {% set availableRoles = [ + {'role': 'ROLE_ADMIN_EDIT', 'label': 'Gestion des Administrateurs', 'icon': 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z'}, + {'role': 'ROLE_ADMIN_PRODUCT', 'label': 'Catalogue Produits', 'icon': 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4'}, + {'role': 'ROLE_ADMIN_CONTRAT', 'label': 'Gestion Contrats', 'icon': 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'} + ] %} + + {% for item in availableRoles %} +
+
+
+ +
+
+ {{ item.label }} + {{ item.role }} +
+
+ +
+ {% endfor %} +
+
+
+ {% else %} +
+
+ +
+
+

Habilitation Totale Active

+

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

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

+ + + + Privilège de Structure +

+ +
+ +
+ +
+
+
+ {% endif %} +
+
+ + {# --- SECTIONS JOURNAUX (LOGS) --- #} +
+ + {# --- SECTION : JOURNAL D'AUDIT DU COMPTE --- #} +
+ + {# HEADER DU JOURNAL #} +
+
+

Journal d'Audit Personnel

+

Historique des actions effectuées par cet utilisateur

+
+
+ + {{ auditLogs.getTotalItemCount }} ÉVÈNEMENTS + +
+
+ +
+ + + + + + + + + {% if is_granted('ROLE_ROOT') %} + + {% endif %} + + + + {% for log in auditLogs %} + + + {# 1. DATE #} + + + {# 2. UA (ADMIN & APPAREIL) #} + + + {# 3. TYPE D'ACTION #} + + + {# 4. INTÉGRITÉ #} + + + {# 5. DÉTAILS #} + + + {# 6. ACTIONS ROOT #} + {% if is_granted('ROLE_ROOT') %} + + {% endif %} + + {% else %} + + + + {% endfor %} + +
HorodatageAppareil / SourceActionIntégritéDétailsActions
+
{{ log.actionAt|date('d/m/Y') }}
+
{{ log.actionAt|date('H:i:s') }}
+
+ {% if log.userAgent %} + {% set ua = log.userAgent|lower %} +
+ + {% if 'firefox' in ua %} + + {% elseif 'chrome' in ua %} + + {% else %} + + {% endif %} + + {{ log.userAgent }} +
+ {% else %} + Source inconnue + {% endif %} +
+ {% set typeMapping = { + 'CREATE': { 'label': 'CRÉATION', 'style': 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20' }, + 'DELETE': { 'label': 'SUPPRESSION', 'style': 'bg-rose-500/10 text-rose-600 border-rose-500/20' }, + 'UPDATE': { 'label': 'MODIFICATION', 'style': 'bg-blue-500/10 text-blue-600 border-blue-500/20' }, + 'AUTH': { 'label': 'CONNEXION', 'style': 'bg-amber-500/10 text-amber-600 border-amber-500/20' }, + 'VIEW': { 'label': 'CONSULTATION', 'style': 'bg-sky-500/10 text-sky-600 border-sky-500/20' }, + 'SECURITY_ALERT': { 'label': 'ALERTE SÉCURITÉ', 'style': 'bg-orange-500/10 text-orange-600 border-orange-500/20' }, + 'SECURITY_CRITICAL': { 'label': 'CRITIQUE', 'style': 'bg-red-600 text-white border-red-700 animate-pulse font-black' } + } %} + + {% set config = typeMapping[log.type] ?? { 'label': log.type, 'style': 'bg-slate-500/10 text-slate-500 border-slate-500/20' } %} + + + {{ config.label }} + + + {% if log.hashCode == log.generateSignature %} +
+
+ +
+ Valide +
+ {% else %} +
+
+ +
+ Altéré +
+ {% endif %} +
+
{{ log.message }}
+
+ {{ log.path|default('/') }} +
+
+
+ + +
+
+ Aucun enregistrement d'audit pour ce compte. +
+
+ + {# PAGINATION #} + {% if auditLogs.getTotalItemCount > 0 %} +
+
+ + Page {{ auditLogs.getCurrentPageNumber }} — {{ auditLogs|length }} logs affichés + + +
+
+ {% endif %} +
+ {# CONNEXIONS #} +
+
+

Dernières Connexions

+
+
+ + + + + + + + + {% for login in loginRegisters %} + + + + + {% else %} + + {% endfor %} + +
Date & HeureIP
{{ login.loginAt|date('d/m/Y H:i') }} + + {{ login.ip }} + +
Aucune connexion enregistrée.
+
+ {% if loginRegisters.getTotalItemCount > 0 %}
{{ knp_pagination_render(loginRegisters) }}
{% endif %} +
+ +
+
+{% endblock %} diff --git a/templates/dashboard/audit_logs.twig b/templates/dashboard/audit_logs.twig index d9d9f38..9ac0794 100644 --- a/templates/dashboard/audit_logs.twig +++ b/templates/dashboard/audit_logs.twig @@ -2,24 +2,27 @@ {% block title %}Traçabilité des actions{% endblock %} -{# BOUTON EXTRACTION DANS LE HEADER #} +{# HEADER : ACTIONS GLOBALES #} {% block actions %} - - - - - Exporter XLSX - +
+ {# Bouton Exporter XLSX #} + + + + + Exporter XLSX + +
{% endblock %} {% block body %}
- {# Header du tableau avec statistiques rapides #} + {# STATISTIQUES #}

Journal d'Audit

-

Historique complet des interactions sur l'Intranet

+

Historique sécurisé par signature cryptographique

@@ -35,20 +38,26 @@ Horodatage Administrateur & Appareil Action - Détails de l'activité + Intégrité + Détails + + {# COLONNE ACTIONS VISIBLE UNIQUEMENT POUR ROOT #} + {% if is_granted('ROLE_ROOT') %} + Actions + {% endif %} {% for log in logs %} - {# 1. DATE & HEURE #} + {# 1. DATE #}
{{ log.actionAt|date('d/m/Y') }}
{{ log.actionAt|date('H:i:s') }}
- {# 2. ADMINISTRATEUR + USER AGENT #} + {# 2. ADMIN & UA #}
@@ -58,65 +67,94 @@
{{ log.account.firstName }} {{ log.account.name }} {% if 'ROLE_ROOT' in log.account.roles %} - Root + Root {% endif %}
-
{{ log.account.email }}
+
{{ log.account.email }}
- {# Bloc User Agent avec icône SVG dynamique #} {% if log.userAgent %} {% set ua = log.userAgent|lower %} -
- - {% if 'firefox' in ua %} - - {% elseif 'edg/' in ua %} - - {% elseif 'chrome' in ua %} - - {% elseif 'safari' in ua and 'chrome' not in ua %} - - {% else %} - - {% endif %} - - - {{ log.userAgent }} - +
+ + {% if 'firefox' in ua %} + + {% elseif 'chrome' in ua %} + + {% else %} + + {% endif %} + + {{ log.userAgent }}
{% endif %}
- {# 3. BADGE ACTION #} + {# 3. TYPE D'ACTION TRADUIT #} + {# 3. TYPE D'ACTION TRADUIT ET STYLISÉ #} - {% set typeStyles = { - 'CREATE': 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20', - 'DELETE': 'bg-rose-500/10 text-rose-600 border-rose-500/20', - 'VIEW': 'bg-sky-500/10 text-sky-600 border-sky-500/20', - 'AUTH': 'bg-amber-500/10 text-amber-600 border-amber-500/20' + {% set typeMapping = { + 'CREATE': { 'label': 'CRÉATION', 'style': 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20' }, + 'DELETE': { 'label': 'SUPPRESSION', 'style': 'bg-rose-500/10 text-rose-600 border-rose-500/20' }, + 'UPDATE': { 'label': 'MODIFICATION', 'style': 'bg-blue-500/10 text-blue-600 border-blue-500/20' }, + 'AUTH': { 'label': 'CONNEXION', 'style': 'bg-amber-500/10 text-amber-600 border-amber-500/20' }, + 'VIEW': { 'label': 'CONSULTATION', 'style': 'bg-sky-500/10 text-sky-600 border-sky-500/20' }, + 'SECURITY_ALERT': { 'label': 'ALERTE SÉCURITÉ', 'style': 'bg-orange-500/10 text-orange-600 border-orange-500/20' }, + 'SECURITY_CRITICAL': { 'label': 'CRITIQUE', 'style': 'bg-red-600 text-white border-red-700 animate-pulse font-black' } } %} - - {{ log.type }} - + + {# On récupère la configuration ou on utilise une valeur par défaut #} + {% set config = typeMapping[log.type] ?? { 'label': log.type, 'style': 'bg-slate-500/10 text-slate-500 border-slate-500/20' } %} + + + {{ config.label }} + + + {# 4. INTÉGRITÉ (HASH CHECK) #} + + {% if log.hashCode == log.generateSignature %} +
+
+ +
+ Valide +
+ {% else %} +
+
+ +
+ Altéré +
+ {% endif %} - {# 4. MESSAGE ET CHEMIN #} + {# 5. DÉTAILS #} -
{{ log.message }}
+
{{ log.message }}
- URL : - - {{ log.path }} - + {{ log.path }}
+ + {# 6. CELLULE ACTIONS VISIBLE UNIQUEMENT POUR ROOT #} + {% if is_granted('ROLE_ROOT') %} + +
+ + +
+ + {% endif %} {% else %} - -
Aucun log trouvé dans cette période.
+ {# On ajuste le colspan dynamiquement #} + + Aucun enregistrement. {% endfor %} @@ -124,11 +162,11 @@
- {# FOOTER & PAGINATION #} + {# PAGINATION #}
- Page {{ logs.getCurrentPageNumber }} — Affichage de {{ logs|length }} logs + Page {{ logs.getCurrentPageNumber }} — {{ logs|length }} logs affichés