Squelette Symfony, entités Server et Action

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

17
.editorconfig Normal file
View file

@ -0,0 +1,17 @@
# editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[{compose.yaml,compose.*.yaml}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false

2
assets/app.js Normal file
View file

@ -0,0 +1,2 @@
import './stimulus_bootstrap.js';
import './styles/app.css';

15
assets/controllers.json Normal file
View file

@ -0,0 +1,15 @@
{
"controllers": {
"@symfony/ux-turbo": {
"turbo-core": {
"enabled": true,
"fetch": "eager"
},
"mercure-turbo-stream": {
"enabled": false,
"fetch": "eager"
}
}
},
"entrypoints": []
}

View file

@ -0,0 +1,81 @@
const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/;
// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager
// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event
// and thus this event-listener will not be executed.
document.addEventListener('submit', function (event) {
generateCsrfToken(event.target);
}, true);
// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie
// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked
document.addEventListener('turbo:submit-start', function (event) {
const h = generateCsrfHeaders(event.detail.formSubmission.formElement);
Object.keys(h).map(function (k) {
event.detail.formSubmission.fetchRequest.headers[k] = h[k];
});
});
// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted
document.addEventListener('turbo:submit-end', function (event) {
removeCsrfToken(event.detail.formSubmission.formElement);
});
export function generateCsrfToken (formElement) {
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return;
}
let csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
let csrfToken = csrfField.value;
if (!csrfCookie && nameCheck.test(csrfToken)) {
csrfField.setAttribute('data-csrf-protection-cookie-value', csrfCookie = csrfToken);
csrfField.defaultValue = csrfToken = btoa(String.fromCharCode.apply(null, (window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18))));
}
csrfField.dispatchEvent(new Event('change', { bubbles: true }));
if (csrfCookie && tokenCheck.test(csrfToken)) {
const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict';
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
}
}
export function generateCsrfHeaders (formElement) {
const headers = {};
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return headers;
}
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
headers[csrfCookie] = csrfField.value;
}
return headers;
}
export function removeCsrfToken (formElement) {
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return;
}
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
const cookie = csrfCookie + '_' + csrfField.value + '=0; path=/; samesite=strict; max-age=0';
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
}
}
/* stimulusFetch: 'lazy' */
export default 'csrf-protection-controller';

View file

@ -0,0 +1,7 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
connect() {
this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js';
}
}

View file

@ -0,0 +1,5 @@
import { startStimulusApp } from '@symfony/stimulus-bundle';
const app = startStimulusApp();
// register any custom, 3rd party controllers here
// app.register('some_controller_name', SomeImportedController);

405
assets/styles/app.css Normal file
View file

