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.');
}
}

View file

@ -0,0 +1,109 @@
<?php
/**
* @file AppFixtures.php
* @brief Fixtures Doctrine pour les données de demonstration et de test
*
* Cree un jeu de données initial pour tester l'application en local :
* deux utilisateurs (admin et operateur), deux serveurs de demonstration
* avec leurs services et quelques logs d'actions pour avoir du contenu.
*
* ATTENTION : Ces données sont uniquement pour le developpement/demo.
* Les mots de passe dans ce fichier sont juste des exemples de fixtures,
* les vrais mots de passe de prod ne doivent JAMAIS etre dans le code.
*
* @package App\DataFixtures
* @author Yasser
*/
namespace App\DataFixtures;
use App\Entity\ActionLog;
use App\Entity\Server;
use App\Entity\Service;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* @class AppFixtures
* @brief Jeu de données de demonstration pour ServerControl
*
* Pour charger les fixtures: php bin/console doctrine:fixtures:load
* ATTENTION en prod utiliser --append pour pas effacer les données existantes.
*/
class AppFixtures extends Fixture
{
/**
* @brief Constructeur - injecte le service de hachage de mot de passe
* @param UserPasswordHasherInterface $passwordHasher
*/
public function __construct(private readonly UserPasswordHasherInterface $passwordHasher) {}
/**
* @brief Charge les données de demonstration en base
*
* Cree: 1 admin, 1 operateur, 2 serveurs, 3 services et 3 logs d'actions.
* Les serveurs crées ici sont fictifs (IPs locales) donc le SSH ne marchera pas.
*
* @param ObjectManager $manager Le gestionnaire d'entités Doctrine
*/
public function load(ObjectManager $manager): void
{
$admin = new User();
$admin->setEmail('admin@servercontrol.local')
->setPseudo('Admin')
->setRoles(['ROLE_ADMIN'])
->setPassword($this->passwordHasher->hashPassword($admin, 'Admin1234!'));
$manager->persist($admin);
$operator = new User();
$operator->setEmail('operator@servercontrol.local')
->setPseudo('Operator')
->setRoles(['ROLE_OPERATOR'])
->setPassword($this->passwordHasher->hashPassword($operator, 'Operator1234!'));
$manager->persist($operator);
$server1 = new Server();
$server1->setName('Prod Web Server')
->setIpAddress('192.168.1.10')
->setSshPort(22)
->setSshUser('deploy')
->setSshPassword('secret123')
->setDescription('Main production web server')
->setLastStatus('unknown')
->setOwner($admin);
$manager->persist($server1);
$server2 = new Server();
$server2->setName('Database Server')
->setIpAddress('192.168.1.11')
->setSshPort(2222)
->setSshUser('ubuntu')
->setSshPassword('secret456')
->setDescription('PostgreSQL database server')
->setLastStatus('unknown')
->setOwner($admin);
$manager->persist($server2);
foreach ([['nginx', 'Nginx Web Server'], ['php8.3-fpm', 'PHP-FPM']] as [$name, $display]) {
$service = new Service();
$service->setName($name)->setDisplayName($display)->setServer($server1);
$manager->persist($service);
}
$pgService = new Service();
$pgService->setName('postgresql')->setDisplayName('PostgreSQL')->setServer($server2);
$manager->persist($pgService);
foreach ([['start', 'success', 'Service started.'], ['stop', 'failure', 'Connection refused'], ['restart', 'success', 'Service restarted.']] as [$action, $status, $output]) {
$log = new ActionLog();
$log->setActionType($action)->setStatus($status)->setOutput($output)
->setServer($server1)->setUser($operator);
$manager->persist($log);
}
$manager->flush();
}
}

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

131
src/Entity/ActionLog.php Normal file
View file

@ -0,0 +1,131 @@
<?php
/**
* @file ActionLog.php
* @brief Entité representant une entrée dans le journal d'audit
*
* Chaque action SSH effectuée (start, stop, restart, status d'un service
* ou commande terminal) est enregistrée ici pour assurer la tracabilité.
* Permet de savoir qui a fait quoi et quand sur quel serveur.
*
* @package App\Entity
* @author Yasser
*/
namespace App\Entity;
use App\Repository\ActionLogRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* @class ActionLog
* @brief Journal des actions effectuées sur les serveurs via SSH
*
* Stocke chaque action avec son type (start/stop/restart/status),
* son resultat (success/failure/needs_sudo), la sortie de la commande
* et l'utilisateur qui a déclenché l'action.
* L'utilisateur peut etre null si l'action est fait par le systeme.
*/
#[ORM\Entity(repositoryClass: ActionLogRepository::class)]
#[ORM\Table(name: 'action_log')]
class ActionLog
{
/**
* @var int|null Identifiant unique du log
*/
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(name: 'log_id')]
private ?int $id = null;
/**
* @var string|null Type d'action: 'start', 'stop', 'restart', 'status' ou 'command'
*/
#[ORM\Column(name: 'action_type', length: 20)]
private ?string $actionType = null;
/**
* @var string|null Statut de l'action: 'success', 'failure' ou 'needs_sudo'
*/
#[ORM\Column(name: 'status', length: 10)]
private ?string $status = null;
/**
* @var string|null Sortie brute de la commande SSH, peut etre null si needs_sudo
*/
#[ORM\Column(name: 'output', type: 'text', nullable: true)]
private ?string $output = null;
/**
* @var \DateTimeImmutable|null Date et heure d'execution de l'action
*/
#[ORM\Column(name: 'executed_at')]
private ?\DateTimeImmutable $executedAt = null;
/**
* @var User|null Utilisateur qui a effectué l'action (null = systeme)
*/
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'actionLogs')]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'user_id', nullable: true)]
private ?User $user = null;
/**
* @var Server|null Serveur sur lequel l'action a été effectuée
*/
#[ORM\ManyToOne(targetEntity: Server::class, inversedBy: 'actionLogs')]
#[ORM\JoinColumn(name: 'server_id', referencedColumnName: 'server_id', nullable: false)]
private ?Server $server = null;
/**
* @brief Constructeur - enregistre automatiquement l'heure d'execution
*/
public function __construct()
{
$this->executedAt = new \DateTimeImmutable();
}
/**
* @return int|null Identifiant du log
*/
public function getId(): ?int { return $this->id; }
/**
* @brief Retourne le type d'action effectué
* @return string|null 'start', 'stop', 'restart', 'status' ou 'command'
*/
public function getActionType(): ?string { return $this->actionType; }
/**
* @param string $actionType Le type d'action
* @return static
*/
public function setActionType(string $actionType): static { $this->actionType = $actionType; return $this; }
/**
* @brief Retourne le statut de l'action
* @return string|null 'success', 'failure' ou 'needs_sudo'
*/
public function getStatus(): ?string { return $this->status; }
/** @param string $status @return static */
public function setStatus(string $status): static { $this->status = $status; return $this; }
/** @return string|null La sortie de la commande SSH */
public function getOutput(): ?string { return $this->output; }
/** @param string|null $output @return static */
public function setOutput(?string $output): static { $this->output = $output; return $this; }
/**
* @brief Retourne la date d'execution du log
* @return \DateTimeImmutable|null
*/
public function getExecutedAt(): ?\DateTimeImmutable { return $this->executedAt; }
/** @return User|null L'utilisateur qui a effectué l'action */
public function getUser(): ?User { return $this->user; }
/** @param User|null $user @return static */
public function setUser(?User $user): static { $this->user = $user; return $this; }
/** @return Server|null Le serveur cible de l'action */
public function getServer(): ?Server { return $this->server; }
/** @param Server|null $server @return static */
public function setServer(?Server $server): static { $this->server = $server; return $this; }
}

230
src/Entity/Server.php Normal file
View file

