From 9c1ea29505a68e3dab020de903f725a1f1ad89ab Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Thu, 2 Apr 2026 23:11:34 +0200 Subject: [PATCH] feat: systeme de logs d'activite admin avec HMAC + export PDF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/Entity/AppLog.php (nouveau): - id, user (ManyToOne nullable, SET NULL on delete), method (GET/POST/etc), url (500 chars), route (nom de la route Symfony), action (description lisible de l'action), ip (nullable), hmac (SHA-256), createdAt - Index sur created_at pour les requetes paginées - HMAC genere dans le constructeur avec payload: method|url|route|action|ip|userId|createdAt (microsecondes) - verifyHmac(): verification timing-safe avec hash_equals - Aucun setter sur les champs (immutable apres creation) src/Repository/AppLogRepository.php (nouveau): - createPaginatedQueryBuilder(): ORDER BY createdAt DESC avec jointure user src/Service/AppLoggerService.php (nouveau): - Dictionnaire ROUTE_LABELS: 30+ routes admin avec descriptions lisibles (ex: app_admin_clients_create → "Creation d'un client") - log(): cree un AppLog avec l'action lisible, persiste et flush - verifyLog(): verifie le HMAC d'un log - Si la route n'est pas dans le dictionnaire, utilise "Acces a {route}" - Ajoute "(soumission)" pour les POST src/EventListener/AdminLogListener.php (nouveau): - Ecoute KernelEvents::CONTROLLER avec priorite -10 - Intercepte toutes les requetes dont la route commence par app_admin_ - Ignore les requetes AJAX de recherche (evite le spam) - Recupere l'utilisateur connecte via TokenStorage - Appelle AppLoggerService::log() dans un try/catch (ne bloque jamais la requete si le logging echoue) src/Controller/Admin/LogsController.php (nouveau): - Route /admin/logs, ROLE_ROOT - index(): pagination KnpPaginator (20 par page), verifie le HMAC de chaque log affiche - pdf(): genere un PDF Dompdf avec toutes les infos du log + verification HMAC (CONFORME vert / ALTEREES rouge) templates/admin/logs/index.html.twig (nouveau): - Tableau glassmorphism: date, utilisateur, methode (badge colore), action, URL (tronquee), IP, colonne HMAC (rond vert/rouge), bouton PDF par ligne - Pagination KnpPaginator en bas templates/admin/logs/pdf.html.twig (nouveau): - PDF A4 avec tableau d'informations du log - Bloc HMAC avec fond vert "INTEGRITE VERIFIEE" ou rouge "INTEGRITE COMPROMISE" + signature HMAC complete - Footer avec mention SARL SITECONSEIL templates/admin/_layout.html.twig: - Ajout lien "Logs" dans la sidebar Super Admin avec icone document migrations/Version20260402211054.php: - Table app_log avec FK user_id, index sur created_at Co-Authored-By: Claude Opus 4.6 (1M context) --- migrations/Version20260402211054.php | 35 +++++++ src/Controller/Admin/LogsController.php | 78 ++++++++++++++++ src/Entity/AppLog.php | 118 ++++++++++++++++++++++++ src/EventListener/AdminLogListener.php | 57 ++++++++++++ src/Repository/AppLogRepository.php | 27 ++++++ src/Service/AppLoggerService.php | 73 +++++++++++++++ templates/admin/_layout.html.twig | 4 + templates/admin/logs/index.html.twig | 72 +++++++++++++++ templates/admin/logs/pdf.html.twig | 66 +++++++++++++ 9 files changed, 530 insertions(+) create mode 100644 migrations/Version20260402211054.php create mode 100644 src/Controller/Admin/LogsController.php create mode 100644 src/Entity/AppLog.php create mode 100644 src/EventListener/AdminLogListener.php create mode 100644 src/Repository/AppLogRepository.php create mode 100644 src/Service/AppLoggerService.php create mode 100644 templates/admin/logs/index.html.twig create mode 100644 templates/admin/logs/pdf.html.twig diff --git a/migrations/Version20260402211054.php b/migrations/Version20260402211054.php new file mode 100644 index 0000000..fd0b1df --- /dev/null +++ b/migrations/Version20260402211054.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE app_log (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, method VARCHAR(10) NOT NULL, url VARCHAR(500) NOT NULL, route VARCHAR(255) NOT NULL, action TEXT NOT NULL, ip VARCHAR(50) DEFAULT NULL, hmac VARCHAR(128) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_id INT DEFAULT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_A14DDB02A76ED395 ON app_log (user_id)'); + $this->addSql('CREATE INDEX idx_applog_created ON app_log (created_at)'); + $this->addSql('ALTER TABLE app_log ADD CONSTRAINT FK_A14DDB02A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE app_log DROP CONSTRAINT FK_A14DDB02A76ED395'); + $this->addSql('DROP TABLE app_log'); + } +} diff --git a/src/Controller/Admin/LogsController.php b/src/Controller/Admin/LogsController.php new file mode 100644 index 0000000..505808f --- /dev/null +++ b/src/Controller/Admin/LogsController.php @@ -0,0 +1,78 @@ +createPaginatedQueryBuilder(); + + $pagination = $paginator->paginate( + $query, + $request->query->getInt('page', 1), + 20, + ); + + // Verifier le HMAC de chaque log + $hmacResults = []; + foreach ($pagination as $log) { + $hmacResults[$log->getId()] = $loggerService->verifyLog($log); + } + + return $this->render('admin/logs/index.html.twig', [ + 'pagination' => $pagination, + 'hmacResults' => $hmacResults, + ]); + } + + #[Route('/{id}/pdf', name: '_pdf', methods: ['GET'])] + public function pdf( + int $id, + AppLogRepository $repository, + AppLoggerService $loggerService, + Environment $twig, + ): Response { + $log = $repository->find($id); + + if (null === $log) { + throw $this->createNotFoundException('Log introuvable.'); + } + + $hmacValid = $loggerService->verifyLog($log); + + $html = $twig->render('admin/logs/pdf.html.twig', [ + 'log' => $log, + 'hmacValid' => $hmacValid, + ]); + + $dompdf = new Dompdf(); + $dompdf->loadHtml($html); + $dompdf->setPaper('A4'); + $dompdf->render(); + + return new Response($dompdf->output(), 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="log-'.$log->getId().'.pdf"', + ]); + } +} diff --git a/src/Entity/AppLog.php b/src/Entity/AppLog.php new file mode 100644 index 0000000..07a086e --- /dev/null +++ b/src/Entity/AppLog.php @@ -0,0 +1,118 @@ +method = $method; + $this->url = $url; + $this->route = $route; + $this->action = $action; + $this->user = $user; + $this->ip = $ip; + $this->createdAt = new \DateTimeImmutable(); + $this->hmac = $this->generateHmac($hmacSecret); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function getMethod(): string + { + return $this->method; + } + + public function getUrl(): string + { + return $this->url; + } + + public function getRoute(): string + { + return $this->route; + } + + public function getAction(): string + { + return $this->action; + } + + public function getIp(): ?string + { + return $this->ip; + } + + public function getHmac(): string + { + return $this->hmac; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function verifyHmac(string $hmacSecret): bool + { + return hash_equals($this->hmac, $this->generateHmac($hmacSecret)); + } + + private function generateHmac(string $secret): string + { + $payload = implode('|', [ + $this->method, + $this->url, + $this->route, + $this->action, + $this->ip ?? '', + null !== $this->user ? (string) $this->user->getId() : '', + $this->createdAt->format('Y-m-d\TH:i:s.u'), + ]); + + return hash_hmac('sha256', $payload, $secret); + } +} diff --git a/src/EventListener/AdminLogListener.php b/src/EventListener/AdminLogListener.php new file mode 100644 index 0000000..ae3120f --- /dev/null +++ b/src/EventListener/AdminLogListener.php @@ -0,0 +1,57 @@ +isMainRequest()) { + return; + } + + $request = $event->getRequest(); + $route = $request->attributes->get('_route', ''); + + if (!str_starts_with($route, 'app_admin_')) { + return; + } + + // Ignorer les requetes AJAX de recherche pour eviter le spam de logs + if ($request->isXmlHttpRequest() && str_contains($route, 'search')) { + return; + } + + $user = null; + $token = $this->tokenStorage->getToken(); + if (null !== $token && $token->getUser() instanceof User) { + $user = $token->getUser(); + } + + try { + $this->logger->log( + $request->getMethod(), + $request->getRequestUri(), + $route, + $user, + $request->getClientIp(), + ); + } catch (\Throwable) { + // Ne jamais bloquer la requete si le logging echoue + } + } +} diff --git a/src/Repository/AppLogRepository.php b/src/Repository/AppLogRepository.php new file mode 100644 index 0000000..a02443d --- /dev/null +++ b/src/Repository/AppLogRepository.php @@ -0,0 +1,27 @@ + + */ +class AppLogRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, AppLog::class); + } + + public function createPaginatedQueryBuilder(): QueryBuilder + { + return $this->createQueryBuilder('l') + ->leftJoin('l.user', 'u') + ->addSelect('u') + ->orderBy('l.createdAt', 'DESC'); + } +} diff --git a/src/Service/AppLoggerService.php b/src/Service/AppLoggerService.php new file mode 100644 index 0000000..2c95e2e --- /dev/null +++ b/src/Service/AppLoggerService.php @@ -0,0 +1,73 @@ + Routes admin → descriptions lisibles */ + private const ROUTE_LABELS = [ + 'app_admin_dashboard' => 'Consultation du tableau de bord', + 'app_admin_clients_index' => 'Consultation de la liste des clients', + 'app_admin_clients_create' => 'Creation d\'un client', + 'app_admin_clients_show' => 'Consultation d\'un client', + 'app_admin_clients_edit' => 'Modification d\'un client', + 'app_admin_clients_search' => 'Recherche de clients', + 'app_admin_facturation_index' => 'Consultation de la facturation', + 'app_admin_revendeurs_index' => 'Consultation des revendeurs', + 'app_admin_revendeurs_create' => 'Creation d\'un revendeur', + 'app_admin_revendeurs_edit' => 'Modification d\'un revendeur', + 'app_admin_revendeurs_contrat' => 'Generation d\'un contrat revendeur', + 'app_admin_revendeurs_search' => 'Recherche de revendeurs', + 'app_admin_membres' => 'Consultation des membres', + 'app_admin_membres_create' => 'Creation d\'un membre', + 'app_admin_membres_delete' => 'Suppression d\'un membre', + 'app_admin_membres_resend' => 'Renvoi des identifiants d\'un membre', + 'app_admin_stats_index' => 'Consultation des statistiques', + 'app_admin_status_index' => 'Consultation du status des services', + 'app_admin_status_resolve' => 'Resolution d\'un message de service', + 'app_admin_sync_index' => 'Consultation de la synchronisation', + 'app_admin_sync_all' => 'Synchronisation complete Meilisearch', + 'app_admin_sync_customers' => 'Synchronisation des clients Meilisearch', + 'app_admin_sync_revendeurs' => 'Synchronisation des revendeurs Meilisearch', + 'app_admin_sync_prices' => 'Synchronisation des tarifs Meilisearch', + 'app_admin_sync_stripe_prices' => 'Synchronisation des tarifs Stripe', + 'app_admin_sync_stripe_webhooks' => 'Creation des webhooks Stripe', + 'app_admin_profil' => 'Consultation du profil', + 'app_admin_order_number' => 'Consultation de la numerotation', + 'app_admin_order_number_update' => 'Modification de la numerotation', + 'app_admin_tarification' => 'Consultation de la tarification', + 'app_admin_tarification_edit' => 'Modification d\'un tarif', + 'app_admin_services_index' => 'Consultation des services', + 'app_admin_logs' => 'Consultation des logs', + ]; + + public function __construct( + private EntityManagerInterface $em, + #[Autowire('%env(APP_SECRET)%')] private string $hmacSecret, + ) { + } + + public function log(string $method, string $url, string $route, ?User $user = null, ?string $ip = null): void + { + $action = self::ROUTE_LABELS[$route] ?? 'Acces a '.$route; + + if ('POST' === $method) { + $action .= ' (soumission)'; + } + + $log = new AppLog($method, $url, $route, $action, $this->hmacSecret, $user, $ip); + + $this->em->persist($log); + $this->em->flush(); + } + + public function verifyLog(AppLog $log): bool + { + return $log->verifyHmac($this->hmacSecret); + } +} diff --git a/templates/admin/_layout.html.twig b/templates/admin/_layout.html.twig index ec327a7..e313257 100644 --- a/templates/admin/_layout.html.twig +++ b/templates/admin/_layout.html.twig @@ -81,6 +81,10 @@ Numerotation + + + Logs + Tarification diff --git a/templates/admin/logs/index.html.twig b/templates/admin/logs/index.html.twig new file mode 100644 index 0000000..9cef44f --- /dev/null +++ b/templates/admin/logs/index.html.twig @@ -0,0 +1,72 @@ +{% extends 'admin/_layout.html.twig' %} + +{% block title %}Logs - Administration - CRM SITECONSEIL{% endblock %} + +{% block admin_content %} +
+

