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 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 13:42:09 +01:00
parent 0709d01240
commit b342d0e50a
9 changed files with 148 additions and 102 deletions

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Profile;
use App\Repository\ProfileRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use function in_array;
#[AsCommand(
name: 'app:create-profile',
description: 'Create a new profile with the given credentials',
)]
class CreateProfileCommand extends Command
{
public function __construct(
private readonly ProfileRepository $profiles,
private readonly EntityManagerInterface $em,
private readonly UserPasswordHasherInterface $passwordHasher,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->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;
}
}