@ -0,0 +1,230 @@
<?php
/**
* @file Server.php
* @brief Entité representant un serveur Linux distant geré par l'application
*
* Cette classe correspond a la table 'server' en base de donnée.
* Elle stock toutes les informations necessaires pour etablir une
* connexion SSH et gerer les services systemd du serveur.
*
* @package App\Entity
* @author Yasser
*/
namespace App\Entity;
use App\Repository\ServerRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @class Server
* @brief Entité Doctrine pour les serveurs distants
*
* Represente un serveur Linux administrable via SSH. Chaque serveur
* appartient a un proprietaire (User avec ROLE_ADMIN) et peut avoir
* plusieurs services systemd associées ainsi que des accés granulaires
* pour les opérateurs.
*/
#[ORM\Entity(repositoryClass: ServerRepository::class)]
#[ORM\Table(name: 'server')]
class Server
{
/**
* @var int|null Identifiant unique auto-incrementé du serveur
*/
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(name: 'server_id')]
private ?int $id = null;
/**
* @var string|null Nom lisible du serveur (ex: "Prod Web Server")
*/
#[ORM\Column(name: 'name', length: 100)]
#[Assert\NotBlank(message: 'The server name is required')]
#[Assert\Length(max: 100)]
private ?string $name = null;
/**
* @var string|null Adresse IP du serveur, supporte IPv4 et IPv6
*/
#[ORM\Column(name: 'ip_address', length: 45)]
#[Assert\NotBlank(message: 'The IP address is required')]
#[Assert\Ip(version: 'all', message: 'Please provide a valid IP address')]
private ?string $ipAddress = null;
/**
* @var int Port SSH du serveur, par defaut 22
*/
#[ORM\Column(name: 'ssh_port', type: 'smallint')]
#[Assert\NotBlank]
#[Assert\Range(min: 1, max: 65535)]
private int $sshPort = 22;
/**
* @var string|null Nom d'utilisateur SSH pour la connexion
*/
#[ORM\Column(name: 'ssh_user', length: 50)]
#[Assert\NotBlank(message: 'The SSH user is required')]
private ?string $sshUser = null;
/**
* @var string|null Mot de passe SSH stocké en clair (TODO: chiffrer)
*/
#[ORM\Column(name: 'ssh_password', type: 'text')]
#[Assert\NotBlank(message: 'The SSH password is required')]
private ?string $sshPassword = null;
/**
* @var string|null Description optionel du serveur
*/
#[ORM\Column(name: 'description', type: 'text', nullable: true)]
private ?string $description = null;
/**
* @var string Dernier statut connu: 'online', 'offline' ou 'unknown'
*/
#[ORM\Column(name: 'last_status', length: 10)]
private string $lastStatus = 'unknown';
/**
* @var \DateTimeImmutable|null Date de creation de l'enregistrement
*/
#[ORM\Column(name: 'created_at')]
private ?\DateTimeImmutable $createdAt = null;
/**
* @var User|null Utilisateur propriétaire du serveur (doit etre ROLE_ADMIN)
*/
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'servers')]
#[ORM\JoinColumn(name: 'owner_id', referencedColumnName: 'user_id', nullable: false)]
private ?User $owner = null;
/**
* @var Collection Liste des services systemd surveillé sur ce serveur
*/
#[ORM\OneToMany(targetEntity: Service::class, mappedBy: 'server', cascade: ['remove'])]
private Collection $services;
/**
* @var Collection Historique des actions effectuées sur ce serveur
*/
#[ORM\OneToMany(targetEntity: ActionLog::class, mappedBy: 'server', cascade: ['remove'])]
private Collection $actionLogs;
/**
* @var Collection Liste des accès operateurs configurés pour ce serveur
*/
#[ORM\OneToMany(targetEntity: ServerAccess::class, mappedBy: 'server', cascade: ['remove'])]
private Collection $serverAccesses;
/**
* @brief Constructeur - initialise les collections et la date de creation
*
* Les collections Doctrine doivent etre initialisées ici sinon
* on a des erreur lors du premier acces avant que Doctrine les charge.
*/
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
$this->services = new ArrayCollection();
$this->actionLogs = new ArrayCollection();
$this->serverAccesses = new ArrayCollection();
}
/**
* @brief Retourne l'identifiant du serveur
* @return int|null L'id en base de donnée, null si pas encore persisté
*/
public function getId(): ?int { return $this->id; }
/**
* @brief Retourne le nom du serveur
* @return string|null Le nom du serveur
*/
public function getName(): ?string { return $this->name; }
/**
* @brief Definit le nom du serveur
* @param string $name Nouveau nom a assigner
* @return static Pour le chaining de méthodes
*/
public function setName(string $name): static { $this->name = $name; return $this; }
/** @return string|null Adresse IP du serveur */
public function getIpAddress(): ?string { return $this->ipAddress; }
/**
* @param string $ipAddress Adresse IP (IPv4 ou IPv6)
* @return static
*/
public function setIpAddress(string $ipAddress): static { $this->ipAddress = $ipAddress; return $this; }
/** @return int Numero de port SSH (defaut 22) */
public function getSshPort(): int { return $this->sshPort; }
/** @param int $sshPort Port entre 1 et 65535 @return static */
public function setSshPort(int $sshPort): static { $this->sshPort = $sshPort; return $this; }
/** @return string|null Nom d'utilisateur pour la connexion SSH */
public function getSshUser(): ?string { return $this->sshUser; }
/** @param string $sshUser @return static */
public function setSshUser(string $sshUser): static { $this->sshUser = $sshUser; return $this; }
/** @return string|null Mot de passe SSH (attention donnée sensible) */
public function getSshPassword(): ?string { return $this->sshPassword; }
/** @param string $sshPassword @return static */
public function setSshPassword(string $sshPassword): static { $this->sshPassword = $sshPassword; return $this; }
/** @return string|null Description optionel du serveur */
public function getDescription(): ?string { return $this->description; }
/** @param string|null $description Peut etre null @return static */
public function setDescription(?string $description): static { $this->description = $description; return $this; }
/**
* @brief Retourne le dernier statut connu du serveur
* @return string 'online', 'offline' ou 'unknown'
*/
public function getLastStatus(): string { return $this->lastStatus; }
/**
* @brief Met a jour le statut du serveur apres une vérification SSH
* @param string $lastStatus Le nouveau statut a stocker
* @return static
*/
public function setLastStatus(string $lastStatus): static { $this->lastStatus = $lastStatus; return $this; }
/** @return \DateTimeImmutable|null Date de creation de cet enregistrement */
public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; }
/** @return User|null Le proprietaire administrateur du serveur */
public function getOwner(): ?User { return $this->owner; }
/**
* @param User|null $owner L'utilisateur propriétaire, doit avoir ROLE_ADMIN
* @return static
*/
public function setOwner(?User $owner): static { $this->owner = $owner; return $this; }
/**
* @brief Retourne la collection de services systemd du serveur
* @return Collection Collection d'objets Service
*/
public function getServices(): Collection { return $this->services; }
/**
* @brief Retourne l'historique des actions sur ce serveur
* @return Collection Collection d'objets ActionLog
*/
public function getActionLogs(): Collection { return $this->actionLogs; }
/**
* @brief Retourne les accés configurés pour les opérateurs
* @return Collection Collection d'objets ServerAccess
*/
public function getServerAccesses(): Collection { return $this->serverAccesses; }
}

View file

