Squelette Symfony, entités Server et Action
This commit is contained in:
commit
52ecb4204c
46 changed files with 4308 additions and 0 deletions
0
src/Controller/.gitignore
vendored
Normal file
0
src/Controller/.gitignore
vendored
Normal file
77
src/Controller/ActionLogController.php
Normal file
77
src/Controller/ActionLogController.php
Normal 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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
306
src/Controller/AdminController.php
Normal file
306
src/Controller/AdminController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
162
src/Controller/PasswordResetController.php
Normal file
162
src/Controller/PasswordResetController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
80
src/Controller/ProfileController.php
Normal file
80
src/Controller/ProfileController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
147
src/Controller/SecurityController.php
Normal file
147
src/Controller/SecurityController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
471
src/Controller/ServerController.php
Normal file
471
src/Controller/ServerController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
109
src/DataFixtures/AppFixtures.php
Normal file
109
src/DataFixtures/AppFixtures.php
Normal 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
0
src/Entity/.gitignore
vendored
Normal file
131
src/Entity/ActionLog.php
Normal file
131
src/Entity/ActionLog.php
Normal 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
230
src/Entity/Server.php
Normal 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; }
|
||||
}
|
||||
98
src/Entity/ServerAccess.php
Normal file
98
src/Entity/ServerAccess.php
Normal 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
93
src/Entity/Service.php
Normal 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
226
src/Entity/User.php
Normal 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()); }
|
||||
}
|
||||
50
src/Form/AdminEmailType.php
Normal file
50
src/Form/AdminEmailType.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
64
src/Form/AdminPasswordChangeType.php
Normal file
64
src/Form/AdminPasswordChangeType.php
Normal 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([]);
|
||||
}
|
||||
}
|
||||
81
src/Form/ChangePasswordType.php
Normal file
81
src/Form/ChangePasswordType.php
Normal 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([]);
|
||||
}
|
||||
}
|
||||
54
src/Form/ForgotPasswordType.php
Normal file
54
src/Form/ForgotPasswordType.php
Normal 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([]);
|
||||
}
|
||||
}
|
||||
75
src/Form/RegistrationFormType.php
Normal file
75
src/Form/RegistrationFormType.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
64
src/Form/ResetPasswordType.php
Normal file
64
src/Form/ResetPasswordType.php
Normal 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
61
src/Form/ServerType.php
Normal 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
63
src/Form/UserType.php
Normal 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
26
src/Kernel.php
Normal 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
0
src/Repository/.gitignore
vendored
Normal file
66
src/Repository/ActionLogRepository.php
Normal file
66
src/Repository/ActionLogRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
28
src/Repository/ServerAccessRepository.php
Normal file
28
src/Repository/ServerAccessRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
73
src/Repository/ServerRepository.php
Normal file
73
src/Repository/ServerRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
28
src/Repository/ServiceRepository.php
Normal file
28
src/Repository/ServiceRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
100
src/Repository/UserRepository.php
Normal file
100
src/Repository/UserRepository.php
Normal 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()
|
||||
));
|
||||
}
|
||||
}
|
||||
66
src/Security/AccessDeniedHandler.php
Normal file
66
src/Security/AccessDeniedHandler.php
Normal 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;
|
||||
}
|
||||
}
|
||||
58
src/Security/UserChecker.php
Normal file
58
src/Security/UserChecker.php
Normal 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 {}
|
||||
}
|
||||
333
src/Service/SshCommandService.php
Normal file
333
src/Service/SshCommandService.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue