Squelette Symfony, entités Server et Action

This commit is contained in:
Yasser Boulayoune 2026-04-07 10:12:00 +02:00
commit 52ecb4204c
46 changed files with 4308 additions and 0 deletions

0
src/Controller/.gitignore vendored Normal file
View file

View file

@ -0,0 +1,77 @@
<?php
/**
* @file ActionLogController.php
* @brief Controleur pour la consultation du journal d'audit
*
* Permet aux opérateurs et admins de consulter l'historique
* des actions effectuées sur les serveurs. Les admins voient
* tout, les opérateurs voient uniquement leurs propres actions.
*
* @package App\Controller
* @author Yasser
*/
namespace App\Controller;
use App\Repository\ActionLogRepository;
use App\Repository\ServerRepository;
use App\Repository\UserRepository;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* @class ActionLogController
* @brief Affichage et filtrage du journal des actions SSH
*
* Accessible uniquement aux ROLE_OPERATOR et supérieur.
* Propose des filtres par serveur, utilisateur et plage de dates.
* La liste des utilisateurs dans les filtres est visible uniquement par les admins.
*/
#[Route('/logs')]
#[IsGranted('ROLE_OPERATOR')]
class ActionLogController extends AbstractController
{
/**
* @brief Liste paginée des logs avec filtres
*
* Recupere les logs depuis la base de donnée avec les filtres
* optionels passés en parametre GET. La pagination est gérée
* par KnpPaginatorBundle avec 20 entrées par page.
*
* @param Request $request La requete avec les parametres de filtre
* @param ActionLogRepository $logRepo Repository pour les logs
* @param ServerRepository $serverRepo Pour la liste des serveurs dans les filtres
* @param UserRepository $userRepo Pour la liste des utilisateurs (admins seulement)
* @param PaginatorInterface $paginator Le paginateur KnpPaginator
* @return Response La vue avec les logs paginés et les filtres
*/
#[Route('/', name: 'app_log_index')]
public function index(
Request $request,
ActionLogRepository $logRepo,
ServerRepository $serverRepo,
UserRepository $userRepo,
PaginatorInterface $paginator
): Response {
$serverId = $request->query->getInt('server') ?: null;
$userId = $request->query->getInt('user') ?: null;
$dateFrom = $request->query->get('date_from');
$dateTo = $request->query->get('date_to');
$query = $logRepo->findByFilters($serverId, $userId, $dateFrom, $dateTo)->getQuery();
$logs = $paginator->paginate($query, $request->query->getInt('page', 1), 20);
return $this->render('logs/index.html.twig', [
'logs' => $logs,
'servers' => $serverRepo->findAll(),
// la liste des users dans les filtres est reservée aux admins
'users' => $this->isGranted('ROLE_ADMIN') ? $userRepo->findAll() : [],
'filters' => compact('serverId', 'userId', 'dateFrom', 'dateTo'),
]);
}
}

View file

@ -0,0 +1,306 @@
<?php
/**
* @file AdminController.php
* @brief Controleur pour la gestion des utilisateurs par les administrateurs
*
* Permet aux admins de gerer les comptes utilisateurs : modifier les roles,
* activer/desactiver des comptes, changer email et mot de passe,
* et assigner des accés aux serveurs.
* Toutes les routes de ce controleur necesitent ROLE_ADMIN.
*
* @package App\Controller
* @author Yasser
*/
namespace App\Controller;
use App\Entity\ServerAccess;
use App\Entity\User;
use App\Form\AdminEmailType;
use App\Form\AdminPasswordChangeType;
use App\Form\UserType;
use App\Repository\ServerAccessRepository;
use App\Repository\ServerRepository;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* @class AdminController
* @brief Gestion des utilisateurs et de leurs accés aux serveurs
*
* Reservé aux administrateurs. Permet de:
* - Lister tous les utilisateurs
* - Modifier les roles, l'etat, l'email et le mot de passe d'un compte
* - Assigner/revoquer l'acces a des serveurs pour un operateur
* - Activer ou desactiver un compte utilisateur
* L'admin peut aussi editer son propre compte mais les roles sont grisés.
*/
#[Route('/admin')]
#[IsGranted('ROLE_ADMIN')]
class AdminController extends AbstractController
{
/**
* @brief Affiche la liste de tout les utilisateurs de l'application
*
* @param UserRepository $userRepo Repository des utilisateurs
* @return Response Vue avec la liste triée par date d'inscription (plus recent en premier)
*/
#[Route('/users', name: 'app_admin_users')]
public function users(UserRepository $userRepo): Response
{
return $this->render('admin/users.html.twig', [
'users' => $userRepo->findBy([], ['createdAt' => 'DESC']),
]);
}
/**
* @brief Edition d'un compte utilisateur (roles, statut actif)
*
* Contrairement a avant, un admin peut maintenant editer son propre compte.
* Dans ce cas on passe isSelf=true au template pour griser les roles/statut
* et empecher l'admin de se retirer ses propres privileges par accident.
*
* @param Request $request
* @param User $user L'utilisateur a modifier (resolu par Doctrine)
* @param EntityManagerInterface $em
* @param ServerRepository $serverRepo Pour afficher les serveurs disponibles
* @param ServerAccessRepository $accessRepo Pour afficher les acces existants
* @return Response Le formulaire d'edition ou redirection
*/
#[Route('/users/{id}/edit', name: 'app_admin_user_edit', requirements: ['id' => '\d+'])]
public function editUser(
Request $request,
User $user,
EntityManagerInterface $em,
ServerRepository $serverRepo,
ServerAccessRepository $accessRepo,
): Response {
$isSelf = ($user === $this->getUser());
// formulaire roles/statut - soumis seulement si pas soi meme
$form = $this->createForm(UserType::class, $user);
// formulaire email - onglet 2
$emailForm = $this->createForm(AdminEmailType::class, $user, [
'action' => $this->generateUrl('app_admin_user_change_email', ['id' => $user->getId()]),
]);
// formulaire mot de passe - onglet 3
$passwordForm = $this->createForm(AdminPasswordChangeType::class, null, [
'action' => $this->generateUrl('app_admin_user_change_password', ['id' => $user->getId()]),
]);
if (!$isSelf) {
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->flush();
$this->addFlash('success', 'Utilisateur mis a jour.');
return $this->redirectToRoute('app_admin_user_edit', ['id' => $user->getId()]);
}
}
$accesses = $accessRepo->findBy(['user' => $user]);
$assignedIds = array_map(fn($a) => $a->getServer()->getId(), $accesses);
$allServers = $serverRepo->findBy([], ['name' => 'ASC']);
return $this->render('admin/edit_user.html.twig', [
'form' => $form,
'emailForm' => $emailForm,
'passwordForm' => $passwordForm,
'user' => $user,
'accesses' => $accesses,
'allServers' => $allServers,
'assignedIds' => $assignedIds,
'isSelf' => $isSelf,
// onglet actif selon la query string pour rester sur le bon apres soumission
'activeTab' => $request->query->get('tab', 'roles'),
]);
}
/**
* @brief Change l'adresse email d'un utilisateur
*
* Route dediée pour le formulaire email de l'onglet "Email".
* Verifie que le nouvel email n'est pas deja utilisé par un autre compte
* avant de sauvegarder. L'admin peut changer son propre email aussi.
*
* @param Request $request
* @param User $user L'utilisateur dont on change l'email
* @param EntityManagerInterface $em
* @param UserRepository $userRepo Pour verifier l'unicité de l'email
* @return Response Redirection vers la page d'edition avec l'onglet email actif
*/
#[Route('/users/{id}/change-email', name: 'app_admin_user_change_email', methods: ['POST'], requirements: ['id' => '\d+'])]
public function changeUserEmail(
Request $request,
User $user,
EntityManagerInterface $em,
UserRepository $userRepo,
): Response {
$emailForm = $this->createForm(AdminEmailType::class, $user);
$emailForm->handleRequest($request);
if ($emailForm->isSubmitted() && $emailForm->isValid()) {
$newEmail = $emailForm->get('email')->getData();
// verifier que cet email est pas deja pris par quelqu'un d'autre
$existing = $userRepo->findOneBy(['email' => $newEmail]);
if ($existing && $existing->getId() !== $user->getId()) {
$this->addFlash('danger', 'Cet email est déjà utilisé par un autre compte.');
return $this->redirectToRoute('app_admin_user_edit', ['id' => $user->getId(), 'tab' => 'email']);
}
$em->flush();
$this->addFlash('success', 'Email mis a jour.');
} else {
$this->addFlash('danger', 'Email invalide.');
}
return $this->redirectToRoute('app_admin_user_edit', ['id' => $user->getId(), 'tab' => 'email']);
}
/**
* @brief Change le mot de passe d'un utilisateur (privilege admin)
*
* L'admin n'a pas besoin de connaitre l'ancien mot de passe.
* Le nouveau mot de passe est hashé avant d'etre persisté.
*
* @param Request $request
* @param User $user L'utilisateur dont on change le mot de passe
* @param EntityManagerInterface $em
* @param UserPasswordHasherInterface $passwordHasher
* @return Response Redirection vers la page d'edition avec l'onglet password actif
*/
#[Route('/users/{id}/change-password', name: 'app_admin_user_change_password', methods: ['POST'], requirements: ['id' => '\d+'])]
public function changeUserPassword(
Request $request,
User $user,
EntityManagerInterface $em,
UserPasswordHasherInterface $passwordHasher,
): Response {
$passwordForm = $this->createForm(AdminPasswordChangeType::class);
$passwordForm->handleRequest($request);
if ($passwordForm->isSubmitted() && $passwordForm->isValid()) {
$newPassword = $passwordForm->get('newPassword')->getData();
$user->setPassword($passwordHasher->hashPassword($user, $newPassword));
$em->flush();
$this->addFlash('success', 'Mot de passe mis a jour pour ' . $user->getPseudo() . '.');
} else {
$this->addFlash('danger', 'Formulaire invalide, verifie les champs.');
}
return $this->redirectToRoute('app_admin_user_edit', ['id' => $user->getId(), 'tab' => 'password']);
}
/**
* @brief Attribue l'accés a un serveur pour un operateur
*
* Cree un ServerAccess avec les credentials SSH specifique a cet operateur.
* Verifie le token CSRF et que l'acces n'existe pas deja pour eviter les doublons.
*
* @param Request $request
* @param User $user L'operateur qui recoit l'acces
* @param EntityManagerInterface $em
* @param ServerRepository $serverRepo
* @param ServerAccessRepository $accessRepo Pour verifier si l'acces existe déja
* @return Response Redirection vers la page d'edition de l'utilisateur
*/
#[Route('/users/{id}/access/grant', name: 'app_admin_grant_access', methods: ['POST'], requirements: ['id' => '\d+'])]
public function grantAccess(
Request $request,
User $user,
EntityManagerInterface $em,
ServerRepository $serverRepo,
ServerAccessRepository $accessRepo,
): Response {
if (!$this->isCsrfTokenValid('grant_access_' . $user->getId(), $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid token.');
return $this->redirectToRoute('app_admin_user_edit', ['id' => $user->getId()]);
}
$server = $serverRepo->find($request->request->get('server_id'));
if (!$server || $accessRepo->findOneBy(['server' => $server, 'user' => $user])) {
$this->addFlash('warning', 'Access already exists or server not found.');
return $this->redirectToRoute('app_admin_user_edit', ['id' => $user->getId()]);
}
$access = new ServerAccess();
$access->setServer($server)->setUser($user)
->setSshUser($request->request->get('ssh_user'))
->setSshPassword($request->request->get('ssh_password'));
$em->persist($access);
$em->flush();
$this->addFlash('success', $user->getPseudo() . ' can now access ' . $server->getName() . '.');
return $this->redirectToRoute('app_admin_user_edit', ['id' => $user->getId()]);
}
/**
* @brief Revoque l'acces d'un operateur a un serveur
*
* Supprime l'entité ServerAccess correspondante. Verifie le CSRF
* et que l'acces appartient bien a l'utilisateur en parametre.
*
* @param Request $request
* @param int $userId Id de l'utilisateur concerne
* @param int $accessId Id du ServerAccess a supprimer
* @param EntityManagerInterface $em
* @param ServerAccessRepository $accessRepo
* @return Response Redirection vers la page d'edition de l'utilisateur
*/
#[Route('/users/{userId}/access/{accessId}/revoke', name: 'app_admin_revoke_access', methods: ['POST'])]
public function revokeAccess(
Request $request,
int $userId,
int $accessId,
EntityManagerInterface $em,
ServerAccessRepository $accessRepo,
): Response {
$access = $accessRepo->find($accessId);
if ($access && $access->getUser()->getId() === $userId
&& $this->isCsrfTokenValid('revoke_access_' . $accessId, $request->request->get('_token'))) {
$em->remove($access);
$em->flush();
$this->addFlash('success', 'Access revoked.');
}
return $this->redirectToRoute('app_admin_user_edit', ['id' => $userId]);
}
/**
* @brief Active ou desactive un compte utilisateur
*
* Inverse l'etat isActive de l'utilisateur. Un admin ne peut pas
* se desactiver lui meme (protection contre les erreur).
* Le token CSRF est verifié avant de modifier l'etat.
*
* @param Request $request
* @param User $user L'utilisateur a activer/desactiver
* @param EntityManagerInterface $em
* @return Response Redirection vers la liste des utilisateurs
*/
#[Route('/users/{id}/toggle', name: 'app_admin_user_toggle', methods: ['POST'], requirements: ['id' => '\d+'])]
public function toggleUser(Request $request, User $user, EntityManagerInterface $em): Response
{
if ($user === $this->getUser()) {
$this->addFlash('error', 'You cannot deactivate yourself.');
return $this->redirectToRoute('app_admin_users');
}
if ($this->isCsrfTokenValid('toggle_user_' . $user->getId(), $request->request->get('_token'))) {
$user->setIsActive(!$user->isActive());
$em->flush();
$this->addFlash('success', 'User status updated.');
}
return $this->redirectToRoute('app_admin_users');
}
}