@ -0,0 +1,405 @@
/* ── Layout ────────────────────────────────────────────────── */
.app-wrapper {
display: flex;
min-height: 100vh;
}
.main-column {
flex: 1;
display: flex;
flex-direction: column;
min-height: 100vh;
overflow-x: hidden;
}
/* ── Sidebar ───────────────────────────────────────────────── */
.sidebar {
width: 220px;
flex-shrink: 0;
min-height: 100vh;
padding: 1rem;
display: flex;
flex-direction: column;
}
.sidebar .brand {
font-size: 1.05rem;
font-weight: 700;
text-decoration: none;
display: block;
margin-bottom: 1.25rem;
padding: .3rem .6rem;
border-radius: 6px;
letter-spacing: -.01em;
}
.sidebar .nav-link {
border-radius: 6px;
padding: .45rem .75rem;
font-size: .88rem;
transition: background .12s, color .12s;
}
.sidebar-section-label {
font-size: .63rem;
font-weight: 700;
letter-spacing: .1em;
text-transform: uppercase;
padding: .3rem .75rem .1rem;
display: block;
}
/* ── Sidebar footer ────────────────────────────────────────── */
.sidebar-footer {
padding-top: .75rem;
border-top: 1px solid transparent;
}
.sidebar-user {
display: flex;
align-items: center;
gap: .5rem;
padding: .3rem .5rem;
margin-bottom: .4rem;
border-radius: 6px;
}
.user-avatar {
width: 26px;
height: 26px;
border-radius: 50%;
background: #5865f2;
color: #fff;
font-size: .65rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.user-name {
font-size: .82rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-controls {
display: flex;
align-items: center;
gap: .4rem;
padding: .2rem .5rem;
}
/* ── Theme switch (iOS style) ──────────────────────────────── */
.theme-switch-wrap {
display: flex;
align-items: center;
gap: .35rem;
font-size: .72rem;
}
.theme-switch {
position: relative;
display: inline-block;
cursor: pointer;
margin: 0;
}
#theme-checkbox {
opacity: 0;
position: absolute;
z-index: -1;
width: 0;
height: 0;
}
.theme-track {
display: inline-block;
width: 38px;
height: 22px;
border-radius: 22px;
background: #9da3ae;
position: relative;
transition: background .22s ease-in-out;
vertical-align: middle;
}
.theme-track::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 4px rgba(0,0,0,.35);
transition: left .22s ease-in-out, transform .22s ease-in-out;
}
#theme-checkbox:checked + .theme-track {
background: #5865f2;
}
#theme-checkbox:checked + .theme-track::after {
left: calc(100% - 3px);
transform: translateX(-100%);
}
.theme-icon-sun { color: #f0a30c; }
.theme-icon-moon { color: #9da3ae; }
/* ── Logout button ─────────────────────────────────────────── */
.btn-logout {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
text-decoration: none;
font-size: .85rem;
transition: background .12s, color .12s;
flex-shrink: 0;
}
/* ── Dark mode ─────────────────────────────────────────────── */
[data-bs-theme="dark"] body {
background-color: #313338;
color: #dbdee1;
}
[data-bs-theme="dark"] .sidebar {
background: #2b2d31;
border-right: 1px solid #1e1f22;
}
[data-bs-theme="dark"] .sidebar .brand { color: #fff; }
[data-bs-theme="dark"] .sidebar .nav-link { color: #b5bac1; }
[data-bs-theme="dark"] .sidebar .nav-link:hover { color: #dde0e4; background: rgba(255,255,255,.05); }
[data-bs-theme="dark"] .sidebar .nav-link.active {
color: #fff;
background: rgba(88,101,242,.18);
box-shadow: inset 3px 0 0 #5865f2;
}
[data-bs-theme="dark"] .sidebar-section-label { color: #72767d; }
[data-bs-theme="dark"] .sidebar-footer { border-top-color: #1e1f22; }
[data-bs-theme="dark"] .sidebar-user { color: #dbdee1; }
[data-bs-theme="dark"] .user-name { color: #dbdee1; }
[data-bs-theme="dark"] .btn-logout { color: #72767d; }
[data-bs-theme="dark"] .btn-logout:hover { background: rgba(242,63,67,.12); color: #f23f43; }
[data-bs-theme="dark"] .card {
background: #2b2d31 !important;
border: 1px solid #1e1f22 !important;
box-shadow: 0 2px 8px rgba(0,0,0,.4);
}
[data-bs-theme="dark"] .card-header {
background: rgba(0,0,0,.22) !important;
border-bottom: 1px solid #1e1f22 !important;
color: #e0e2e5;
}
[data-bs-theme="dark"] .card-footer {
background: rgba(0,0,0,.14) !important;
border-top-color: #1e1f22 !important;
}
[data-bs-theme="dark"] .table { color: #dbdee1; border-color: #3a3d44; }
[data-bs-theme="dark"] .table th { color: #9ea3ab; }
[data-bs-theme="dark"] .table-light,
[data-bs-theme="dark"] .table-light th,
[data-bs-theme="dark"] .table-light td { color: #212529 !important; }
[data-bs-theme="dark"] .form-control,
[data-bs-theme="dark"] .form-select {
background-color: #1e1f22;
border-color: #3a3d44;
color: #dbdee1;
}
[data-bs-theme="dark"] .form-control:focus,
[data-bs-theme="dark"] .form-select:focus {
border-color: #5865f2;
box-shadow: 0 0 0 3px rgba(88,101,242,.2);
background-color: #1e1f22;
color: #dbdee1;
}
[data-bs-theme="dark"] .input-group-text {
background-color: #1e1f22;
border-color: #3a3d44;
color: #72767d;
}
[data-bs-theme="dark"] .modal-content {
background: #2b2d31;
border-color: #1e1f22;
}
[data-bs-theme="dark"] .modal-header,
[data-bs-theme="dark"] .modal-footer { border-color: #3a3d44; }
/* ── Light mode ────────────────────────────────────────────── */
[data-bs-theme="light"] body {
background-color: #f2f3f5;
color: #2e3338;
}
[data-bs-theme="light"] .sidebar {
background: #f2f3f5;
border-right: 1px solid #e3e5e8;
}
[data-bs-theme="light"] .sidebar .brand { color: #1a1c1e; }
[data-bs-theme="light"] .sidebar .nav-link { color: #4e5058; }
[data-bs-theme="light"] .sidebar .nav-link:hover { color: #1a1c1e; background: rgba(0,0,0,.05); }
[data-bs-theme="light"] .sidebar .nav-link.active {
color: #5865f2;
background: rgba(88,101,242,.1);
box-shadow: inset 3px 0 0 #5865f2;
}
[data-bs-theme="light"] .sidebar-section-label { color: #747f8d; }
[data-bs-theme="light"] .sidebar-footer { border-top-color: #e3e5e8; }
[data-bs-theme="light"] .sidebar-user { color: #2e3338; }
[data-bs-theme="light"] .user-name { color: #2e3338; }
[data-bs-theme="light"] .btn-logout { color: #9da3ae; }
[data-bs-theme="light"] .btn-logout:hover { background: rgba(242,63,67,.08); color: #f23f43; }
[data-bs-theme="light"] .card {
background: #fff !important;
border: 1px solid #e3e5e8 !important;
box-shadow: 0 1px 4px rgba(0,0,0,.06);
}
[data-bs-theme="light"] .card-header {
background: #f7f8f9 !important;
border-bottom: 1px solid #e3e5e8 !important;
color: #1a1c1e;
}
[data-bs-theme="light"] .card-footer {
background: #f7f8f9 !important;
border-top-color: #e3e5e8 !important;
}
[data-bs-theme="light"] .form-control:focus,
[data-bs-theme="light"] .form-select:focus {
border-color: #5865f2;
box-shadow: 0 0 0 3px rgba(88,101,242,.15);
}
[data-bs-theme="light"] .text-white-50 { color: rgba(0,0,0,.45) !important; }
/* ── Status ────────────────────────────────────────────────── */
.status-online { color: #23a55a; }
.status-offline { color: #f23f43; }
.status-unknown { color: #80848e; }
/* ── Tables ────────────────────────────────────────────────── */
thead th { border-bottom: 2px solid var(--bs-border-color); font-size: .82rem; letter-spacing: .02em; }
/* ── Terminal ──────────────────────────────────────────────── */
.terminal-titlebar {
background: #161b22;
padding: 7px 14px;
border-radius: 8px 8px 0 0;
display: flex;
align-items: center;
user-select: none;
border-bottom: 1px solid #21262d;
}
.terminal-host {
color: #58a6ff;
font-size: .78rem;
font-family: 'Courier New', monospace;
}
.terminal-body {
padding: 12px 14px;
height: 380px;
overflow-y: auto;
color: #c9d1d9;
background: #0d1117;
font-size: .82rem;
line-height: 1.65;
}
.terminal-body::-webkit-scrollbar { width: 5px; }
.terminal-body::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; }
.term-line { white-space: pre-wrap; word-break: break-all; }
.term-prompt { color: #79c0ff; }
.term-success { color: #56d364; }
.term-error { color: #ff7b72; }
.term-info { color: #8b949e; font-style: italic; }
.term-warn { color: #e3b341; }
.terminal-input-row {
display: flex;
align-items: center;
background: #0d1117;
border-top: 1px solid #21262d;
padding: 8px 14px;
border-radius: 0 0 8px 8px;
}
.terminal-ps1 {
color: #79c0ff;
font-family: 'Courier New', monospace;
font-size: .82rem;
white-space: nowrap;
margin-right: 6px;
}
.terminal-cmd {
flex: 1;
background: transparent;
border: none;
outline: none;
color: #c9d1d9;
font-family: 'Courier New', Courier, monospace;
font-size: .82rem;
caret-color: #79c0ff;
}
.terminal-cmd::placeholder { color: #3d444d; }
#terminal-card { display: flex; flex-direction: column; }
#terminal-card .card-body { display: flex; flex-direction: column; background: #0d1117 !important; padding: 0 !important; }
/* ── Service cards ─────────────────────────────────────────── */
.service-card { border: 1px solid var(--bs-border-color) !important; box-shadow: 0 1px 3px rgba(0,0,0,.1) !important; }
.service-card .card-footer { background: transparent !important; border-top: 1px solid var(--bs-border-color) !important; }
/* ── Forms ─────────────────────────────────────────────────── */
.form-section-title {
font-size: .7rem;
font-weight: 700;
letter-spacing: .1em;
text-transform: uppercase;
color: #80848e;
margin-bottom: .75rem;
padding-bottom: .4rem;
border-bottom: 1px solid rgba(128,132,142,.2);
}
.field-icon { color: #80848e; }
.input-group-text { background: transparent; }
.role-badge { font-size: .75rem; padding: .3em .6em; }
/* ── Server picker ─────────────────────────────────────────── */
.server-search-wrap { position: relative; }
.server-search-wrap .bi-search {
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
color: #80848e; pointer-events: none;
}
#server-search { padding-left: 2rem; }
#server-list { max-height: 340px; overflow-y: auto; }
#server-list::-webkit-scrollbar { width: 5px; }
#server-list::-webkit-scrollbar-thumb { background: rgba(128,132,142,.3); border-radius: 3px; }
.server-item { transition: background .12s; }
.server-item.hidden { display: none !important; }
.grant-form {
display: none;
background: rgba(var(--bs-primary-rgb), .05);
border-top: 1px solid rgba(var(--bs-primary-rgb), .15);
padding: .75rem 1rem;
gap: .5rem;
}
.server-item.open .grant-form { display: flex; flex-wrap: wrap; align-items: flex-end; }
.grant-form .form-label { font-size: .78rem; font-weight: 600; margin-bottom: .2rem; }
.grant-form .form-control { font-size: .82rem; }

113
composer.json Normal file
View file

@ -0,0 +1,113 @@
{
"type": "project",
"license": "proprietary",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.4",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6",
"knplabs/knp-paginator-bundle": "^6.10",
"phpdocumentor/reflection-docblock": "^6.0",
"phpseclib/phpseclib": "^3.0",
"phpstan/phpdoc-parser": "^2.3",
"symfony/asset": "8.0.*",
"symfony/asset-mapper": "8.0.*",
"symfony/console": "8.0.*",
"symfony/doctrine-messenger": "8.0.*",
"symfony/dotenv": "8.0.*",
"symfony/expression-language": "8.0.*",
"symfony/flex": "^2",
"symfony/form": "8.0.*",
"symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*",
"symfony/intl": "8.0.*",
"symfony/mailer": "8.0.*",
"symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^3.0|^4.0",
"symfony/notifier": "8.0.*",
"symfony/process": "8.0.*",
"symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*",
"symfony/runtime": "8.0.*",
"symfony/security-bundle": "8.0.*",
"symfony/serializer": "8.0.*",
"symfony/stimulus-bundle": "^3.0",
"symfony/string": "8.0.*",
"symfony/translation": "8.0.*",
"symfony/twig-bundle": "8.0.*",
"symfony/ux-turbo": "^3.0",
"symfony/validator": "8.0.*",
"symfony/web-link": "8.0.*",
"symfony/yaml": "8.0.*",
"symfonycasts/reset-password-bundle": "^1.25",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0"
},
"config": {
"allow-plugins": {
"php-http/discovery": true,
"symfony/flex": true,
"symfony/runtime": true
},
"bump-after-update": true,
"sort-packages": true
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php73": "*",
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*",
"symfony/polyfill-php82": "*",
"symfony/polyfill-php83": "*",
"symfony/polyfill-php84": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd",
"importmap:install": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "8.0.*"
}
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.3",
"phpunit/phpunit": "^13.1",
"symfony/browser-kit": "8.0.*",
"symfony/css-selector": "8.0.*",
"symfony/debug-bundle": "8.0.*",
"symfony/maker-bundle": "^1.0",
"symfony/stopwatch": "8.0.*",
"symfony/web-profiler-bundle": "8.0.*"
}
}

0
migrations/.gitignore vendored Normal file
View file

View file

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260417000001 extends AbstractMigration
{
public function getDescription(): string
{
return 'Initial schema: app_user, server, service, action_log tables';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE app_user (
user_id SERIAL NOT NULL,
email VARCHAR(180) NOT NULL,
pseudo VARCHAR(50) NOT NULL,
roles JSON NOT NULL,
password VARCHAR(255) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
reset_token VARCHAR(100) DEFAULT NULL,
reset_token_expires_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
PRIMARY KEY(user_id)
)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_app_user_email ON app_user (email)');
$this->addSql('COMMENT ON COLUMN app_user.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN app_user.reset_token_expires_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('CREATE TABLE server (
server_id SERIAL NOT NULL,
owner_id INT NOT NULL,
name VARCHAR(100) NOT NULL,
ip_address VARCHAR(45) NOT NULL,
ssh_port SMALLINT NOT NULL DEFAULT 22,
ssh_user VARCHAR(50) NOT NULL,
ssh_password TEXT NOT NULL,
description TEXT DEFAULT NULL,
last_status VARCHAR(10) NOT NULL DEFAULT \'unknown\',
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
PRIMARY KEY(server_id)
)');
$this->addSql('COMMENT ON COLUMN server.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE server ADD CONSTRAINT FK_server_owner FOREIGN KEY (owner_id) REFERENCES app_user (user_id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE TABLE service (
service_id SERIAL NOT NULL,
server_id INT NOT NULL,
name VARCHAR(50) NOT NULL,
display_name VARCHAR(100) NOT NULL,
PRIMARY KEY(service_id)
)');
$this->addSql('ALTER TABLE service ADD CONSTRAINT FK_service_server FOREIGN KEY (server_id) REFERENCES server (server_id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE TABLE action_log (
log_id SERIAL NOT NULL,
user_id INT DEFAULT NULL,
server_id INT NOT NULL,
action_type VARCHAR(20) NOT NULL,
status VARCHAR(10) NOT NULL,
output TEXT DEFAULT NULL,
executed_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
PRIMARY KEY(log_id)
)');
$this->addSql('COMMENT ON COLUMN action_log.executed_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE action_log ADD CONSTRAINT FK_log_user FOREIGN KEY (user_id) REFERENCES app_user (user_id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE action_log ADD CONSTRAINT FK_log_server FOREIGN KEY (server_id) REFERENCES server (server_id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE action_log DROP CONSTRAINT FK_log_user');
$this->addSql('ALTER TABLE action_log DROP CONSTRAINT FK_log_server');
$this->addSql('ALTER TABLE service DROP CONSTRAINT FK_service_server');
$this->addSql('ALTER TABLE server DROP CONSTRAINT FK_server_owner');
$this->addSql('DROP TABLE action_log');
$this->addSql('DROP TABLE service');
$this->addSql('DROP TABLE server');
$this->addSql('DROP TABLE app_user');
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260417000002 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add server_access table: per-user SSH credentials per server';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE server_access (
access_id SERIAL NOT NULL,
server_id INT NOT NULL,
user_id INT NOT NULL,
ssh_user VARCHAR(50) NOT NULL,
ssh_password TEXT NOT NULL,
PRIMARY KEY(access_id),
CONSTRAINT uq_server_user UNIQUE (server_id, user_id)
)');
$this->addSql('ALTER TABLE server_access
ADD CONSTRAINT FK_sa_server FOREIGN KEY (server_id) REFERENCES server (server_id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE server_access
ADD CONSTRAINT FK_sa_user FOREIGN KEY (user_id) REFERENCES app_user (user_id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE server_access');
}
}

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

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

View file

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

26
src/Kernel.php Normal file
View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

55
tests/Unit/ServerTest.php Normal file
View file

@ -0,0 +1,55 @@
<?php
namespace App\Tests\Unit;
use App\Entity\Server;
use App\Entity\User;
use PHPUnit\Framework\TestCase;
class ServerTest extends TestCase
{
public function testServerDefaultStatus(): void
{
$server = new Server();
$this->assertEquals('unknown', $server->getLastStatus());
}
public function testServerDefaultPort(): void
{
$server = new Server();
$this->assertEquals(22, $server->getSshPort());
}
public function testServerSettersAndGetters(): void
{
$server = new Server();
$server->setName('Test Server');
$server->setIpAddress('192.168.1.1');
$server->setSshPort(2222);
$server->setSshUser('admin');
$server->setSshPassword('pass123');
$server->setLastStatus('online');
$this->assertEquals('Test Server', $server->getName());
$this->assertEquals('192.168.1.1', $server->getIpAddress());
$this->assertEquals(2222, $server->getSshPort());
$this->assertEquals('admin', $server->getSshUser());
$this->assertEquals('online', $server->getLastStatus());
}
public function testServerOwnerRelation(): void
{
$server = new Server();
$user = new User();
$user->setEmail('owner@test.com')->setPseudo('Owner');
$server->setOwner($user);
$this->assertSame($user, $server->getOwner());
}
public function testServerCreatedAtIsSetOnConstruct(): void
{
$server = new Server();
$this->assertInstanceOf(\DateTimeImmutable::class, $server->getCreatedAt());
}
}

51
tests/Unit/UserTest.php Normal file
View file

@ -0,0 +1,51 @@
<?php
namespace App\Tests\Unit;
use App\Entity\User;
use PHPUnit\Framework\TestCase;
class UserTest extends TestCase
{
public function testUserAlwaysHasRoleUser(): void
{
$user = new User();
$user->setRoles([]);
$this->assertContains('ROLE_USER', $user->getRoles());
}
public function testAdminDetection(): void
{
$user = new User();
$user->setRoles(['ROLE_ADMIN']);
$this->assertTrue($user->isAdmin());
$this->assertTrue($user->isOperator());
}
public function testOperatorDetection(): void
{
$user = new User();
$user->setRoles(['ROLE_OPERATOR']);
$this->assertTrue($user->isOperator());
$this->assertFalse($user->isAdmin());
}
public function testUserIsActiveByDefault(): void
{
$user = new User();
$this->assertTrue($user->isActive());
}
public function testUserIdentifierIsEmail(): void
{
$user = new User();
$user->setEmail('test@example.com');
$this->assertEquals('test@example.com', $user->getUserIdentifier());
}
public function testCreatedAtSetOnConstruct(): void
{
$user = new User();
$this->assertInstanceOf(\DateTimeImmutable::class, $user->getCreatedAt());
}
}

13
tests/bootstrap.php Normal file
View file

@ -0,0 +1,13 @@
<?php
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php';
if (method_exists(Dotenv::class, 'bootEnv')) {
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
}
if ($_SERVER['APP_DEBUG']) {
umask(0000);
}