@ -0,0 +1,98 @@
<?php
/**
* @file ServerAccess.php
* @brief Entité representant l'accès d'un opérateur a un serveur specifique
*
* Permet d'assigner un opérateur a un serveur avec des identifiants SSH
* dédié (different de ceux de l'admin). La contrainte d'unicité empeche
* de donner plusieurs fois acces au meme user sur le meme serveur.
*
* @package App\Entity
* @author Yasser
*/
namespace App\Entity;
use App\Repository\ServerAccessRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* @class ServerAccess
* @brief Relation entre un opérateur et un serveur avec ses propres identifiants SSH
*
* Table de liaison enrichie entre User et Server. Chaque operateur
* peut avoir des credentials SSH different selon le serveur auquel
* il a acces. La contrainte uq_server_user evite les doublons.
*/
#[ORM\Entity(repositoryClass: ServerAccessRepository::class)]
#[ORM\Table(name: 'server_access')]
#[ORM\UniqueConstraint(name: 'uq_server_user', columns: ['server_id', 'user_id'])]
class ServerAccess
{
/**
* @var int|null Identifiant unique de cet accés
*/
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(name: 'access_id')]
private ?int $id = null;
/**
* @var Server|null Le serveur auquel l'opérateur a accés
* Supprimé en cascade si le serveur est supprimé
*/
#[ORM\ManyToOne(targetEntity: Server::class, inversedBy: 'serverAccesses')]
#[ORM\JoinColumn(name: 'server_id', referencedColumnName: 'server_id', nullable: false, onDelete: 'CASCADE')]
private ?Server $server = null;
/**
* @var User|null L'opérateur qui a l'accés au serveur
* Supprimé en cascade si l'utilisateur est supprimé
*/
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'serverAccesses')]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'user_id', nullable: false, onDelete: 'CASCADE')]
private ?User $user = null;
/**
* @var string|null Identifiant SSH propre a cet opérateur sur ce serveur
* Peut etre different de l'utilisateur SSH admin du serveur
*/
#[ORM\Column(name: 'ssh_user', length: 50)]
private ?string $sshUser = null;
/**
* @var string|null Mot de passe SSH propre a cet opérateur
*/
#[ORM\Column(name: 'ssh_password', type: 'text')]
private ?string $sshPassword = null;
/** @return int|null */
public function getId(): ?int { return $this->id; }
/**
* @brief Retourne le serveur cible de cet accés
* @return Server|null
*/
public function getServer(): ?Server { return $this->server; }
/** @param Server $server @return static */
public function setServer(Server $server): static { $this->server = $server; return $this; }
/**
* @brief Retourne l'opérateur qui possede cet accés
* @return User|null
*/
public function getUser(): ?User { return $this->user; }
/** @param User $user @return static */
public function setUser(User $user): static { $this->user = $user; return $this; }
/** @return string|null Identifiant SSH de l'opérateur */
public function getSshUser(): ?string { return $this->sshUser; }
/** @param string $sshUser @return static */
public function setSshUser(string $sshUser): static { $this->sshUser = $sshUser; return $this; }
/** @return string|null Mot de passe SSH de l'opérateur */
public function getSshPassword(): ?string { return $this->sshPassword; }
/** @param string $sshPassword @return static */
public function setSshPassword(string $sshPassword): static { $this->sshPassword = $sshPassword; return $this; }
}

93
src/Entity/Service.php Normal file
View file

@ -0,0 +1,93 @@
<?php
/**
* @file Service.php
* @brief Entité representant un service systemd surveillé sur un serveur
*
* Un service est associé a un Server et correspond a une unité systemd
* (ex: nginx, postgresql, php8.3-fpm). Il est detécté automatiquement
* lors de l'ajout du serveur ou ajouté manuellement par un admin.
*
* @package App\Entity
* @author Yasser
*/
namespace App\Entity;
use App\Repository\ServiceRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @class Service
* @brief Entité Doctrine pour un service systemd surveillé
*
* Represente une unité systemd (service) sur un serveur Linux.
* Le champ 'name' correspond au nom systemd (ex: 'nginx')
* et 'displayName' est le nom affiché dans l'interface.
*/
#[ORM\Entity(repositoryClass: ServiceRepository::class)]
#[ORM\Table(name: 'service')]
class Service
{
/**
* @var int|null Identifiant unique auto-incrementé du service
*/
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(name: 'service_id')]
private ?int $id = null;
/**
* @var string|null Nom systemd du service (ex: 'nginx', 'postgresql')
* Ce nom est utilisé dans les commandes systemctl
*/
#[ORM\Column(name: 'name', length: 50)]
#[Assert\NotBlank]
private ?string $name = null;
/**
* @var string|null Nom affiché dans l'interface utilisateur
*/
#[ORM\Column(name: 'display_name', length: 100)]
#[Assert\NotBlank]
private ?string $displayName = null;
/**
* @var Server|null Le serveur auquel appartient ce service
*/
#[ORM\ManyToOne(targetEntity: Server::class, inversedBy: 'services')]
#[ORM\JoinColumn(name: 'server_id', referencedColumnName: 'server_id', nullable: false)]
private ?Server $server = null;
/**
* @brief Retourne l'id du service
* @return int|null
*/
public function getId(): ?int { return $this->id; }
/**
* @brief Retourne le nom systemd du service
* @return string|null Ex: 'nginx', 'php8.3-fpm'
*/
public function getName(): ?string { return $this->name; }
/**
* @param string $name Nom systemd du service
* @return static
*/
public function setName(string $name): static { $this->name = $name; return $this; }
/** @return string|null Nom d'affichage dans l'interface */
public function getDisplayName(): ?string { return $this->displayName; }
/** @param string $displayName @return static */
public function setDisplayName(string $displayName): static { $this->displayName = $displayName; return $this; }
/**
* @brief Retourne le serveur associé a ce service
* @return Server|null
*/
public function getServer(): ?Server { return $this->server; }
/** @param Server|null $server @return static */
public function setServer(?Server $server): static { $this->server = $server; return $this; }
}

226
src/Entity/User.php Normal file
View file