View file

@ -0,0 +1,162 @@
<?php
/**
* @file PasswordResetController.php
* @brief Controleur pour la reinitialisation de mot de passe par email
*
* Gere le flux complet de reset : demande via email, validation du token
* et definition du nouveau mot de passe. Utilise le champ resetToken
* deja present sur l'entité User (valide 1 heure).
*
* @package App\Controller
* @author Yasser
*/
namespace App\Controller;
use App\Form\ForgotPasswordType;
use App\Form\ResetPasswordType;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* @class PasswordResetController
* @brief Gestion du flux de reinitialisation de mot de passe
*
* Deux etapes:
* 1. L'utilisateur saisit son email → on lui envoie un lien avec un token unique
* 2. Il clique sur le lien → il saisit son nouveau mot de passe
*
* Le token expire apres 1 heure. On affiche toujours un message de succes
* meme si l'email n'existe pas (pour pas divulguer si un compte existe).
*/
class PasswordResetController extends AbstractController
{
/**
* @brief Etape 1 : formulaire de demande de reinitialisation
*
* Si l'utilisateur est deja connecté on le redirige.
* On genere un token aleatoire, on l'enregistre dans la base avec
* une date d'expiration et on envoie l'email avec le lien de reset.
* En cas d'email inconnu on affiche quand meme le message de succes
* pour pas permettre de deviner les emails enregistrés.
*
* @param Request $request
* @param UserRepository $userRepo
* @param EntityManagerInterface $em
* @param MailerInterface $mailer
* @return Response Le formulaire ou redirection
*/
#[Route('/forgot-password', name: 'app_forgot_password')]
public function forgotPassword(
Request $request,
UserRepository $userRepo,
EntityManagerInterface $em,
MailerInterface $mailer,
): Response {
if ($this->getUser()) {
return $this->redirectToRoute('app_home');
}
$form = $this->createForm(ForgotPasswordType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$email = $form->get('email')->getData();
$user = $userRepo->findOneBy(['email' => $email]);
// on envoie l'email uniquement si le compte existe
if ($user) {
$token = bin2hex(random_bytes(32));
$expiresAt = new \DateTimeImmutable('+1 hour');
$user->setResetToken($token);
$user->setResetTokenExpiresAt($expiresAt);
$em->flush();
$resetUrl = $this->generateUrl(
'app_reset_password',
['token' => $token],
UrlGeneratorInterface::ABSOLUTE_URL
);
$message = (new Email())
->from('noreply@servercontrol.local')
->to($user->getEmail())
->subject('ServerControl - Reinitialisation de mot de passe')
->html($this->renderView('emails/reset_password.html.twig', [
'user' => $user,
'resetUrl' => $resetUrl,
]));
$mailer->send($message);
}
// on affiche toujours le meme message pour pas divulguer si l'email existe
$this->addFlash('success', 'Si un compte correspond a cet email, vous allez recevoir un lien de reinitialisation.');
return $this->redirectToRoute('app_forgot_password');
}
return $this->render('security/forgot_password.html.twig', ['form' => $form]);
}
/**
* @brief Etape 2 : validation du token et saisie du nouveau mot de passe
*
* Verifie que le token existe en base et qu'il n'est pas expiré.
* Si valide, affiche le formulaire de nouveau mot de passe.
* Apres succes, le token est effacé et l'utilisateur est redirigé vers le login.
*
* @param string $token Le token de reset depuis l'URL
* @param Request $request
* @param UserRepository $userRepo
* @param EntityManagerInterface $em
* @param UserPasswordHasherInterface $passwordHasher
* @return Response Le formulaire de reset ou redirection
*/
#[Route('/reset-password/{token}', name: 'app_reset_password')]
public function resetPassword(
string $token,
Request $request,
UserRepository $userRepo,
EntityManagerInterface $em,
UserPasswordHasherInterface $passwordHasher,
): Response {
$user = $userRepo->findOneBy(['resetToken' => $token]);
// token invalide ou expiré
if (!$user || !$user->getResetTokenExpiresAt() || $user->getResetTokenExpiresAt() < new \DateTimeImmutable()) {
$this->addFlash('danger', 'Ce lien de reinitialisation est invalide ou a expiré.');
return $this->redirectToRoute('app_forgot_password');
}
$form = $this->createForm(ResetPasswordType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$newPassword = $form->get('plainPassword')->getData();
$user->setPassword($passwordHasher->hashPassword($user, $newPassword));
// on efface le token apres utilisation pour qu'il ne peut plus etre reutilisé
$user->setResetToken(null);
$user->setResetTokenExpiresAt(null);
$em->flush();
$this->addFlash('success', 'Votre mot de passe a bien été mis a jour. Vous pouvez vous connecter.');
return $this->redirectToRoute('app_login');
}
return $this->render('security/reset_password.html.twig', [
'form' => $form,
'token' => $token,
]);
}
}

View file

@ -0,0 +1,80 @@
<?php
/**
* @file ProfileController.php
* @brief Controleur pour la gestion du profil de l'utilisateur connecté
*
* Accessible a tout les utilisateurs connectés (ROLE_OPERATOR minimum).
* Permet de changer son propre mot de passe en fournisant l'ancien.
*
* @package App\Controller
* @author Yasser
*/
namespace App\Controller;
use App\Form\ChangePasswordType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* @class ProfileController
* @brief Gestion du profil et du mot de passe pour l'utilisateur connecté
*
* Tout les opérateurs et admins peuvent acceder a cette page pour changer
* leur propre mot de passe. L'admin a aussi acces via la page admin/users
* pour changer le mot de passe des autres.
*/
#[IsGranted('ROLE_OPERATOR')]
class ProfileController extends AbstractController
{
/**
* @brief Page de profil avec le formulaire de changement de mot de passe
*
* Verifie que l'ancien mot de passe saisi correspond bien au hash stocké
* en base avant d'autoriser le changement. Si incorrect, on affiche
* une erreur sans reveler d'informations supplementaires.
*
* @param Request $request
* @param UserPasswordHasherInterface $passwordHasher Pour verifier et hasher le mot de passe
* @param EntityManagerInterface $em
* @return Response La page de profil avec le formulaire
*/
#[Route('/profile', name: 'app_profile')]
public function profile(
Request $request,
UserPasswordHasherInterface $passwordHasher,
EntityManagerInterface $em,
): Response {
/** @var \App\Entity\User $user */
$user = $this->getUser();
$form = $this->createForm(ChangePasswordType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$currentPassword = $form->get('currentPassword')->getData();
$newPassword = $form->get('newPassword')->getData();
// verifier que l'ancien mot de passe est correcte avant de changer
if (!$passwordHasher->isPasswordValid($user, $currentPassword)) {
$this->addFlash('danger', 'Mot de passe actuel incorrect.');
return $this->redirectToRoute('app_profile');
}
$user->setPassword($passwordHasher->hashPassword($user, $newPassword));
$em->flush();
$this->addFlash('success', 'Votre mot de passe a bien été mis a jour.');
return $this->redirectToRoute('app_profile');
}
return $this->render('security/profile.html.twig', [
'form' => $form,
]);
}
}

View file

@ -0,0 +1,147 @@
<?php
/**
* @file SecurityController.php
* @brief Controleur gérant l'authentification et l'inscription des utilisateurs
*
* Prend en charge la page de connexion, deconnexion, inscription et
* la page d'attente pour les comptes non encore validé par un admin.
*
* @package App\Controller
* @author Yasser
*/
namespace App\Controller;
use App\Entity\User;
use App\Form\RegistrationFormType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
/**
* @class SecurityController
* @brief Gestion de l'authentification et des comptes utilisateurs
*
* Ce controleur gere toute la partie authentification de l'appli.
* Les nouveaux comptes sont crée avec ROLE_USER et doivent etre
* promu par un admin avant de pouvoir acceder aux serveurs.
*/
class SecurityController extends AbstractController
{
/**
* @brief Page d'accueil - redirige selon le statut de connexion
*
* Si connecté et operateur/admin : redirige vers la liste des serveurs.
* Si connecté mais pas encore validé : redirige vers la page d'attente.
* Si pas connecté : redirige vers le login.
*
* @return Response Redirection vers la bonne page
*/
#[Route('/', name: 'app_home')]
public function home(): Response
{
if ($this->getUser()) {
return $this->isGranted('ROLE_OPERATOR')
? $this->redirectToRoute('app_server_index')
: $this->redirectToRoute('app_pending');
}
return $this->redirectToRoute('app_login');
}
/**
* @brief Page d'attente pour les comptes non encore promu par un admin
*
* Affiché aux utilisateurs avec ROLE_USER uniquement.
* Si l'utilisateur est déja opérateur il est redirigé automatiquement.
*
* @return Response La page d'attente ou une redirection
*/
#[Route('/pending', name: 'app_pending')]
public function pending(): Response
{
if ($this->isGranted('ROLE_OPERATOR')) {
return $this->redirectToRoute('app_server_index');
}
return $this->render('security/pending.html.twig');
}
/**
* @brief Page de connexion
*
* Affiche le formulaire de login. Si l'utilisateur est déja connecté
* on le redirige directement vers la home page.
* Symfony gere la soumission du form via le firewall, pas ici.
*
* @param AuthenticationUtils $authenticationUtils Utilitaire Symfony pour recuperer les erreurs
* @return Response La page de login avec eventuellement un message d'erreur
*/
#[Route('/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
if ($this->getUser()) {
return $this->redirectToRoute('app_home');
}
return $this->render('security/login.html.twig', [
'last_username' => $authenticationUtils->getLastUsername(),
'error' => $authenticationUtils->getLastAuthenticationError(),
]);
}
/**
* @brief Route de deconnexion - géré automatiquement par Symfony
*
* Cette methode est vide car Symfony intercepte la route /logout
* via la configuration du firewall avant que le code soit executé.
*/
#[Route('/logout', name: 'app_logout')]
public function logout(): void {}
/**
* @brief Page et traitement du formulaire d'inscription
*
* Cree un nouveau compte avec ROLE_USER par defaut. Le mot de passe
* est hashé avec le PasswordHasher de Symfony avant d'etre persisté.
* Le compte devra etre validé par un admin pour avoir acces aux serveurs.
*
* @param Request $request La requete HTTP
* @param UserPasswordHasherInterface $passwordHasher Pour hasher le mot de passe
* @param EntityManagerInterface $em Pour persister l'utilisateur en base
* @return Response Le formulaire d'inscription ou une redirection apres succes
*/
#[Route('/register', name: 'app_register')]
public function register(
Request $request,
UserPasswordHasherInterface $passwordHasher,
EntityManagerInterface $em,
): Response {
if ($this->getUser()) {
return $this->redirectToRoute('app_home');
}
$user = new User();
$form = $this->createForm(RegistrationFormType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$user->setPassword(
$passwordHasher->hashPassword($user, $form->get('plainPassword')->getData())
);
$user->setRoles(['ROLE_USER']);
$em->persist($user);
$em->flush();
$this->addFlash('success', 'Account created! You can now log in.');
return $this->redirectToRoute('app_login');
}
return $this->render('security/register.html.twig', ['form' => $form]);
}
}

View file