Logs d'activite

+ +
+ + + + + + + + + + + + + + + {% for log in pagination %} + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
DateUtilisateurMethodeActionURLIPHMACPDF
{{ log.createdAt|date('d/m/Y H:i:s') }} + {% if log.user %} + {{ log.user.fullName }} + {% else %} + + {% endif %} + + + {{ log.method }} + + {{ log.action }}{{ log.url }}{{ log.ip }} + {% if hmacResults[log.id] %} + + {% else %} + + {% endif %} + + PDF +
Aucun log enregistre.
+
+ + {% if pagination.totalItemCount > 20 %} +
+ {{ knp_pagination_render(pagination) }} +
+ {% endif %} +
+{% endblock %} diff --git a/templates/admin/logs/pdf.html.twig b/templates/admin/logs/pdf.html.twig new file mode 100644 index 0000000..3119695 --- /dev/null +++ b/templates/admin/logs/pdf.html.twig @@ -0,0 +1,66 @@ + + + + + Log #{{ log.id }} - CRM SITECONSEIL + + + +

Rapport de log #{{ log.id }}

+

CRM SITECONSEIL - Genere le {{ "now"|date("d/m/Y H:i:s") }}

+ + + + + + + + + + + + + + +
Informations du log
ID{{ log.id }}
Date{{ log.createdAt|date('d/m/Y H:i:s') }}
Utilisateur + {% if log.user %} + {{ log.user.fullName }} ({{ log.user.email }}) + {% else %} + Non connecte + {% endif %} +
Methode HTTP{{ log.method }}
URL{{ log.url }}
Route{{ log.route }}
Action{{ log.action }}
Adresse IP{{ log.ip }}
+ +
+

+ {% if hmacValid %} + ✓ INTEGRITE VERIFIEE - DONNEES CONFORMES + {% else %} + ✗ INTEGRITE COMPROMISE - DONNEES ALTEREES + {% endif %} +

+

Signature HMAC SHA-256 :

+

{{ log.hmac }}

+
+ + + +