@ -0,0 +1,226 @@
<?php
/**
* @file User.php
* @brief Entité utilisateur pour le systeme d'authentification Symfony
*
* Represente un utilisateur de l'application ServerControl.
* Implémente UserInterface et PasswordAuthenticatedUserInterface
* pour s'intégrer avec le systeme de securité de Symfony.
*
* @package App\Entity
* @author Yasser
*/
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @class User
* @brief Entité Doctrine representant un utilisateur de l'application
*
* Gere les 3 niveaux de roles : ROLE_USER (compte en attente),
* ROLE_OPERATOR (acces aux serveurs assignés) et ROLE_ADMIN (acces total).
* Le champ email est unique et sert d'identifiant de connexion.
* Un utilisateur peut etre desactivé par un admin sans suppression.
*/
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'app_user')]
#[UniqueEntity(fields: ['email'], message: 'There is already an account with this email')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
/**
* @var int|null Identifiant unique auto-incrementé de l'utilisateur
*/
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(name: 'user_id')]
private ?int $id = null;
/**
* @var string|null Adresse email unique, utilisée comme identifiant de connexion
*/
#[ORM\Column(name: 'email', length: 180, unique: true)]
private ?string $email = null;
/**
* @var string|null Pseudo affiché dans l'interface (nom d'affichage)
*/
#[ORM\Column(name: 'pseudo', length: 50)]
private ?string $pseudo = null;
/**
* @var array Tableau des roles Symfony (ex: ['ROLE_ADMIN'])
* ROLE_USER est toujours ajouté automatiquement par getRoles()
*/
#[ORM\Column(name: 'roles', type: 'json')]
private array $roles = [];
/**
* @var string|null Mot de passe hashé par Symfony PasswordHasher
*/
#[ORM\Column(name: 'password', length: 255)]
private ?string $password = null;
/**
* @var bool Indique si le compte est actif, permet de bloquer un utilisateur
*/
#[ORM\Column(name: 'is_active')]
private bool $isActive = true;
/**
* @var \DateTimeImmutable|null Date d'inscription de l'utilisateur
*/
#[ORM\Column(name: 'created_at')]
private ?\DateTimeImmutable $createdAt = null;
/**
* @var string|null Token temporaire pour la reinitialisation de mot de passe
*/
#[ORM\Column(name: 'reset_token', length: 100, nullable: true)]
private ?string $resetToken = null;
/**
* @var \DateTimeImmutable|null Date d'expiration du token de reset, null si aucun token actif
*/
#[ORM\Column(name: 'reset_token_expires_at', nullable: true)]
private ?\DateTimeImmutable $resetTokenExpiresAt = null;
/**
* @var Collection Serveurs dont cet utilisateur est proprietaire
*/
#[ORM\OneToMany(targetEntity: Server::class, mappedBy: 'owner')]
private Collection $servers;
/**
* @var Collection Logs de toutes les actions effectué par cet utilisateur
*/
#[ORM\OneToMany(targetEntity: ActionLog::class, mappedBy: 'user')]
private Collection $actionLogs;
/**
* @var Collection Acces aux serveurs assignés a cet utilisateur par un admin
*/
#[ORM\OneToMany(targetEntity: ServerAccess::class, mappedBy: 'user', cascade: ['remove'])]
private Collection $serverAccesses;
/**
* @brief Constructeur - initialise les collections et la date de creation
*/
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
$this->servers = new ArrayCollection();
$this->actionLogs = new ArrayCollection();
$this->serverAccesses = new ArrayCollection();
}
/**
* @brief Retourne l'identifiant de l'utilisateur
* @return int|null L'id, null si pas encore persisté en base
*/
public function getId(): ?int { return $this->id; }
/** @return string|null Email de l'utilisateur */
public function getEmail(): ?string { return $this->email; }
/** @param string $email @return static */
public function setEmail(string $email): static { $this->email = $email; return $this; }
/** @return string|null Pseudo de l'utilisateur */
public function getPseudo(): ?string { return $this->pseudo; }
/** @param string $pseudo @return static */
public function setPseudo(string $pseudo): static { $this->pseudo = $pseudo; return $this; }
/**
* @brief Retourne l'identifiant de connexion (email) pour Symfony Security
* @return string L'email comme identifiant unique
*/
public function getUserIdentifier(): string { return (string) $this->email; }
/**
* @brief Retourne les roles de l'utilisateur
*
* ROLE_USER est toujours inclus automatiquement pour garantir
* que tout les utilisateurs aient au moin un role de base.
*
* @return array Tableau de roles uniques
*/
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
/**
* @param array $roles Les roles a assigner (ex: ['ROLE_OPERATOR'])
* @return static
*/
public function setRoles(array $roles): static { $this->roles = $roles; return $this; }
/** @return string|null Mot de passe hashé */
public function getPassword(): ?string { return $this->password; }
/** @param string $password Le hash du mot de passe @return static */
public function setPassword(string $password): static { $this->password = $password; return $this; }
/**
* @brief Verifie si le compte est actif
* @return bool true si actif, false si désactivé par un admin
*/
public function isActive(): bool { return $this->isActive; }
/** @param bool $isActive @return static */
public function setIsActive(bool $isActive): static { $this->isActive = $isActive; return $this; }
/** @return \DateTimeImmutable|null Date d'inscription */
public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; }
/** @return string|null Token de reinitialisation de mot de passe */
public function getResetToken(): ?string { return $this->resetToken; }
/** @param string|null $resetToken null pour effacer le token @return static */
public function setResetToken(?string $resetToken): static { $this->resetToken = $resetToken; return $this; }
/** @return \DateTimeImmutable|null Date d'expiration du token */
public function getResetTokenExpiresAt(): ?\DateTimeImmutable { return $this->resetTokenExpiresAt; }
/**
* @param \DateTimeImmutable|null $resetTokenExpiresAt
* @return static
*/
public function setResetTokenExpiresAt(?\DateTimeImmutable $resetTokenExpiresAt): static
{
$this->resetTokenExpiresAt = $resetTokenExpiresAt;
return $this;
}
/**
* @brief Efface les données sensibles de l'utilisateur apres authentification
* Requis par l'interface UserInterface, on laisse vide car on utilise pas plainPassword
*/
public function eraseCredentials(): void {}
/** @return Collection Serveurs propriéte de cet utilisateur */
public function getServers(): Collection { return $this->servers; }
/** @return Collection Logs des actions de cet utilisateur */
public function getActionLogs(): Collection { return $this->actionLogs; }
/** @return Collection Accès aux serveurs assignés */
public function getServerAccesses(): Collection { return $this->serverAccesses; }
/**
* @brief Vérifie si l'utilisateur a le role admin
* @return bool true si ROLE_ADMIN est dans les roles
*/
public function isAdmin(): bool { return in_array('ROLE_ADMIN', $this->getRoles()); }
/**
* @brief Vérifie si l'utilisateur est au moins opérateur
* @return bool true si ROLE_OPERATOR ou ROLE_ADMIN
*/
public function isOperator(): bool { return $this->isAdmin() || in_array('ROLE_OPERATOR', $this->getRoles()); }
}

View file