@ -0,0 +1,471 @@
<?php
/**
* @file ServerController.php
* @brief Controleur principal pour la gestion des serveurs Linux distants
*
* Gere toutes les operations sur les serveurs : CRUD, actions SSH sur les services,
* terminal interactif, gestion des acces operateurs et des services systemd.
* Toutes les routes necesite au minimum ROLE_OPERATOR, certaines ROLE_ADMIN.
*
* @package App\Controller
* @author Yasser
*/
namespace App\Controller;
use App\Entity\Server;
use App\Entity\ServerAccess;
use App\Entity\Service;
use App\Form\ServerType;
use App\Repository\ServerAccessRepository;
use App\Repository\ServerRepository;
use App\Repository\UserRepository;
use App\Service\SshCommandService;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* @class ServerController
* @brief Controller principal de l'application pour la gestion des serveurs
*
* Regroupe toutes les actions liées aux serveurs. Les admins voient tout les
* serveurs, les opérateurs voient uniquement ceux auxquels ils ont acces.
* Les actions SSH (start/stop/restart) sont loggées via SshCommandService.
*/
#[Route('/server')]
#[IsGranted('ROLE_OPERATOR')]
class ServerController extends AbstractController
{
/**
* @brief Liste paginée des serveurs avec filtres de recherche
*
* Les admins voient tout les serveurs, les opérateurs seulement
* ceux qui leurs sont assignés via ServerAccess. La pagination
* est de 10 elements par page.
*
* @param Request $request Requete avec les parametres de filtre (search, status, page)
* @param ServerRepository $serverRepo
* @param PaginatorInterface $paginator KnpPaginator
* @return Response La vue avec la liste des serveurs
*/
#[Route('/', name: 'app_server_index')]
public function index(Request $request, ServerRepository $serverRepo, PaginatorInterface $paginator): Response
{
$search = $request->query->get('search');
$status = $request->query->get('status');
$currentUser = $this->getUser();
$query = $serverRepo->findByFilters($search, $status, $currentUser)->getQuery();
$servers = $paginator->paginate($query, $request->query->getInt('page', 1), 10);
return $this->render('server/index.html.twig', [
'servers' => $servers,
'search' => $search,
'status' => $status,
]);
}
/**
* @brief Formulaire de creation d'un nouveau serveur
*
* Apres la creation, on scan automatiquement les services systemd actifs
* sur le serveur via SSH et on les enregistre en base de donnée.
* Si le scan echoue (SSH KO) le serveur est quand meme crée.
*
* @param Request $request
* @param EntityManagerInterface $em
* @param SshCommandService $sshService Pour le scan automatique des services
* @return Response Le formulaire ou redirection apres succes
*/
#[Route('/new', name: 'app_server_new')]
#[IsGranted('ROLE_ADMIN')]
public function new(Request $request, EntityManagerInterface $em, SshCommandService $sshService): Response
{
$server = new Server();
$form = $this->createForm(ServerType::class, $server);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$server->setOwner($this->getUser());
$em->persist($server);
$em->flush();
// scan des services actifs sur le serveur apres l'ajout
foreach ($sshService->scanServices($server) as $serviceName) {
$service = new Service();
$service->setName($serviceName);
$service->setDisplayName(ucfirst($serviceName));
$service->setServer($server);
$em->persist($service);
}
$em->flush();
$this->addFlash('success', 'Serveur ajouté avec succès.');
return $this->redirectToRoute('app_server_index');
}
return $this->render('server/new.html.twig', ['form' => $form]);
}
/**
* @brief Page de detail d'un serveur avec ses services et acces
*
* Verifie que l'utilisateur courant a bien acces a ce serveur
* via assertServerAccess(). Passe aussi la liste des operateurs
* qui peuvent etre assignés (ceux pas encore assignés).
*
* @param Server $server Le serveur a afficher (resolu par ParamConverter)
* @param UserRepository $userRepo Pour trouver les operateurs assignables
* @return Response La page de detail du serveur
*/
#[Route('/{id}', name: 'app_server_show', requirements: ['id' => '\d+'])]
public function show(Server $server, UserRepository $userRepo): Response
{
$this->assertServerAccess($server);
$assignedIds = $server->getServerAccesses()->map(fn($a) => $a->getUser()->getId())->toArray();
$assignableUsers = $userRepo->findOperatorsExcluding($assignedIds);
return $this->render('server/show.html.twig', [
'server' => $server,
'assignableUsers' => $assignableUsers,
]);
}
/**
* @brief Formulaire d'edition d'un serveur
*
* Si le champ mot de passe SSH est laissé vide, on fait un refresh()
* pour pas ecraser le mot de passe existant en base.
*
* @param Request $request
* @param Server $server Le serveur a modifier
* @param EntityManagerInterface $em
* @return Response Le formulaire d'edition ou redirection
*/
#[Route('/{id}/edit', name: 'app_server_edit', requirements: ['id' => '\d+'])]
#[IsGranted('ROLE_ADMIN')]
public function edit(Request $request, Server $server, EntityManagerInterface $em): Response
{
$form = $this->createForm(ServerType::class, $server);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// si le mdp est vide on recharge l'anciene valeur pour pas l'effacer
if (!$form->get('sshPassword')->getData()) {
$em->refresh($server);
}
$em->flush();
$this->addFlash('success', 'Server updated successfully.');
return $this->redirectToRoute('app_server_show', ['id' => $server->getId()]);
}
return $this->render('server/edit.html.twig', ['form' => $form, 'server' => $server]);
}
/**
* @brief Suppression d'un serveur
*
* La suppression est en cascade sur les services, logs et acces associés
* (configuré dans les entités). Le token CSRF est verifié avant suppression.
*
* @param Request $request
* @param Server $server Le serveur a supprimer
* @param EntityManagerInterface $em
* @return Response Redirection vers la liste des serveurs
*/
#[Route('/{id}/delete', name: 'app_server_delete', methods: ['POST'], requirements: ['id' => '\d+'])]
#[IsGranted('ROLE_ADMIN')]
public function delete(Request $request, Server $server, EntityManagerInterface $em): Response
{
if ($this->isCsrfTokenValid('delete_server_' . $server->getId(), $request->request->get('_token'))) {
$em->remove($server);
$em->flush();
$this->addFlash('success', 'Server deleted.');
}
return $this->redirectToRoute('app_server_index');
}
/**
* @brief Execute une commande libre via le terminal SSH interactif
*
* Recoit une commande en POST et la transmet via SshCommandService.
* Si la commande contient 'sudo' et que le mot de passe est necessaire,
* retourne needs_sudo=true pour que le front affiche la modal de saisie.
*
* @param Request $request Requete avec 'command', optionnellement 'sudo_password'
* @param Server $server Le serveur cible
* @param SshCommandService $sshService
* @return JsonResponse Resultat de la commande avec output et exit_code
*/
#[Route('/{id}/terminal', name: 'app_server_terminal', methods: ['POST'], requirements: ['id' => '\d+'])]
public function terminal(Request $request, Server $server, SshCommandService $sshService): JsonResponse
{
$this->assertServerAccess($server);
if (!$this->isCsrfTokenValid('terminal_' . $server->getId(), $request->request->get('_token'))) {
return $this->json(['error' => 'Invalid CSRF token'], 403);
}
$command = trim($request->request->get('command', ''));
if ($command === '') {
return $this->json(['success' => true, 'output' => '', 'needs_sudo' => false, 'exit_code' => 0]);
}
$sudoPassword = $request->request->get('sudo_password') ?: null;
$currentUser = $this->getUser();
$result = $sshService->runCommand($server, $command, $sudoPassword, $currentUser);
return $this->json($result);
}
/**
* @brief Execute une action systemd (start/stop/restart/status) sur un service
*
* L'action et le nom du service sont passés en POST. L'action est
* loggée dans ActionLog peu importe le resultat (succes ou echec).
* Retourne needs_sudo si le mot de passe sudo est requis.
*
* @param Request $request Requete avec 'action', 'service', optionnellement 'sudo_password'
* @param Server $server Le serveur cible
* @param SshCommandService $sshService
* @return JsonResponse Statut, output et needs_sudo
*/
#[Route('/{id}/action', name: 'app_server_action', methods: ['POST'], requirements: ['id' => '\d+'])]
public function action(Request $request, Server $server, SshCommandService $sshService): JsonResponse
{
$this->assertServerAccess($server);
if (!$this->isCsrfTokenValid('server_action', $request->request->get('_token'))) {
return $this->json(['error' => 'Invalid CSRF token'], 403);
}
$action = $request->request->get('action');
$serviceName = $request->request->get('service');
if (!$action || !$serviceName) {
return $this->json(['error' => 'Missing parameters'], 400);
}
$sudoPassword = $request->request->get('sudo_password') ?: null;
$log = $sshService->executeAction($server, $action, $serviceName, $this->getUser(), $sudoPassword);
return $this->json([
'status' => $log->getStatus(),
'output' => $log->getOutput(),
'needs_sudo' => $log->getStatus() === 'needs_sudo',
]);
}
/**
* @brief Verifie le statut (active/inactive) de tout les services d'un serveur
*
* Utilisé par le front en AJAX pour mettre a jour les badges de statut
* sur la page de detail du serveur sans recharger la page.
*
* @param Server $server
* @param SshCommandService $sshService
* @return JsonResponse Tableau associatif nom_service => 'active'|'inactive'
*/
#[Route('/{id}/services/status', name: 'app_services_status', methods: ['GET'], requirements: ['id' => '\d+'])]
public function servicesStatus(Server $server, SshCommandService $sshService): JsonResponse
{
$this->assertServerAccess($server);
$names = $server->getServices()->map(fn($s) => $s->getName())->toArray();
if (empty($names)) {
return $this->json([]);
}
return $this->json($sshService->checkServicesStatus($server, $names));
}
/**
* @brief Verifie si le serveur est accessible via SSH et met a jour son statut
*
* Tente une connexion SSH avec un timeout court. Met a jour lastStatus
* en base et retourne 'online' ou 'offline'.
*
* @param Server $server
* @param SshCommandService $sshService
* @param EntityManagerInterface $em Pour persister le nouveau statut
* @return JsonResponse ['status' => 'online'|'offline']
*/
#[Route('/{id}/status', name: 'app_server_status', methods: ['GET'], requirements: ['id' => '\d+'])]
public function status(Server $server, SshCommandService $sshService, EntityManagerInterface $em): JsonResponse
{
$this->assertServerAccess($server);
$status = $sshService->checkStatus($server);
$server->setLastStatus($status);
$em->flush();
return $this->json(['status' => $status]);
}
/**
* @brief Ajoute un acces operateur a un serveur depuis la page du serveur
*
* Cree un ServerAccess pour l'utilisateur selectionné avec les credentials SSH
* fournis. Double check que l'acces n'existe pas deja avant de creer.
*
* @param Request $request
* @param int $serverId
* @param EntityManagerInterface $em
* @param ServerRepository $serverRepo
* @param UserRepository $userRepo
* @param ServerAccessRepository $accessRepo
* @return Response Redirection vers la page du serveur
*/
#[Route('/{serverId}/access/new', name: 'app_access_new', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function addAccess(Request $request, int $serverId, EntityManagerInterface $em, ServerRepository $serverRepo, UserRepository $userRepo, ServerAccessRepository $accessRepo): Response
{
$server = $serverRepo->find($serverId);
if (!$server) {
throw $this->createNotFoundException();
}
if ($this->isCsrfTokenValid('add_access_' . $serverId, $request->request->get('_token'))) {
$user = $userRepo->find($request->request->get('user_id'));
if ($user && !$accessRepo->findOneBy(['server' => $server, 'user' => $user])) {
$access = new ServerAccess();
$access->setServer($server);
$access->setUser($user);
$access->setSshUser($request->request->get('ssh_user'));
$access->setSshPassword($request->request->get('ssh_password'));
$em->persist($access);
$em->flush();
$this->addFlash('success', 'Access granted to ' . $user->getPseudo());
}
}
return $this->redirectToRoute('app_server_show', ['id' => $serverId]);
}
/**
* @brief Supprime un acces operateur depuis la page du serveur
*
* @param Request $request
* @param int $serverId Id du serveur (pour verifier la cohérence)
* @param int $accessId Id du ServerAccess a supprimer
* @param EntityManagerInterface $em
* @param ServerAccessRepository $accessRepo
* @return Response Redirection vers la page du serveur
*/
#[Route('/{serverId}/access/{accessId}/delete', name: 'app_access_delete', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function deleteAccess(Request $request, int $serverId, int $accessId, EntityManagerInterface $em, ServerAccessRepository $accessRepo): Response
{
$access = $accessRepo->find($accessId);
if ($access && $access->getServer()->getId() === $serverId
&& $this->isCsrfTokenValid('delete_access_' . $accessId, $request->request->get('_token'))) {
$em->remove($access);
$em->flush();
$this->addFlash('success', 'Access revoked.');
}
return $this->redirectToRoute('app_server_show', ['id' => $serverId]);
}
/**
* @brief Supprime un service systemd de la liste surveillée
*
* Ne supprime pas le service sur le serveur Linux, juste
* l'enregistrement en base qui indique qu'on le surveille.
*
* @param Request $request
* @param int $serverId
* @param int $serviceId Id du Service a supprimer
* @param EntityManagerInterface $em
* @param ServerRepository $serverRepo
* @return Response Redirection vers la page du serveur
*/
#[Route('/{serverId}/service/{serviceId}/delete', name: 'app_service_delete', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function deleteService(Request $request, int $serverId, int $serviceId, EntityManagerInterface $em, ServerRepository $serverRepo): Response
{
$server = $serverRepo->find($serverId);
if (!$server) {
throw $this->createNotFoundException();
}
if ($this->isCsrfTokenValid('delete_service_' . $serviceId, $request->request->get('_token'))) {
foreach ($server->getServices() as $service) {
if ($service->getId() === $serviceId) {
$em->remove($service);
$em->flush();
$this->addFlash('success', 'Service deleted.');
break;
}
}
}
return $this->redirectToRoute('app_server_show', ['id' => $serverId]);
}
/**
* @brief Ajoute manuellement un service systemd a surveiller
*
* L'admin peut ajouter un service qui n'a pas été detecté automatiquement
* lors du scan initial ou qui a été installé apres l'ajout du serveur.
*
* @param Request $request
* @param int $serverId
* @param EntityManagerInterface $em
* @param ServerRepository $serverRepo
* @return Response Redirection vers la page du serveur
*/
#[Route('/{serverId}/service/new', name: 'app_service_new', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function addService(Request $request, int $serverId, EntityManagerInterface $em, ServerRepository $serverRepo): Response
{
$server = $serverRepo->find($serverId);
if (!$server) {
throw $this->createNotFoundException();
}
if ($this->isCsrfTokenValid('add_service_' . $serverId, $request->request->get('_token'))) {
$service = new Service();
$service->setName($request->request->get('name'));
$service->setDisplayName($request->request->get('display_name'));
$service->setServer($server);
$em->persist($service);
$em->flush();
$this->addFlash('success', 'Service added.');
}
return $this->redirectToRoute('app_server_show', ['id' => $serverId]);
}
/**
* @brief Verifie que l'utilisateur courant a bien acces a ce serveur
*
* Les admins ont acces a tout les serveurs. Pour les opérateurs,
* on verifie qu'il existe un ServerAccess pour cet user et ce serveur.
* Lance une AccessDeniedException si l'acces est refusé.
*
* @param Server $server Le serveur dont on verifie l'acces
* @throws \Symfony\Component\Security\Core\Exception\AccessDeniedException
*/
private function assertServerAccess(Server $server): void
{
if ($this->isGranted('ROLE_ADMIN')) {
return;
}
foreach ($server->getServerAccesses() as $access) {
if ($access->getUser() === $this->getUser()) {
return;
}
}
throw $this->createAccessDeniedException('You do not have access to this server.');
}
}