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 }}

+
+ + + +