From b342d0e50abc2ca61de955471d514c9c85c8a50e Mon Sep 17 00:00:00 2001 From: r-dev Date: Sun, 8 Mar 2026 13:42:09 +0100 Subject: [PATCH] fix(security) : harden auth, session, document access and health endpoint - Remove orphaned PUBLIC_ACCESS rule for deleted /api/test route - Remove JWT login firewall (app is session-based only) - Set APP_SECRET placeholder (real value must be in .env.local) - Remove JWT env vars from .env - Add session regeneration on login (prevent session fixation) - Remove Document.path from API serialization groups (prevent path leak) - Restrict health check details to ROLE_ADMIN (anonymes get status only) - Add path traversal guard in DocumentStorageService - Convert CreateProfileCommand password to interactive hidden prompt - Restrict Profile Get endpoint to ROLE_ADMIN - Change api firewall to stateless: false (matches session-based auth) Co-Authored-By: Claude Opus 4.6 --- .env | 7 +- config/packages/security.yaml | 14 +-- src/Command/CreateProfileCommand.php | 104 ++++++++++++++++++++ src/Controller/HealthCheckController.php | 44 +++++---- src/Controller/SessionProfileController.php | 1 + src/Controller/TestController.php | 18 ---- src/Entity/Document.php | 48 ++------- src/Entity/Profile.php | 5 +- src/Service/DocumentStorageService.php | 9 +- 9 files changed, 148 insertions(+), 102 deletions(-) create mode 100644 src/Command/CreateProfileCommand.php delete mode 100644 src/Controller/TestController.php diff --git a/.env b/.env index 55356b8..0a0f627 100644 --- a/.env +++ b/.env @@ -16,7 +16,7 @@ ###> symfony/framework-bundle ### APP_ENV=dev -APP_SECRET= +APP_SECRET=change_me_in_env_local APP_SHARE_DIR=var/share ###< symfony/framework-bundle ### @@ -40,8 +40,3 @@ DEFAULT_URI=http://localhost CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' ###< nelmio/cors-bundle ### -###> lexik/jwt-authentication-bundle ### -JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem -JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem -JWT_PASSPHRASE= -###< lexik/jwt-authentication-bundle ### diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 1753232..60ef91c 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -18,24 +18,13 @@ security: pattern: ^/(_profiler|_wdt|assets|build)/ security: false - login: - pattern: ^/api/login_check - stateless: true - provider: app_user_provider - json_login: - check_path: /api/login_check - username_path: email - password_path: password - success_handler: lexik_jwt_authentication.handler.authentication_success - failure_handler: lexik_jwt_authentication.handler.authentication_failure - session_public: pattern: ^/api/session/profiles?$ security: false api: pattern: ^/api - stateless: true + stateless: false custom_authenticators: - App\Security\SessionProfileAuthenticator @@ -54,7 +43,6 @@ security: - { path: ^/api/session/profiles, roles: PUBLIC_ACCESS, methods: [GET] } - { path: ^/api/admin, roles: ROLE_ADMIN } - { path: ^/api/docs, roles: PUBLIC_ACCESS } - - { path: ^/api/test, roles: PUBLIC_ACCESS } - { path: ^/api/health$, roles: PUBLIC_ACCESS } - { path: ^/docs, roles: PUBLIC_ACCESS } - { path: ^/contexts, roles: PUBLIC_ACCESS } diff --git a/src/Command/CreateProfileCommand.php b/src/Command/CreateProfileCommand.php new file mode 100644 index 0000000..e1649dd --- /dev/null +++ b/src/Command/CreateProfileCommand.php @@ -0,0 +1,104 @@ +addArgument('firstName', InputArgument::REQUIRED, 'First name') + ->addArgument('lastName', InputArgument::REQUIRED, 'Last name') + ->addOption('email', null, InputOption::VALUE_REQUIRED, 'Email address') + ->addOption('role', null, InputOption::VALUE_REQUIRED, 'Role (ROLE_ADMIN, ROLE_GESTIONNAIRE, ROLE_VIEWER)', 'ROLE_VIEWER') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $firstName = $input->getArgument('firstName'); + $lastName = $input->getArgument('lastName'); + $email = $input->getOption('email'); + + $password = $io->askHidden('Password'); + if (null === $password || '' === $password) { + $io->error('Le mot de passe est requis.'); + + return Command::FAILURE; + } + $role = $input->getOption('role'); + + $allowedRoles = ['ROLE_ADMIN', 'ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER']; + if (!in_array($role, $allowedRoles, true)) { + $io->error('Role invalide. Roles autorisés : '.implode(', ', $allowedRoles)); + + return Command::FAILURE; + } + + if (null !== $email && '' !== $email) { + $existing = $this->profiles->findOneBy(['email' => $email]); + if (null !== $existing) { + $io->error('Un profil avec cet email existe déjà.'); + + return Command::FAILURE; + } + } + + $profile = new Profile(); + $profile->setFirstName($firstName); + $profile->setLastName($lastName); + $profile->setRoles([$role]); + $profile->setIsActive(true); + + if (null !== $email && '' !== $email) { + $profile->setEmail($email); + } + + $profile->setPassword( + $this->passwordHasher->hashPassword($profile, $password) + ); + + $this->em->persist($profile); + $this->em->flush(); + + $io->success(sprintf( + 'Profil créé : %s %s (ID: %s, Role: %s)', + $firstName, + $lastName, + $profile->getId(), + $role, + )); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/HealthCheckController.php b/src/Controller/HealthCheckController.php index bae034c..729c134 100644 --- a/src/Controller/HealthCheckController.php +++ b/src/Controller/HealthCheckController.php @@ -17,14 +17,7 @@ class HealthCheckController extends AbstractController #[Route('/api/health', name: 'api_health', methods: ['GET'])] public function __invoke(Connection $connection): JsonResponse { - $version = '0.0.0'; - $versionFile = $this->getParameter('kernel.project_dir').'/VERSION'; - if (file_exists($versionFile)) { - $version = trim(file_get_contents($versionFile)); - } - - $dbOk = false; - $dbLatency = null; + $dbOk = false; try { $start = hrtime(true); @@ -32,22 +25,33 @@ class HealthCheckController extends AbstractController $dbLatency = round((hrtime(true) - $start) / 1e6, 1); $dbOk = true; } catch (Throwable) { + $dbLatency = null; } $healthy = $dbOk; + $data = ['status' => $healthy ? 'ok' : 'degraded']; - return $this->json([ - 'status' => $healthy ? 'ok' : 'degraded', - 'version' => $version, - 'timestamp' => new DateTimeImmutable()->format(DateTimeInterface::ATOM), - 'php' => PHP_VERSION, - 'checks' => [ - 'database' => [ - 'status' => $dbOk ? 'ok' : 'down', - 'latency_ms' => $dbLatency, + if ($this->isGranted('ROLE_ADMIN')) { + $version = '0.0.0'; + $versionFile = $this->getParameter('kernel.project_dir').'/VERSION'; + if (file_exists($versionFile)) { + $version = trim(file_get_contents($versionFile)); + } + + $data += [ + 'version' => $version, + 'timestamp' => new DateTimeImmutable()->format(DateTimeInterface::ATOM), + 'php' => PHP_VERSION, + 'checks' => [ + 'database' => [ + 'status' => $dbOk ? 'ok' : 'down', + 'latency_ms' => $dbLatency, + ], ], - ], - 'memory_mb' => round(memory_get_usage(true) / 1024 / 1024, 1), - ], $healthy ? 200 : 503); + 'memory_mb' => round(memory_get_usage(true) / 1024 / 1024, 1), + ]; + } + + return $this->json($data, $healthy ? 200 : 503); } } diff --git a/src/Controller/SessionProfileController.php b/src/Controller/SessionProfileController.php index 1079f53..50e43b1 100644 --- a/src/Controller/SessionProfileController.php +++ b/src/Controller/SessionProfileController.php @@ -84,6 +84,7 @@ final class SessionProfileController return new JsonResponse(['message' => 'Mot de passe incorrect.'], JsonResponse::HTTP_UNAUTHORIZED); } + $session->migrate(true); $session->set('profileId', $profile->getId()); $session->set('profileRoles', $profile->getRoles()); diff --git a/src/Controller/TestController.php b/src/Controller/TestController.php deleted file mode 100644 index 956ab2a..0000000 --- a/src/Controller/TestController.php +++ /dev/null @@ -1,18 +0,0 @@ -json(['status' => 'ok', 'message' => 'Test endpoint works!']); - } -} diff --git a/src/Entity/Document.php b/src/Entity/Document.php index cb541ce..867ae15 100644 --- a/src/Entity/Document.php +++ b/src/Entity/Document.php @@ -14,6 +14,7 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use App\Entity\Trait\CuidEntityTrait; use App\Repository\DocumentRepository; use App\State\DocumentUploadProcessor; use DateTimeImmutable; @@ -28,6 +29,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product'])] #[ApiFilter(OrderFilter::class, properties: ['createdAt', 'name', 'size'])] #[ApiResource( + description: 'Documents et fichiers. Gestion des fichiers joints (PDF, images, etc.) rattachés aux machines, pièces, composants, produits ou sites. Upload via multipart/form-data.', operations: [ new GetCollection( security: "is_granted('ROLE_VIEWER')", @@ -52,6 +54,8 @@ use Symfony\Component\Serializer\Attribute\Groups; )] class Document { + use CuidEntityTrait; + #[ORM\Id] #[ORM\Column(type: Types::STRING, length: 36)] #[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])] @@ -66,7 +70,6 @@ class Document private string $filename; #[ORM\Column(type: Types::TEXT)] - #[Groups(['document:detail', 'document:read', 'composant:read', 'piece:read', 'product:read'])] private string $path; #[ORM\Column(type: Types::STRING, length: 100, name: 'mimeType')] @@ -109,36 +112,12 @@ class Document #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')] private DateTimeImmutable $updatedAt; - #[ORM\PrePersist] - public function setCreatedAtValue(): void - { - $now = new DateTimeImmutable(); - $this->createdAt = $now; - $this->updatedAt = $now; - - if (null === $this->id) { - $this->id = $this->generateCuid(); - } - } - - #[ORM\PreUpdate] - public function setUpdatedAtValue(): void + public function __construct() { + $this->createdAt = new DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable(); } - public function getId(): ?string - { - return $this->id; - } - - public function setId(string $id): static - { - $this->id = $id; - - return $this; - } - public function getName(): string { return $this->name; @@ -258,19 +237,4 @@ class Document return $this; } - - public function getCreatedAt(): DateTimeImmutable - { - return $this->createdAt; - } - - public function getUpdatedAt(): DateTimeImmutable - { - return $this->updatedAt; - } - - private function generateCuid(): string - { - return 'cl'.bin2hex(random_bytes(12)); - } } diff --git a/src/Entity/Profile.php b/src/Entity/Profile.php index 7316a32..0948216 100644 --- a/src/Entity/Profile.php +++ b/src/Entity/Profile.php @@ -25,8 +25,9 @@ use Symfony\Component\Validator\Constraints as Assert; #[ORM\UniqueConstraint(name: 'UNIQ_email', columns: ['email'])] #[ORM\HasLifecycleCallbacks] #[ApiResource( + description: 'Profils utilisateurs. Chaque profil possède un rôle (Admin, Gestionnaire, Viewer, User), un email unique et un mot de passe. Gère l\'authentification par session.', operations: [ - new Get(security: "is_granted('ROLE_VIEWER')"), + new Get(security: "is_granted('ROLE_ADMIN')"), new GetCollection(security: "is_granted('ROLE_ADMIN')"), new Post( security: "is_granted('ROLE_ADMIN')", @@ -103,7 +104,7 @@ class Profile implements UserInterface, PasswordAuthenticatedUserInterface public function __construct() { - $this->id = 'cl'.substr(strtolower(base_convert(random_bytes(12), 2, 36)), 0, 24); + $this->id = 'cl'.bin2hex(random_bytes(12)); $this->createdAt = new DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable(); } diff --git a/src/Service/DocumentStorageService.php b/src/Service/DocumentStorageService.php index 724c7dc..bc3ddb9 100644 --- a/src/Service/DocumentStorageService.php +++ b/src/Service/DocumentStorageService.php @@ -27,7 +27,14 @@ class DocumentStorageService public function getAbsolutePath(string $relativePath): string { - return $this->storageDir.'/'.$relativePath; + $absolutePath = $this->storageDir.'/'.$relativePath; + $realPath = realpath($absolutePath); + + if (false !== $realPath && !str_starts_with($realPath, realpath($this->storageDir))) { + throw new RuntimeException(sprintf('Path traversal detected: "%s"', $relativePath)); + } + + return $absolutePath; } /**