@ -0,0 +1,50 @@
<?php
/**
* @file AdminEmailType.php
* @brief Formulaire pour modifier l'adresse email d'un utilisateur (admin seulement)
*
* Utilisé dans l'onglet "Email" de la page d'edition admin.
* La validation verifie que le format est correct mais pas l'unicité,
* ca c'est geré dans le controleur avec UniqueEntity.
*
* @package App\Form
* @author Yasser
*/
namespace App\Form;
use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @class AdminEmailType
* @brief Formulaire de changement d'email dans l'interface admin
*/
class AdminEmailType extends AbstractType
{
/**
* @brief Construit le formulaire avec juste le champ email
* @param FormBuilderInterface $builder
* @param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('email', EmailType::class, [
'label' => 'Adresse email',
'attr' => ['placeholder' => 'user@example.com'],
]);
}
/**
* @brief Lie le formulaire a l'entité User pour que l'email soit pre-rempli
* @param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => User::class]);
}
}

View file

@ -0,0 +1,64 @@
<?php
/**
* @file AdminPasswordChangeType.php
* @brief Formulaire pour qu'un admin change le mot de passe d'un utilisateur
*
* Pas besoin de l'ancien mot de passe car l'admin a les privileges pour
* forcer le changement. Utilisé dans l'onglet "Mot de passe" de la page admin.
*
* @package App\Form
* @author Yasser
*/
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* @class AdminPasswordChangeType
* @brief Formulaire admin pour forcer le nouveau mot de passe d'un compte
*
* Contrairement a ChangePasswordType, on ne demande pas le mot de passe
* actuel car l'admin n'as pas besoin de le connaitre pour le changer.
*/
class AdminPasswordChangeType extends AbstractType
{
/**
* @brief Construit le formulaire avec un champ de nouveau mot de passe doublé
* @param FormBuilderInterface $builder
* @param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('newPassword', RepeatedType::class, [
'type' => PasswordType::class,
'mapped' => false,
'first_options' => ['label' => 'Nouveau mot de passe'],
'second_options' => ['label' => 'Confirmer le mot de passe'],
'invalid_message' => 'Les mots de passe ne correspondent pas.',
'constraints' => [
new NotBlank(message: 'Veuillez saisir un mot de passe'),
new Length(
min: 8,
minMessage: 'Le mot de passe doit faire au moin {{ limit }} caractères'
),
],
]);
}
/**
* @brief Pas de data_class, le mot de passe est traité manuellement dans le controleur
* @param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}

View file

@ -0,0 +1,81 @@
<?php
/**
* @file ChangePasswordType.php
* @brief Formulaire pour qu'un utilisateur change son propre mot de passe
*
* Contrairement au formulaire admin, on exige l'ancien mot de passe
* pour verifier que c'est bien le proprietaire du compte qui fait la demande.
* La validation de currentPassword est faite dans le controleur via PasswordHasher.
*
* @package App\Form
* @author Yasser
*/
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* @class ChangePasswordType
* @brief Formulaire de changement de mot de passe pour l'utilisateur connecté
*
* Demande l'ancien mot de passe en premier pour eviter qu'une
* personne non authorisée puisse changer le mot de passe si la
* session est laissée ouverte sur un poste partagé.
*/
class ChangePasswordType extends AbstractType
{
/**
* @brief Construit le formulaire avec l'ancien et le nouveau mot de passe
* @param FormBuilderInterface $builder
* @param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('currentPassword', PasswordType::class, [
'label' => 'Mot de passe actuel',
'mapped' => false,
'attr' => ['autocomplete' => 'current-password'],
'constraints' => [
new NotBlank(message: 'Veuillez saisir votre mot de passe actuel'),
],
])
->add('newPassword', RepeatedType::class, [
'type' => PasswordType::class,
'mapped' => false,
'first_options' => [
'label' => 'Nouveau mot de passe',
'attr' => ['autocomplete' => 'new-password'],
],
'second_options' => [
'label' => 'Confirmer le nouveau mot de passe',
'attr' => ['autocomplete' => 'new-password'],
],
'invalid_message' => 'Les mots de passe ne correspondent pas.',
'constraints' => [
new NotBlank(message: 'Veuillez saisir un nouveau mot de passe'),
new Length(
min: 8,
minMessage: 'Le mot de passe doit faire au moin {{ limit }} caractères'
),
],
]);
}
/**
* @brief Pas de data_class car tout les champs sont unmapped
* @param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}

View file

@ -0,0 +1,54 @@
<?php
/**
* @file ForgotPasswordType.php
* @brief Formulaire pour demander la reinitialisation de mot de passe
*
* Contient uniquement un champ email. Le systeme envoi un lien
* de reset si l'adresse correspond a un compte existant.
*
* @package App\Form
* @author Yasser
*/
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* @class ForgotPasswordType
* @brief Formulaire de saisie de l'email pour le reset de mot de passe
*/
class ForgotPasswordType extends AbstractType
{
/**
* @brief Construit le formulaire avec uniquement le champ email
* @param FormBuilderInterface $builder
* @param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('email', EmailType::class, [
'label' => 'Votre adresse email',
'attr' => ['placeholder' => 'exemple@email.com'],
'constraints' => [
new NotBlank(message: 'Please enter your email'),
new Email(message: 'Please enter a valid email'),
],
]);
}
/**
* @brief Pas de data_class car on verifie l'email manuellement dans le controleur
* @param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}

View file

@ -0,0 +1,75 @@
<?php
/**
* @file RegistrationFormType.php
* @brief Formulaire d'inscription pour les nouveaux utilisateurs
*
* Permet de créer un nouveau compte avec email, pseudo et mot de passe.
* Le champ password utilise RepeatedType pour la confirmation du mot de passe.
* La validation du mot de passe est faite via les constraints Symfony.
*
* @package App\Form
* @author Yasser
*/
namespace App\Form;
use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* @class RegistrationFormType
* @brief Formulaire d'inscription des nouveaux utilisateurs
*
* Le champ plainPassword est en 'mapped' => false car il n'est pas
* directement dans l'entité User. Le controleur recupere sa valeur
* manuellement et hash le mot de passe avant de le sauvegarder.
*/
class RegistrationFormType extends AbstractType
{
/**
* @brief Construit le formulaire avec email, pseudo et mot de passe (avec confirmation)
*
* @param FormBuilderInterface $builder
* @param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class, ['label' => 'Email'])
->add('pseudo', TextType::class, [
'label' => 'Pseudo',
'constraints' => [
new NotBlank(message: 'Please enter a pseudo'),
new Length(min: 2, max: 50),
],
])
->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'mapped' => false,
'first_options' => ['label' => 'Password'],
'second_options' => ['label' => 'Confirm Password'],
'constraints' => [
new NotBlank(message: 'Please enter a password'),
new Length(min: 8, minMessage: 'Password must be at least {{ limit }} characters'),
],
]);
}
/**
* @brief Lie le formulaire a l'entité User
* @param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => User::class]);
}
}

View file

@ -0,0 +1,64 @@
<?php
/**
* @file ResetPasswordType.php
* @brief Formulaire de saisie du nouveau mot de passe apres le clic sur le lien de reset
*
* Utilise RepeatedType pour demander la confirmation du nouveau mot de passe.
* Le champ n'est pas mappé directement sur l'entité User.
*
* @package App\Form
* @author Yasser
*/
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* @class ResetPasswordType
* @brief Formulaire de definition du nouveau mot de passe
*
* Le champ est un RepeatedType donc l'utilisateur doit saisir
* le mot de passe deux fois pour confirmer. Minimum 8 caracteres.
*/
class ResetPasswordType extends AbstractType
{
/**
* @brief Construit le formulaire avec le champ mot de passe x2
* @param FormBuilderInterface $builder
* @param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'mapped' => false,
'first_options' => ['label' => 'New Password'],
'second_options' => ['label' => 'Confirm Password'],
'invalid_message' => 'The passwords do not match.',
'constraints' => [
new NotBlank(message: 'Please enter a password'),
new Length(
min: 8,
minMessage: 'Your password must be at least {{ limit }} characters'
),
],
]);
}
/**
* @brief Pas de data_class, le mot de passe est traité manuellement
* @param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}

61
src/Form/ServerType.php Normal file
View file

@ -0,0 +1,61 @@
<?php
/**
* @file ServerType.php
* @brief Formulaire Symfony pour la creation et l'edition d'un serveur
*
* @package App\Form
* @author Yasser
*/
namespace App\Form;
use App\Entity\Server;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @class ServerType
* @brief Formulaire de creation/edition d'un serveur Linux
*
* Le champ sshPassword est en 'always_empty' = false pour que la valeur
* actuelle soit pre-remplie lors de l'edition. Si laissé vide en edition
* le controleur fait un refresh() pour garder l'ancien mot de passe.
*/
class ServerType extends AbstractType
{
/**
* @brief Construit les champs du formulaire serveur
*
* @param FormBuilderInterface $builder
* @param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, ['label' => 'Server Name'])
->add('ipAddress', TextType::class, ['label' => 'IP Address'])
->add('sshPort', IntegerType::class, ['label' => 'SSH Port', 'data' => 22])
->add('sshUser', TextType::class, ['label' => 'SSH User'])
->add('sshPassword', PasswordType::class, [
'label' => 'SSH Password',
'always_empty' => false,
'required' => false,
])
->add('description', TextareaType::class, ['label' => 'Description', 'required' => false]);
}
/**
* @brief Configure les options du formulaire, lie l'entité Server
* @param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => Server::class]);
}
}

63
src/Form/UserType.php Normal file
View file

@ -0,0 +1,63 @@
<?php
/**
* @file UserType.php
* @brief Formulaire d'edition d'un utilisateur par un administrateur
*
* Permet de modifier les roles et l'etat actif/inactif d'un compte.
* Utilisé uniquement dans l'interface d'administration.
*
* @package App\Form
* @author Yasser
*/
namespace App\Form;
use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @class UserType
* @brief Formulaire admin pour modifier les roles et le statut d'un utilisateur
*
* Contient uniquement les champs que l'admin peut modifier : les roles
* et l'etat actif. L'email et le pseudo ne sont pas modifiables ici
* pour simplifier la gestion et eviter les confusions.
*/
class UserType extends AbstractType
{
/**
* @brief Construit le formulaire avec les champs roles et isActive
*
* @param FormBuilderInterface $builder
* @param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('roles', ChoiceType::class, [
'choices' => [
'User' => 'ROLE_USER',
'Operator' => 'ROLE_OPERATOR',
'Admin' => 'ROLE_ADMIN',
],
'multiple' => true,
'expanded' => true,
'label' => 'Roles',
])
->add('isActive', CheckboxType::class, ['label' => 'Active', 'required' => false]);
}
/**
* @brief Lie le formulaire a l'entité User
* @param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => User::class]);
}
}

26
src/Kernel.php Normal file
View file

@ -0,0 +1,26 @@
<?php
/**
* @file Kernel.php
* @brief Noyau principal de l'application Symfony
*
* Point d'entree du framework Symfony. Utilise MicroKernelTrait
* pour la configuration simplifiée (un seul fichier de config).
*
* @package App
* @author Yasser
*/
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
/**
* @class Kernel
* @brief Kernel de l'application, genéré automatiquement par Symfony
*/
class Kernel extends BaseKernel
{
use MicroKernelTrait;
}

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

View file

@ -0,0 +1,66 @@
<?php
namespace App\Repository;
use App\Entity\ActionLog;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @class ActionLogRepository
* @brief Repository pour les logs d'actions SSH avec filtres
*
* @extends ServiceEntityRepository<ActionLog>
*/
class ActionLogRepository extends ServiceEntityRepository
{
/**
* @brief Constructeur standard du repository Doctrine
* @param ManagerRegistry $registry
*/
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ActionLog::class);
}
/**
* @brief Construit une requete filtrée pour l'historique des actions
*
* Tout les filtres sont optionels, si aucun filtre n'est fourni
* on retourne tout les logs trié par date decroissante.
* Les jointures sur user et server sont faites en LEFT JOIN pour
* inclure les logs sans utilisateur (actions systeme).
*
* @param int|null $serverId Filtrer par serveur
* @param int|null $userId Filtrer par utilisateur
* @param string|null $dateFrom Date de debut (format Y-m-d)
* @param string|null $dateTo Date de fin (format Y-m-d), inclus jusqu'a 23:59:59
* @return QueryBuilder Le query builder pour la pagination
*/
public function findByFilters(?int $serverId, ?int $userId, ?string $dateFrom, ?string $dateTo): QueryBuilder
{
$qb = $this->createQueryBuilder('a')
->leftJoin('a.user', 'u')
->leftJoin('a.server', 's')
->addSelect('u', 's')
->orderBy('a.executedAt', 'DESC');
if ($serverId) {
$qb->andWhere('s.id = :serverId')->setParameter('serverId', $serverId);
}
if ($userId) {
$qb->andWhere('u.id = :userId')->setParameter('userId', $userId);
}
if ($dateFrom) {
$qb->andWhere('a.executedAt >= :dateFrom')
->setParameter('dateFrom', new \DateTimeImmutable($dateFrom));
}
if ($dateTo) {
$qb->andWhere('a.executedAt <= :dateTo')
->setParameter('dateTo', new \DateTimeImmutable($dateTo . ' 23:59:59'));
}
return $qb;
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Repository;
use App\Entity\ServerAccess;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @class ServerAccessRepository
* @brief Repository pour la gestion des acces operateurs aux serveurs
*
* Utilise principalement findOneBy(['server' => ..., 'user' => ...])
* pour verifier si un acces existe deja avant d'en creer un nouveau.
*
* @extends ServiceEntityRepository<ServerAccess>
*/
class ServerAccessRepository extends ServiceEntityRepository
{
/**
* @brief Constructeur standard du repository Doctrine
* @param ManagerRegistry $registry
*/
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ServerAccess::class);
}
}

View file

@ -0,0 +1,73 @@
<?php
/**
* @file ServerRepository.php
* @brief Repository Doctrine pour les requetes sur les serveurs
*
* @package App\Repository
* @author Yasser
*/
namespace App\Repository;
use App\Entity\Server;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @class ServerRepository
* @brief Repository pour l'entité Server avec methodes de recherche personnalisées
*
* @extends ServiceEntityRepository<Server>
*/
class ServerRepository extends ServiceEntityRepository
{
/**
* @brief Constructeur standard du repository Doctrine
* @param ManagerRegistry $registry
*/
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Server::class);
}
/**
* @brief Construit une requete filtrée pour la liste des serveurs
*
* Filtre par nom/IP (recherche partielle), statut et utilisateur courant.
* Pour les opérateurs (non admin), filtre avec un JOIN sur ServerAccess
* pour n'afficher que les serveurs auxquels ils ont acces.
*
* @param string|null $search Terme de recherche sur le nom ou l'IP
* @param string|null $status Filtre sur le statut ('online', 'offline', 'unknown')
* @param User|null $currentUser L'utilisateur courant pour filtrer selon son role
* @return QueryBuilder Le query builder pret a etre paginé
*/
public function findByFilters(?string $search, ?string $status, ?User $currentUser = null): QueryBuilder
{
$qb = $this->createQueryBuilder('s')
->leftJoin('s.owner', 'o')
->addSelect('o')
->orderBy('s.createdAt', 'DESC');
if ($currentUser !== null && !$currentUser->isAdmin()) {
$qb->innerJoin('s.serverAccesses', 'sa')
->andWhere('sa.user = :currentUser')
->setParameter('currentUser', $currentUser);
}
if ($search) {
$qb->andWhere('s.name LIKE :search OR s.ipAddress LIKE :search')
->setParameter('search', '%' . $search . '%');
}
if ($status && in_array($status, ['online', 'offline', 'unknown'])) {
$qb->andWhere('s.lastStatus = :status')
->setParameter('status', $status);
}
return $qb;
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Repository;
use App\Entity\Service;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @class ServiceRepository
* @brief Repository pour l'entité Service (services systemd)
*
* Pas de methodes personnalisées pour l'instant, on utilise les methodes
* de base de ServiceEntityRepository (findBy, findOneBy, etc).
*
* @extends ServiceEntityRepository<Service>
*/
class ServiceRepository extends ServiceEntityRepository
{
/**
* @brief Constructeur standard du repository Doctrine
* @param ManagerRegistry $registry
*/
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Service::class);
}
}

View file

@ -0,0 +1,100 @@
<?php
/**
* @file UserRepository.php
* @brief Repository Doctrine pour les requetes sur les utilisateurs
*
* @package App\Repository
* @author Yasser
*/
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
/**
* @class UserRepository
* @brief Repository pour l'entité User avec methodes de recherche personnalisées
*
* Implémente PasswordUpgraderInterface pour que Symfony puisse mettre a jour
* automatiquement le hash du mot de passe si l'algorithme change.
*
* @extends ServiceEntityRepository<User>
*/
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
/**
* @brief Constructeur standard du repository Doctrine
* @param ManagerRegistry $registry
*/
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
/**
* @brief Met a jour le hash du mot de passe d'un utilisateur
*
* Appelé automatiquement par Symfony si le hash du mot de passe
* doit etre mis a jour (ex: changement d'algorithme). Requis par
* l'interface PasswordUpgraderInterface.
*
* @param PasswordAuthenticatedUserInterface $user L'utilisateur dont on met a jour le mdp
* @param string $newHashedPassword Le nouveau hash a stocker
* @throws UnsupportedUserException Si l'utilisateur n'est pas une instance de User
*/
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
}
$user->setPassword($newHashedPassword);
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
/**
* @brief Retourne tout les utilisateurs avec un compte actif
*
* @return array Tableau d'objets User trié par date d'inscription (plus recent en premier)
*/
public function findAllActiveUsers(): array
{
return $this->createQueryBuilder('u')
->andWhere('u.isActive = true')
->orderBy('u.createdAt', 'DESC')
->getQuery()
->getResult();
}
/**
* @brief Retourne les operateurs actifs en excluant ceux deja assignés a un serveur
*
* Utilisé dans la page de detail d'un serveur pour afficher la liste
* des operateurs qui peuvent encore etre assignés (ceux pas encore dans
* ServerAccess pour ce serveur). Les admins sont aussi exclus.
*
* @param array $excludeIds Tableau des ids d'utilisateurs a exclure
* @return array Tableau d'objets User (operateurs uniquement, pas admins)
*/
public function findOperatorsExcluding(array $excludeIds): array
{
$qb = $this->createQueryBuilder('u')
->andWhere('u.isActive = true')
->orderBy('u.pseudo', 'ASC');
if (!empty($excludeIds)) {
$qb->andWhere('u.id NOT IN (:ids)')->setParameter('ids', $excludeIds);
}
return array_values(array_filter(
$qb->getQuery()->getResult(),
fn($u) => !$u->isAdmin()
));
}
}

View file

@ -0,0 +1,66 @@
<?php
/**
* @file AccessDeniedHandler.php
* @brief Gestionnaire personnalisé pour les erreur d'accés refusé
*
* Intercepte les AccessDeniedException et redirige les utilisateurs
* avec ROLE_USER vers la page d'attente au lieu d'afficher une erreur 403.
*
* @package App\Security
* @author Yasser
*/
namespace App\Security;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Http\Authorization\AccessDeniedHandlerInterface;
/**
* @class AccessDeniedHandler
* @brief Handler personnalisé pour les accés refusés
*
* Si un utilisateur connecté (ROLE_USER) tente d'acceder a une page
* reservée aux opérateurs, on le redirige vers la page d'attente
* au lieu d'afficher une page d'erreur 403 pas tres user-friendly.
* Pour les autres cas (not found etc) on retourne null pour laisser
* Symfony gérer l'erreur normalement.
*/
class AccessDeniedHandler implements AccessDeniedHandlerInterface
{
/**
* @brief Constructeur avec injection du router et du service de securité
*
* @param RouterInterface $router Pour generer l'URL de redirection
* @param Security $security Pour verifier le role de l'utilisateur courant
*/
public function __construct(
private readonly RouterInterface $router,
private readonly Security $security,
) {}
/**
* @brief Gere l'exception d'acces refusé
*
* Si l'utilisateur est connecté mais n'est pas encore opérateur,
* on le redirige vers la page d'attente. Sinon on retourne null
* pour que Symfony continue avec son comportement par defaut.
*
* @param Request $request La requete courante
* @param AccessDeniedException $accessDeniedException L'exception levée
* @return Response|null Une redirection ou null pour laisser Symfony gerer
*/
public function handle(Request $request, AccessDeniedException $accessDeniedException): ?Response
{
if ($this->security->getUser() && !$this->security->isGranted('ROLE_OPERATOR')) {
return new RedirectResponse($this->router->generate('app_pending'));
}
return null;
}
}

View file

@ -0,0 +1,58 @@
<?php
/**
* @file UserChecker.php
* @brief Vérificateur de compte utilisateur avant/apres authentification
*
* Implémente UserCheckerInterface pour bloquer la connexion des
* comptes désactivés avant que le token d'authentification soit crée.
*
* @package App\Security
* @author Yasser
*/
namespace App\Security;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @class UserChecker
* @brief Verifie l'etat du compte utilisateur lors de la connexion
*
* Applelé automatiquement par Symfony lors du processus d'authentification.
* Permet d'empecher la connexion d'un compte desactivé par un admin
* avec un message d'erreur personalisé au lieu d'un message generique.
*/
class UserChecker implements UserCheckerInterface
{
/**
* @brief Verifie le compte avant l'authentification
*
* Appelé avant que le mot de passe soit verifié. Si le compte
* est desactivé on lance une exception avec un message d'erreur
* lisible par l'utilisateur.
*
* @param UserInterface $user L'utilisateur qui tente de se connecter
* @throws CustomUserMessageAuthenticationException Si le compte est desactivé
*/
public function checkPreAuth(UserInterface $user): void
{
if ($user instanceof User && !$user->isActive()) {
throw new CustomUserMessageAuthenticationException(
'Your account has been disabled. Please contact an administrator.'
);
}
}
/**
* @brief Verifie le compte apres l'authentification (non utilisé ici)
*
* @param UserInterface $user
* @param TokenInterface|null $token
*/
public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void {}
}

View file

@ -0,0 +1,333 @@
<?php
/**
* @file SshCommandService.php
* @brief Service principal pour les connexions SSH et l'execution de commandes
*
* Ce service encapsule toute la logique SSH de l'application en utilisant
* la librairie phpseclib3. Il gere la connexion, l'execution des actions
* systemd, le terminal interactif et le scan automatique des services.
*
* @package App\Service
* @author Yasser
*/
namespace App\Service;
use App\Entity\ActionLog;
use App\Entity\Server;
use App\Entity\User;
use App\Repository\ServerAccessRepository;
use Doctrine\ORM\EntityManagerInterface;
use phpseclib3\Net\SSH2;
/**
* @class SshCommandService
* @brief Service de gestion des connexions SSH et des commandes systemd
*
* Gere les connexions SSH via phpseclib3. Les admins se connectent
* avec les credentials du serveur, les opérateurs utilisent leurs
* propres credentials stocked dans ServerAccess.
* Toutes les actions systemd sont loggées via ActionLog.
*/
class SshCommandService
{
/**
* @brief Constructeur avec injection des dependances
*
* @param EntityManagerInterface $entityManager Pour persister les ActionLog
* @param ServerAccessRepository $accessRepo Pour recuperer les credentials operateur
*/
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ServerAccessRepository $accessRepo,
) {}
/**
* @brief Execute une action systemd sur un service et cree un log
*
* Les actions autorisées sont: start, stop, restart, status.
* Si sudo est necessaire et que le mot de passe est pas fourni,
* le log est crée avec statut 'needs_sudo' sans executer l'action.
* Toutes les actions sont loggées même en cas d'echec.
*
* @param Server $server Le serveur cible
* @param string $action L'action systemd (start/stop/restart/status)
* @param string $serviceName Le nom du service systemd
* @param User|null $user L'utilisateur qui declenche l'action
* @param string|null $sudoPassword Mot de passe sudo si necessaire
* @return ActionLog Le log de l'action avec son statut et output
* @throws \InvalidArgumentException Si l'action n'est pas dans la liste autorisée
*/
public function executeAction(Server $server, string $action, string $serviceName, ?User $user, ?string $sudoPassword = null): ActionLog
{
$allowedActions = ['start', 'stop', 'restart', 'status'];
if (!in_array($action, $allowedActions, true)) {
throw new \InvalidArgumentException(sprintf('Action "%s" is not allowed.', $action));
}
$log = new ActionLog();
$log->setActionType($action);
$log->setServer($server);
$log->setUser($user);
try {
$ssh = $this->connect($server, $user);
if ($sudoPassword !== null) {
$cmd = sprintf('echo %s | sudo -S systemctl %s %s 2>&1',
escapeshellarg($sudoPassword), $action, escapeshellarg($serviceName));
['output' => $output, 'exit_code' => $exitCode] = $this->execPty($ssh, $cmd);
if ($this->isWrongPassword($output)) {
$log->setStatus('needs_sudo');
$log->setOutput('');
} else {
$log->setStatus($exitCode === 0 ? 'success' : 'failure');
$log->setOutput($output);
$server->setLastStatus('online');
}
} else {
$cmd = sprintf('sudo systemctl %s %s 2>&1', $action, escapeshellarg($serviceName));
$output = (string) $ssh->exec($cmd);
if ($this->needsSudoPassword($output)) {
$log->setStatus('needs_sudo');
$log->setOutput('');
} else {
$exitCode = (int) ($ssh->getExitStatus() ?? 1);
$log->setStatus($exitCode === 0 ? 'success' : 'failure');
$log->setOutput($output);
$server->setLastStatus('online');
}
}
} catch (\Throwable $e) {
$log->setStatus('failure');
$log->setOutput($e->getMessage());
}
$this->entityManager->persist($log);
$this->entityManager->flush();
return $log;
}
/**
* @brief Execute une commande libre sur le serveur via SSH (terminal interactif)
*
* Utilisé par le terminal dans l'interface. Si la commande contient 'sudo'
* et que le mot de passe est fourni, on utilise execPty() pour avoir
* un vrai pseudo-terminal. Pas de log pour les commandes libres.
*
* @param Server $server Le serveur cible
* @param string $command La commande a executer
* @param string|null $sudoPassword Mot de passe sudo si la commande en a besoin
* @param User|null $currentUser L'utilisateur courant (pour choisir les bons credentials)
* @return array ['success' => bool, 'output' => string, 'needs_sudo' => bool, 'exit_code' => int]
*/
public function runCommand(Server $server, string $command, ?string $sudoPassword = null, ?User $currentUser = null): array
{
try {
$ssh = $this->connect($server, $currentUser);
$hasSudo = (bool) preg_match('/\bsudo\b/', $command);
if ($sudoPassword !== null && $hasSudo) {
$inner = preg_replace('/^\s*sudo\s+/', '', $command);
$cmd = sprintf('echo %s | sudo -S %s 2>&1', escapeshellarg($sudoPassword), $inner);
['output' => $output, 'exit_code' => $exitCode] = $this->execPty($ssh, $cmd);
if ($this->isWrongPassword($output)) {
return ['success' => false, 'output' => '', 'needs_sudo' => true, 'exit_code' => 1];
}
return ['success' => $exitCode === 0, 'output' => $output, 'needs_sudo' => false, 'exit_code' => $exitCode];
}
$output = (string) $ssh->exec($command . ' 2>&1');
$exitCode = (int) ($ssh->getExitStatus() ?? 0);
if ($this->needsSudoPassword($output)) {
return ['success' => false, 'output' => '', 'needs_sudo' => true, 'exit_code' => 1];
}
return ['success' => $exitCode === 0, 'output' => $output, 'needs_sudo' => false, 'exit_code' => $exitCode];
} catch (\Throwable $e) {
return ['success' => false, 'output' => 'Error: ' . $e->getMessage(), 'needs_sudo' => false, 'exit_code' => 1];
}
}
/**
* @brief Verifie si le serveur est accessible en tentant une connexion SSH rapide
*
* Utilise un timeout de 5 secondes pour pas bloquer l'interface trop longtemps.
* Retourne 'offline' si la connexion echoue ou si une exception est lancée.
*
* @param Server $server Le serveur a verifier
* @return string 'online' si SSH accessible, 'offline' sinon
*/
public function checkStatus(Server $server): string
{
try {
$ssh = new SSH2($server->getIpAddress(), $server->getSshPort(), 5);
if (!$ssh->login($server->getSshUser(), $server->getSshPassword())) {
return 'offline';
}
return 'online';
} catch (\Throwable) {
return 'offline';
}
}
/**
* @brief Etablit une connexion SSH sur le serveur avec les bons credentials
*
* Si l'utilisateur courant est un opérateur (pas admin), on utilise
* ses credentials SSH personnels depuis ServerAccess. Les admins
* utilisent les credentials principaux du serveur.
*
* @param Server $server Le serveur sur lequel se connecter
* @param User|null $currentUser L'utilisateur courant ou null pour les admins
* @return SSH2 L'objet de connexion SSH prêt a etre utilisé
* @throws \RuntimeException Si l'authentification SSH echoue
*/
private function connect(Server $server, ?User $currentUser = null): SSH2
{
$sshUser = $server->getSshUser();
$sshPassword = $server->getSshPassword();
if ($currentUser !== null && !$currentUser->isAdmin()) {
$access = $this->accessRepo->findOneBy(['server' => $server, 'user' => $currentUser]);
if ($access) {
$sshUser = $access->getSshUser();
$sshPassword = $access->getSshPassword();
}
}
$ssh = new SSH2($server->getIpAddress(), $server->getSshPort(), 10);
if (!$ssh->login($sshUser, $sshPassword)) {
throw new \RuntimeException(sprintf('SSH authentication failed for user "%s".', $sshUser));
}
return $ssh;
}
/**
* @brief Execute une commande via un pseudo-terminal (PTY) pour supporter sudo
*
* Necesaire pour les commandes sudo car sudo a besoin d'un vrai terminal
* pour afficher le prompt de mot de passe. On nettoie les codes d'echappement
* ANSI de la sortie avant de la retourner.
*
* @param SSH2 $ssh La connexion SSH active
* @param string $command La commande a executer avec le PTY
* @return array ['output' => string, 'exit_code' => int]
*/
private function execPty(SSH2 $ssh, string $command): array
{
$ssh->enablePTY();
$ssh->exec($command);
$ssh->setTimeout(15);
$raw = (string) ($ssh->read() ?: '');
$output = preg_replace('/\x1b\[[0-9;]*[a-zA-Z]|\x1b[()][\w]|\r/', '', $raw) ?? $raw;
$output = preg_replace('/^\[sudo\] password for .*?:\s*$/m', '', $output);
$output = trim($output);
$exitCode = (int) ($ssh->getExitStatus() ?? 0);
return ['output' => $output, 'exit_code' => $exitCode];
}
/**
* @brief Verifie le statut de plusieurs services systemd en une seule commande
*
* Utilise 'systemctl is-active' avec plusieurs noms en meme temps pour
* limiter le nombre de connexions SSH. Retourne 'unknown' pour tout les
* services si la connexion SSH echoue.
*
* @param Server $server Le serveur cible
* @param array $serviceNames Tableau des noms de services a verifier
* @return array Tableau associatif ['service_name' => 'active'|'inactive'|'unknown']
*/
public function checkServicesStatus(Server $server, array $serviceNames): array
{
if (empty($serviceNames)) return [];
try {
$ssh = $this->connect($server);
$escaped = implode(' ', array_map('escapeshellarg', $serviceNames));
$output = (string) $ssh->exec("systemctl is-active $escaped 2>/dev/null");
$lines = array_values(array_filter(explode("\n", trim($output)), fn($l) => $l !== ''));
$result = [];
foreach (array_values($serviceNames) as $i => $name) {
$result[$name] = ($lines[$i] ?? '') === 'active' ? 'active' : 'inactive';
}
return $result;
} catch (\Throwable) {
return array_fill_keys($serviceNames, 'unknown');
}
}
/**
* @brief Scanne les services systemd actifs sur le serveur
*
* Utilisé automatiquement lors de l'ajout d'un serveur pour pre-remplir
* la liste des services a surveiller. Retourne un tableau vide si la
* connexion SSH echoue (le serveur est quand meme crée).
*
* @param Server $server Le serveur a scanner
* @return array Tableau des noms de services systemd actifs (sans '.service')
*/
public function scanServices(Server $server): array
{
try {
$ssh = $this->connect($server);
$output = (string) $ssh->exec(
'systemctl list-units --type=service --state=active --no-pager --no-legend --plain 2>/dev/null'
);
$services = [];
foreach (explode("\n", $output) as $line) {
$line = trim($line);
if (!$line) continue;
$parts = preg_split('/\s+/', $line, 2);
$name = $parts[0] ?? '';
if (str_ends_with($name, '.service')) {
$serviceName = substr($name, 0, -8);
if ($serviceName !== '') {
$services[] = $serviceName;
}
}
}
return $services;
} catch (\Throwable) {
return [];
}
}
/**
* @brief Detecte si la sortie SSH indique que sudo demande un mot de passe
*
* @param string $output La sortie brute de la commande SSH
* @return bool true si sudo a besoin d'un mot de passe
*/
private function needsSudoPassword(string $output): bool
{
return (bool) preg_match(
'/\[sudo\] password|sudo: a password is required|sudo: a terminal is required/i',
$output
);
}
/**
* @brief Detecte si le mot de passe sudo fourni est incorrect
*
* @param string $output La sortie brute de la commande PTY
* @return bool true si le mot de passe est mauvais
*/
private function isWrongPassword(string $output): bool
{
return (bool) preg_match(
'/sorry, try again|sudo: \d+ incorrect password attempt|authentication failure/i',
$output
);
}
}