From aba456e5ca6f3f617c0682f5dd79f4632c252158 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Thu, 15 Jan 2026 20:08:04 +0100 Subject: [PATCH] =?UTF-8?q?```=20=E2=9C=A8=20feat(caddy):=20Am=C3=A9liore?= =?UTF-8?q?=20la=20s=C3=A9curit=C3=A9=20avec=20CSP=20et=20headers=20standa?= =?UTF-8?q?rds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ feat(templates): Met à jour le logo sur la page d'inscription réussie ✨ feat(knp_paginator): Ajoute la configuration pour le style Tailwind ✨ feat(audit_logs): Crée la page de traçabilité des actions ✨ feat(logs): Ajoute le contrôleur pour gérer les logs d'audit ✨ feat(AppLogger): Enregistre l'user agent dans les logs d'audit ✨ feat(AccountController): Supprime l'appel inutile de l'EventAdminCreate ✨ feat(AuditLogRepository): Récupère les logs en excluant les ROOT ✨ feat(base): Ajoute la structure de base pour le dashboard ``` --- ansible/templates/caddy.j2 | 30 +++- composer.json | 4 +- composer.lock | 2 +- config/packages/knp_paginator.yaml | 3 + migrations/Version20260115190139.php | 38 +++++ .../Dashboard/AccountController.php | 1 - src/Controller/Dashboard/LogsController.php | 76 +++++++++ src/Entity/AuditLog.php | 7 +- src/Logger/AppLogger.php | 2 +- src/Repository/AuditLogRepository.php | 66 +++++--- templates/dashboard/audit_logs.twig | 139 +++++++++++++++ templates/dashboard/base.twig | 159 ++++++++++-------- templates/security/unscribe_success.twig | 2 +- 13 files changed, 414 insertions(+), 115 deletions(-) create mode 100644 config/packages/knp_paginator.yaml create mode 100644 migrations/Version20260115190139.php create mode 100644 src/Controller/Dashboard/LogsController.php create mode 100644 templates/dashboard/audit_logs.twig diff --git a/ansible/templates/caddy.j2 b/ansible/templates/caddy.j2 index c1e245b..49c80ce 100644 --- a/ansible/templates/caddy.j2 +++ b/ansible/templates/caddy.j2 @@ -10,17 +10,29 @@ intranet.ludikevent.fr, signature.ludikevent.fr { max_size 100MB } - header { - # This prevents search engines from indexing the site - X-Robots-Tag "noindex, nofollow, nosnippet, noarchive" + header { + # Empêche l'indexation + X-Robots-Tag "noindex, nofollow, nosnippet, noarchive" - # Your existing Permissions-Policy - Permissions-Policy "accelerometer=(), autoplay=(), camera=(), clipboard-write=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), usb=(), vr=(), screen-wake-lock=(), xr-spatial-tracking=(), bluetooth=(), ambient-light-sensor=(), battery=(), gamepad=(), notifications=(), push=()" + # Content Security Policy (CSP) Finale + # auth.esy-web.dev ajouté dans : + # - script-src (si chargement de SDK auth) + # - connect-src (pour les requêtes d'authentification / token) + # - frame-src (si affichage d'une fenêtre de login en iframe) + Content-Security-Policy "default-src 'self'; \ + script-src 'self' 'unsafe-inline' https://sentry.esy-web.dev https://chat.esy-web.dev https://auth.esy-web.dev; \ + connect-src 'self' https://sentry.esy-web.dev https://chat.esy-web.dev https://auth.esy-web.dev; \ + frame-src 'self' https://chat.esy-web.dev https://auth.esy-web.dev; \ + style-src 'self' 'unsafe-inline' https://chat.esy-web.dev; \ + img-src 'self' data: https://chat.esy-web.dev; \ + font-src 'self' data:; \ + frame-ancestors 'none';" - # Recommended security headers for an intranet - X-Content-Type-Options "nosniff" - X-Frame-Options "DENY" - Referrer-Policy "strict-origin-when-cross-origin" + # Headers de sécurité standards + Permissions-Policy "accelerometer=(), autoplay=(), camera=(), clipboard-write=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), usb=(), vr=(), screen-wake-lock=(), xr-spatial-tracking=(), bluetooth=(), ambient-light-sensor=(), battery=(), gamepad=(), notifications=(), push=()" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" } php_fastcgi unix//run/php/php8.3-fpm.sock { diff --git a/composer.json b/composer.json index 1500612..dac982b 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "healey/robots": "^1.0.1", "imagine/imagine": "^1.5.2", "io-developer/php-whois": ">=4.1.10", - "knplabs/knp-paginator-bundle": "^6.10.0", + "knplabs/knp-paginator-bundle": "^6.10", "knpuniversity/oauth2-client-bundle": "^2.20", "lasserafn/php-initial-avatar-generator": "^4.5", "league/flysystem-aws-s3-v3": "^3.30.1", @@ -39,7 +39,7 @@ "ovh/ovh": ">=3.5", "pear/net_dns2": ">=2.0.7", "phpdocumentor/reflection-docblock": "^5.6.6", - "phpoffice/phpspreadsheet": ">=5.4", + "phpoffice/phpspreadsheet": "^5.4", "phpstan/phpdoc-parser": "^2.3.1", "presta/sitemap-bundle": "^4.2", "scheb/2fa-bundle": "^7.13", diff --git a/composer.lock b/composer.lock index c494ddf..0ee84b0 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": "4bb28dc935f256091e8b6e96cd1fbfa3", + "content-hash": "45482c705146a5e69d39c6e43bf018b1", "packages": [ { "name": "async-aws/core", diff --git a/config/packages/knp_paginator.yaml b/config/packages/knp_paginator.yaml new file mode 100644 index 0000000..6f425c0 --- /dev/null +++ b/config/packages/knp_paginator.yaml @@ -0,0 +1,3 @@ +knp_paginator: + template: + pagination: '@KnpPaginator/Pagination/tailwindcss_pagination.html.twig' diff --git a/migrations/Version20260115190139.php b/migrations/Version20260115190139.php new file mode 100644 index 0000000..f5f5446 --- /dev/null +++ b/migrations/Version20260115190139.php @@ -0,0 +1,38 @@ +addSql('ALTER TABLE audit_log ADD user_agent VARCHAR(255) NOT NULL'); + $this->addSql('ALTER TABLE audit_log ALTER account_id SET NOT NULL'); + $this->addSql('ALTER TABLE audit_log ALTER type TYPE VARCHAR(50)'); + $this->addSql('ALTER TABLE audit_log ALTER message TYPE TEXT'); + } + + 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 user_agent'); + $this->addSql('ALTER TABLE audit_log ALTER account_id DROP NOT NULL'); + $this->addSql('ALTER TABLE audit_log ALTER type TYPE VARCHAR(255)'); + $this->addSql('ALTER TABLE audit_log ALTER message TYPE VARCHAR(255)'); + } +} diff --git a/src/Controller/Dashboard/AccountController.php b/src/Controller/Dashboard/AccountController.php index d57acb6..76b38a5 100644 --- a/src/Controller/Dashboard/AccountController.php +++ b/src/Controller/Dashboard/AccountController.php @@ -27,7 +27,6 @@ class AccountController extends AbstractController { // Audit Log : On trace la consultation de la liste $appLogger->record('VIEW', 'Consultation de la liste des administrateurs'); - $eventDispatcher->dispatch(new EventAdminCreate($accountRepository->findAdmin()[0], $this->getUser())); return $this->render('dashboard/administrateur.twig', [ 'admins' => $accountRepository->findAdmin(), ]); diff --git a/src/Controller/Dashboard/LogsController.php b/src/Controller/Dashboard/LogsController.php new file mode 100644 index 0000000..977e1b8 --- /dev/null +++ b/src/Controller/Dashboard/LogsController.php @@ -0,0 +1,76 @@ +getQueryForUser($this->getUser()); + if ($request->query->get('extract')) { + return $this->generateExcelExport($query->getResult()); + } + $pagination = $paginator->paginate( + $query, /* la requête */ + $request->query->getInt('page', 1), /* numéro de page */ + 25 /* nombre d'éléments par page */ + ); + + return $this->render('dashboard/audit_logs.twig', [ + 'logs' => $pagination // On passe l'objet pagination à la place de l'array + ]); + } + + private function generateExcelExport(array $logs): StreamedResponse + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('Logs Intranet Ludikevent'); + + // En-têtes stylisés + $headers = ['Date', 'Heure', 'Admin', 'Email', 'Action', 'Message', 'URL', 'Navigateur/OS']; + foreach (range('A', 'G') as $i => $column) { + $sheet->setCellValue($column . '1', $headers[$i]); + $sheet->getStyle($column . '1')->getFont()->setBold(true); + } + + $row = 2; + /** @var AuditLog $log */ + foreach ($logs as $log) { + $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()); + $sheet->setCellValue('D' . $row, $log->getAccount()->getEmail()); + $sheet->setCellValue('E' . $row, $log->getType()); + $sheet->setCellValue('F' . $row, $log->getMessage()); + $sheet->setCellValue('G' . $row, $log->getPath()); + $sheet->setCellValue('H' . $row, $log->getUserAgent()); + $row++; + } + + $writer = new Xlsx($spreadsheet); + $response = new StreamedResponse(fn() => $writer->save('php://output')); + + $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 . '"'); + + return $response; + } +} diff --git a/src/Entity/AuditLog.php b/src/Entity/AuditLog.php index 06ae724..a1d0956 100644 --- a/src/Entity/AuditLog.php +++ b/src/Entity/AuditLog.php @@ -29,15 +29,19 @@ class AuditLog #[ORM\Column(length: 255)] private ?string $path = null; + #[ORM\Column(length: 255)] + private ?string $userAgent = 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) + public function __construct(Account $account, string $type, string $message, string $path,string $userAgent) { $this->account = $account; $this->type = $type; $this->message = $message; $this->path = $path; $this->actionAt = new \DateTimeImmutable(); + $this->userAgent = $userAgent; } // Uniquement des Getters (Pas de Setters = Pas de modification possible en PHP) @@ -47,4 +51,5 @@ class AuditLog public function getType(): ?string { return $this->type; } public function getMessage(): ?string { return $this->message; } public function getPath(): ?string { return $this->path; } + public function getUserAgent(): ?string{ return $this->userAgent;} } diff --git a/src/Logger/AppLogger.php b/src/Logger/AppLogger.php index 038d8ba..196c2ec 100644 --- a/src/Logger/AppLogger.php +++ b/src/Logger/AppLogger.php @@ -27,7 +27,7 @@ class AppLogger $path = $request ? $request->getRequestUri() : 'CLI/Internal'; // Création de l'objet immuable via le constructeur - $log = new AuditLog($user, $type, $message, $path); + $log = new AuditLog($user, $type, $message, $path,$request->headers->get('user-agent')); $this->entityManager->persist($log); $this->entityManager->flush(); diff --git a/src/Repository/AuditLogRepository.php b/src/Repository/AuditLogRepository.php index bb0a92b..6c7623f 100644 --- a/src/Repository/AuditLogRepository.php +++ b/src/Repository/AuditLogRepository.php @@ -5,10 +5,8 @@ namespace App\Repository; use App\Entity\AuditLog; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; +use Symfony\Component\Security\Core\User\UserInterface; -/** - * @extends ServiceEntityRepository - */ class AuditLogRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) @@ -16,28 +14,44 @@ class AuditLogRepository extends ServiceEntityRepository parent::__construct($registry, AuditLog::class); } - // /** - // * @return AuditLog[] Returns an array of AuditLog objects - // */ - // public function findByExampleField($value): array - // { - // return $this->createQueryBuilder('a') - // ->andWhere('a.exampleField = :val') - // ->setParameter('val', $value) - // ->orderBy('a.id', 'ASC') - // ->setMaxResults(10) - // ->getQuery() - // ->getResult() - // ; - // } + /** + * Récupère les logs en excluant les comptes ROLE_ROOT + */ +// src/Repository/AuditLogRepository.php - // public function findOneBySomeField($value): ?AuditLog - // { - // return $this->createQueryBuilder('a') - // ->andWhere('a.exampleField = :val') - // ->setParameter('val', $value) - // ->getQuery() - // ->getOneOrNullResult() - // ; - // } + + + public function findAllForUser(?UserInterface $user): array + { + $qb = $this->createQueryBuilder('l') + ->innerJoin('l.account', 'a') + ->orderBy('l.actionAt', 'DESC') + ->setMaxResults(100); + + // Si l'utilisateur n'est pas ROOT, on cache les actions des ROOT + if (!$user || !in_array('ROLE_ROOT', $user->getRoles())) { + // Astuce PostgreSQL pour DQL : on utilise une expression de comparaison + // qui force la lecture en texte sans utiliser le mot-clé CAST qui plante + $qb->andWhere('a.roles NOT LIKE :role') + ->setParameter('role', '%ROLE_ROOT%'); + } + + return $qb->getQuery()->getResult(); + } + + // src/Repository/AuditLogRepository.php + + public function getQueryForUser(?UserInterface $user) + { + $qb = $this->createQueryBuilder('l') + ->innerJoin('l.account', 'a') + ->orderBy('l.actionAt', 'DESC'); + + if (!$user || !in_array('ROLE_ROOT', $user->getRoles())) { + $qb->andWhere('a.roles NOT LIKE :role') + ->setParameter('role', '%ROLE_ROOT%'); + } + + return $qb->getQuery(); + } } diff --git a/templates/dashboard/audit_logs.twig b/templates/dashboard/audit_logs.twig new file mode 100644 index 0000000..d9d9f38 --- /dev/null +++ b/templates/dashboard/audit_logs.twig @@ -0,0 +1,139 @@ +{% extends 'dashboard/base.twig' %} + +{% block title %}Traçabilité des actions{% endblock %} + +{# BOUTON EXTRACTION DANS LE HEADER #} +{% block actions %} + + + + + Exporter XLSX + +{% endblock %} + +{% block body %} +
+ + {# Header du tableau avec statistiques rapides #} +
+
+

Journal d'Audit

+

Historique complet des interactions sur l'Intranet

+
+
+ + {{ logs.getTotalItemCount }} ENREGISTREMENTS + +
+
+ +
+ + + + + + + + + + + {% for log in logs %} + + + {# 1. DATE & HEURE #} + + + {# 2. ADMINISTRATEUR + USER AGENT #} + + + {# 3. BADGE ACTION #} + + + {# 4. MESSAGE ET CHEMIN #} + + + {% else %} + + + + {% endfor %} + +
HorodatageAdministrateur & AppareilActionDétails de l'activité
+
{{ log.actionAt|date('d/m/Y') }}
+
{{ log.actionAt|date('H:i:s') }}
+
+
+
+ {{ log.account.firstName|first|upper }}{{ log.account.name|first|upper }} +
+
+
+ {{ log.account.firstName }} {{ log.account.name }} + {% if 'ROLE_ROOT' in log.account.roles %} + Root + {% endif %} +
+
{{ 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 }} + +
+ {% endif %} +
+
+
+ {% 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' + } %} + + {{ log.type }} + + +
{{ log.message }}
+
+ URL : + + {{ log.path }} + +
+
+
Aucun log trouvé dans cette période.
+
+
+ + {# FOOTER & PAGINATION #} +
+
+
+ Page {{ logs.getCurrentPageNumber }} — Affichage de {{ logs|length }} logs +
+ +
+
+
+{% endblock %} diff --git a/templates/dashboard/base.twig b/templates/dashboard/base.twig index 153e016..4bcb884 100644 --- a/templates/dashboard/base.twig +++ b/templates/dashboard/base.twig @@ -3,110 +3,123 @@ - {% block title %}Administration{% endblock %} - LudikEvent + {% block title %}Administration{% endblock %} — Intranet Ludikevent {{ vite_asset('admin.js', {}) }} + + - +
- {# Overlay pour mobile #} - + {# SIDEBAR MODERNE #} +
- diff --git a/templates/security/unscribe_success.twig b/templates/security/unscribe_success.twig index 141cddd..39ae88e 100644 --- a/templates/security/unscribe_success.twig +++ b/templates/security/unscribe_success.twig @@ -8,7 +8,7 @@ {# Logo #}
- Ludikevent Logo + Ludikevent Logo
{# Icône de succès #}