Sécurité et qualité : headers HTTP, permissions .env, lint PHPStan + PHP-CS-Fixer, réorganisation dossiers, scripts de déploiement

This commit is contained in:
Cedric Abonnel
2026-05-08 13:18:00 +02:00
parent 700329f156
commit 70304d3b31
44 changed files with 776 additions and 670 deletions
-71
View File
@@ -1,71 +0,0 @@
<?php
require_once BASE_PATH . '/src/db.php';
require_once BASE_PATH . '/src/PostManager.php';
$postManager = new PostManager($db);
$errors = [];
$title = '';
$content = '';
$published = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim($_POST['title'] ?? '');
$content = trim($_POST['content'] ?? '');
$published = isset($_POST['published']);
if ($title === '') {
$errors[] = 'Le titre est obligatoire.';
}
if (empty($errors)) {
$postId = $postManager->create($title, $content, $published);
header("Location: index.php");
exit;
}
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Nouveau post</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container py-4">
<h1 class="mb-4">Ajouter un nouveau post</h1>
<?php if (!empty($errors)): ?>
<div class="alert alert-danger">
<ul class="mb-0">
<?php foreach ($errors as $error): ?>
<li><?= htmlspecialchars($error) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form method="POST">
<div class="mb-3">
<label for="title" class="form-label">Titre</label>
<input type="text" class="form-control" id="title" name="title" value="<?= htmlspecialchars($title) ?>" required>
</div>
<div class="mb-3">
<label for="content" class="form-label">Contenu</label>
<textarea class="form-control" id="content" name="content" rows="6"><?= htmlspecialchars($content) ?></textarea>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="published" name="published" <?= $published ? 'checked' : '' ?>>
<label class="form-check-label" for="published">Publier immédiatement</label>
</div>
<button type="submit" class="btn btn-primary">Enregistrer</button>
<a href="index.php" class="btn btn-secondary">Annuler</a>
</form>
</div>
</body>
</html>
+2
View File
@@ -1,4 +1,6 @@
<?php
declare(strict_types=1);
define('BASE_PATH', realpath(__DIR__ . '/../'));
require_once BASE_PATH . '/src/helpers.php';
+13 -7
View File
@@ -4,33 +4,39 @@ declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
if (!defined('BASE_PATH')) { require_once dirname(__DIR__, 2) . '/config/config.php'; }
if (!defined('BASE_PATH')) {
require_once dirname(__DIR__, 2) . '/config/config.php';
}
require_once BASE_PATH . '/includes/db.php';
require_once BASE_PATH . '/includes/csrf.php';
require_once BASE_PATH . '/includes/ConfigRepo.php';
require_once BASE_PATH . '/src/ConfigRepo.php';
Session::startSecure(getenv('SESSION_NAME') ?: 'SID_IDENT');
ensure_admin();
csrf_start();
$cfg = config_repo_get();
$msg = null; $err = null;
$msg = null;
$err = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrf_check($_POST['csrf'] ?? '')) { http_response_code(403); exit('CSRF'); }
if (!csrf_check($_POST['csrf'] ?? '')) {
http_response_code(403);
exit('CSRF');
}
$in = [
'oidc_issuer' => trim((string)($_POST['oidc_issuer'] ?? '')),
'oidc_name' => trim((string)($_POST['oidc_name'] ?? '')),
'oidc_client_id' => trim((string)($_POST['oidc_client_id'] ?? '')),
'oidc_client_secret'=> trim((string)($_POST['oidc_client_secret'] ?? '')),
'oidc_client_secret' => trim((string)($_POST['oidc_client_secret'] ?? '')),
'oidc_redirect_uri' => trim((string)($_POST['oidc_redirect_uri'] ?? '')),
];
// validations simples
if ($in['allow_oidc']) {
if ($in['oidc_issuer'] === '' || $in['oidc_client_id'] === '' || $in['oidc_client_secret'] === '' || $in['oidc_redirect_uri'] === '') {
$err = "OIDC activé mais champs incomplets.";
$err = 'OIDC activé mais champs incomplets.';
}
}
@@ -48,7 +54,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
env_set_pairs(BASE_PATH.'/.env', $envPairs);
$cfg = config_repo_get();
$msg = "Configuration enregistrée.";
$msg = 'Configuration enregistrée.';
}
}
?>
+28 -10
View File
@@ -8,18 +8,27 @@ use App\Http\Csrf;
// --- Helpers AVANT tout usage ---
if (!function_exists('env')) {
function env(string $key, ?string $default = null): ?string {
if (array_key_exists($key, $_ENV) && $_ENV[$key] !== '') return (string)$_ENV[$key];
function env(string $key, ?string $default = null): ?string
{
if (array_key_exists($key, $_ENV) && $_ENV[$key] !== '') {
return (string)$_ENV[$key];
}
$v = getenv($key);
if ($v !== false && $v !== '') return (string)$v;
if ($v !== false && $v !== '') {
return (string)$v;
}
return $default;
}
}
if (!function_exists('db')) {
function db(): \PDO { return \App\Infrastructure\Database::get(); }
function db(): \PDO
{
return \App\Infrastructure\Database::get();
}
}
if (!function_exists('url')) {
function url(string $path = '/'): string {
function url(string $path = '/'): string
{
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
return $scheme . '://' . $host . $path;
@@ -40,7 +49,9 @@ $maxPerWin = (int) env('MAGIC_MAX_PER_WINDOW', '5');
$defaultReturn = '/';
$sanitize = static function (string $url) use ($defaultReturn): string {
$url = trim($url);
if ($url === '' || !str_starts_with($url, '/')) return $defaultReturn;
if ($url === '' || !str_starts_with($url, '/')) {
return $defaultReturn;
}
return $url;
};
$returnTo = $sanitize((string)($_GET['return_to'] ?? ($_SERVER['HTTP_REFERER'] ?? $defaultReturn)));
@@ -49,7 +60,10 @@ $returnTo = $sanitize((string)($_GET['return_to'] ?? ($_SERVER['HTTP_REFERER'] ?
$oidcEnabled = (bool) (env('OIDC_ISSUER') && env('OIDC_CLIENT_ID'));
$oidcLoginUrl = '/login/oidc' . ($returnTo ? ('?return_to=' . urlencode($returnTo)) : '');
$oidcAuto = (isset($_GET['sso']) && $_GET['sso'] === '1') || (env('OIDC_AUTO', '0') === '1');
if ($oidcEnabled && $oidcAuto) { header('Location: ' . $oidcLoginUrl, true, 302); exit; }
if ($oidcEnabled && $oidcAuto) {
header('Location: ' . $oidcLoginUrl, true, 302);
exit;
}
// --- form: demande de lien magique ---
$errors = [];
@@ -65,13 +79,15 @@ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
} else {
// rate limit simple par email et IP
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
if (strpos($ip, ',') !== false) $ip = trim(explode(',', $ip, 2)[0]);
if (strpos($ip, ',') !== false) {
$ip = trim(explode(',', $ip, 2)[0]);
}
$pdo = db();
$pdo->beginTransaction();
try {
// purge expirés / consommés
$pdo->prepare("DELETE FROM auth_magic_links WHERE email = :e AND (expires_at < NOW() OR consumed_at IS NOT NULL)")
$pdo->prepare('DELETE FROM auth_magic_links WHERE email = :e AND (expires_at < NOW() OR consumed_at IS NOT NULL)')
->execute([':e' => $email]);
// 1) cooldown: refuser si un envoi récent < coolMin
@@ -130,7 +146,9 @@ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$okMsg = "Un lien vient d'être envoyé. Vérifiez votre boîte de réception et le dossier spam/indésirables.";
} catch (\Throwable $ex) {
if ($pdo->inTransaction()) $pdo->rollBack();
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
$errors[] = $ex->getMessage();
}
}
+29 -12
View File
@@ -1,4 +1,5 @@
<?php
// projet : mug.a5l.fr
// fichier : pages/login/magic.php
// version : 20251011
@@ -8,13 +9,17 @@ require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
require_once dirname(__DIR__, 2) . '/config/config.php';
use App\Service\AuthService; // si tu as un service pour ouvrir une session
// si tu as un service pour ouvrir une session
if (!function_exists('db')) {
function db(): PDO { return \App\Infrastructure\Database::get(); }
function db(): PDO
{
return \App\Infrastructure\Database::get();
}
}
if (!function_exists('url')) {
function url(string $path = '/'): string {
function url(string $path = '/'): string
{
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
return $scheme . '://' . $host . $path;
@@ -31,24 +36,32 @@ $pdo = db();
$pdo->beginTransaction();
try {
// récupère lien non consommé et non expiré
$sql = "SELECT id, email, token, created_at, expires_at, consumed_at, return_to
$sql = 'SELECT id, email, token, created_at, expires_at, consumed_at, return_to
FROM auth_magic_links
WHERE token = :t
FOR UPDATE";
FOR UPDATE';
$stmt = $pdo->prepare($sql);
$stmt->execute([':t' => $token]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) throw new RuntimeException('Lien inconnu.');
if ($row['consumed_at'] !== null) throw new RuntimeException('Lien déjà utilisé.');
if (strtotime((string)$row['expires_at']) < time()) throw new RuntimeException('Lien expiré.');
if (!$row) {
throw new RuntimeException('Lien inconnu.');
}
if ($row['consumed_at'] !== null) {
throw new RuntimeException('Lien déjà utilisé.');
}
if (strtotime((string)$row['expires_at']) < time()) {
throw new RuntimeException('Lien expiré.');
}
// consomme le lien
$pdo->prepare("UPDATE auth_magic_links SET consumed_at = NOW() WHERE id = :id")->execute([':id' => $row['id']]);
$pdo->prepare('UPDATE auth_magic_links SET consumed_at = NOW() WHERE id = :id')->execute([':id' => $row['id']]);
$pdo->commit();
// ouvre une session applicative « anonyme authentifiée par email »
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$_SESSION['auth'] = [
'method' => 'magic',
'email' => (string)$row['email'],
@@ -58,11 +71,15 @@ try {
$dest = $row['return_to'] ?? '/';
// sécurité: ne renvoyer que des chemins relatifs
if (!is_string($dest) || !str_starts_with($dest, '/')) $dest = '/';
if (!is_string($dest) || !str_starts_with($dest, '/')) {
$dest = '/';
}
header('Location: ' . $dest, true, 303);
exit;
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
http_response_code(400);
echo htmlspecialchars($e->getMessage(), ENT_QUOTES);
}
+2
View File
@@ -1,4 +1,6 @@
<?php
declare(strict_types=1);
// proxy vers pages/oidc/start.php avec flow=login
$_GET['flow'] = 'login';
require_once dirname(__DIR__) . '/oidc/start.php';
+22 -8
View File
@@ -1,19 +1,27 @@
<?php
declare(strict_types=1);
use App\Infrastructure\Database;
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
require_once dirname(__DIR__, 2) . '/config/config.php';
if (!function_exists('env')) {
function env(string $key, ?string $default = null): ?string {
if (array_key_exists($key, $_ENV) && $_ENV[$key] !== '') return (string)$_ENV[$key];
function env(string $key, ?string $default = null): ?string
{
if (array_key_exists($key, $_ENV) && $_ENV[$key] !== '') {
return (string)$_ENV[$key];
}
$v = getenv($key);
if ($v !== false && $v !== '') return (string)$v;
if ($v !== false && $v !== '') {
return (string)$v;
}
return $default;
}
}
@@ -68,7 +76,9 @@ $post = [
'client_id' => $OIDC_CLIENT_ID,
'code_verifier' => $codeVerifier,
];
if ($OIDC_CLIENT_SECRET !== '') $post['client_secret'] = $OIDC_CLIENT_SECRET;
if ($OIDC_CLIENT_SECRET !== '') {
$post['client_secret'] = $OIDC_CLIENT_SECRET;
}
$ch = curl_init($tokenEndpoint);
curl_setopt_array($ch, [
@@ -127,7 +137,9 @@ if (!$email && $idToken && substr_count($idToken, '.') === 2) {
[, $p, ] = explode('.', $idToken, 3);
$payloadJson = base64_decode(strtr($p, '-_', '+/'), true);
$payload = $payloadJson ? json_decode($payloadJson, true) : null;
if (is_array($payload) && !empty($payload['email'])) $email = $payload['email'];
if (is_array($payload) && !empty($payload['email'])) {
$email = $payload['email'];
}
}
if (!$email) {
@@ -159,7 +171,9 @@ if ($flow === 'login' && $existingId) {
];
$target = $_SESSION['oidc_return_to'] ?? '/';
unset($_SESSION['oidc_return_to'], $_SESSION['oidc_flow']);
if (!is_string($target) || $target === '' || $target[0] !== '/') $target = '/';
if (!is_string($target) || $target === '' || $target[0] !== '/') {
$target = '/';
}
header('Location: ' . $target, true, 303);
exit;
}
@@ -176,4 +190,4 @@ $_SESSION['pending_oidc'] = [
unset($_SESSION['oidc_flow']);
header('Location: ' . url('register/from-oidc'), true, 303);
exit;
exit;
+31 -12
View File
@@ -12,31 +12,48 @@ require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
require_once dirname(__DIR__, 2) . '/config/config.php';
function maskToken(?string $t): string {
if (!$t) return '';
function maskToken(?string $t): string
{
if (!$t) {
return '';
}
$len = strlen($t);
if ($len <= 12) return str_repeat('•', $len);
if ($len <= 12) {
return str_repeat('•', $len);
}
return substr($t, 0, 6) . str_repeat('•', max(0, $len - 12)) . substr($t, -6);
}
function b64url_decode_str(string $s): string|false {
function b64url_decode_str(string $s): string|false
{
$s = strtr($s, '-_', '+/');
$pad = strlen($s) % 4;
if ($pad) $s .= str_repeat('=', 4 - $pad);
if ($pad) {
$s .= str_repeat('=', 4 - $pad);
}
return base64_decode($s, true);
}
function decode_jwt(string $jwt): array {
if (substr_count($jwt, '.') !== 2) return [];
function decode_jwt(string $jwt): array
{
if (substr_count($jwt, '.') !== 2) {
return [];
}
[, $payload, ] = explode('.', $jwt, 3);
$json = b64url_decode_str($payload);
if ($json === false) return [];
if ($json === false) {
return [];
}
$arr = json_decode($json, true);
return is_array($arr) ? $arr : [];
}
$env = static function(string $k, ?string $d = null): ?string {
if (array_key_exists($k, $_ENV) && $_ENV[$k] !== '') return (string)$_ENV[$k];
$env = static function (string $k, ?string $d = null): ?string {
if (array_key_exists($k, $_ENV) && $_ENV[$k] !== '') {
return (string)$_ENV[$k];
}
$v = getenv($k);
if ($v !== false && $v !== '') return (string)$v;
if ($v !== false && $v !== '') {
return (string)$v;
}
return $d;
};
@@ -72,7 +89,9 @@ if ($debugEnabled && $claims === [] && $accTok && $issuer) {
curl_close($ch);
if ($resp !== false && $code === 200) {
$tmp = json_decode((string)$resp, true);
if (is_array($tmp)) $claims = $tmp;
if (is_array($tmp)) {
$claims = $tmp;
}
}
}
+17 -7
View File
@@ -1,23 +1,33 @@
<?php
declare(strict_types=1);
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
require_once dirname(__DIR__, 2) . '/config/config.php';
if (!function_exists('env')) {
function env(string $key, ?string $default = null): ?string {
if (array_key_exists($key, $_ENV) && $_ENV[$key] !== '') return (string)$_ENV[$key];
function env(string $key, ?string $default = null): ?string
{
if (array_key_exists($key, $_ENV) && $_ENV[$key] !== '') {
return (string)$_ENV[$key];
}
$v = getenv($key);
if ($v !== false && $v !== '') return (string)$v;
if ($v !== false && $v !== '') {
return (string)$v;
}
return $default;
}
}
$flow = $_GET['flow'] ?? 'login'; // 'login' ou 'register'
if (!in_array($flow, ['login','register'], true)) $flow = 'login';
if (!in_array($flow, ['login','register'], true)) {
$flow = 'login';
}
// return_to (URL relative uniquement)
$defaultReturn = '/';
@@ -29,8 +39,8 @@ $_SESSION['oidc_flow'] = $flow;
$_SESSION['oidc_return_to'] = $returnTo;
// --- OIDC conf ---
$issuer = rtrim((string)env('OIDC_ISSUER',''), '/');
$clientId = (string)env('OIDC_CLIENT_ID','');
$issuer = rtrim((string)env('OIDC_ISSUER', ''), '/');
$clientId = (string)env('OIDC_CLIENT_ID', '');
$redirectUri = (string)(env('OIDC_REDIRECT_URI') ?: url('oidc/callback'));
if (!$issuer || !$clientId || !$redirectUri) {
http_response_code(500);
+54 -52
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
define('BASE_PATH', realpath(__DIR__ . '/../'));
require_once BASE_PATH . '/src/db.php';
@@ -50,7 +52,7 @@ switch ($action) {
}
}
header("Location: route.php");
header('Location: route.php');
exit;
}
}
@@ -62,13 +64,13 @@ switch ($action) {
case 'view':
if (!$id) {
echo "ID manquant.";
echo 'ID manquant.';
exit;
}
$post = $postManager->get($id);
if (!$post) {
echo "Post introuvable.";
echo 'Post introuvable.';
exit;
}
@@ -79,61 +81,61 @@ switch ($action) {
if ($id) {
$postManager->delete($id);
}
header("Location: route.php");
header('Location: route.php');
exit;
case 'edit':
if (!$id) {
echo "ID manquant.";
exit;
case 'edit':
if (!$id) {
echo 'ID manquant.';
exit;
}
$post = $postManager->get($id);
if (!$post) {
echo 'Post introuvable.';
exit;
}
$title = $_POST['title'] ?? $post['title'];
$content = $_POST['content'] ?? $post['content'];
$published_at = $_POST['published_at'] ?? date('Y-m-d\TH:i', strtotime($post['created_at']));
$published = isset($_POST['published']) ? true : $post['is_published'];
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (trim($title) === '') {
$errors[] = 'Le titre est obligatoire.';
}
$post = $postManager->get($id);
if (!$post) {
echo "Post introuvable.";
exit;
}
$title = $_POST['title'] ?? $post['title'];
$content = $_POST['content'] ?? $post['content'];
$published_at = $_POST['published_at'] ?? date('Y-m-d\TH:i', strtotime($post['created_at']));
$published = isset($_POST['published']) ? true : $post['is_published'];
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (trim($title) === '') {
$errors[] = 'Le titre est obligatoire.';
}
if (empty($errors)) {
$published_at_sql = str_replace('T', ' ', $_POST['published_at']);
$postManager->update($id, $title, $content, $published_at_sql, $published);
if (!empty($_FILES['files']['name'][0])) {
foreach ($_FILES['files']['tmp_name'] as $i => $tmpName) {
if ($_FILES['files']['error'][$i] === UPLOAD_ERR_OK) {
$file = [
'name' => $_FILES['files']['name'][$i],
'type' => $_FILES['files']['type'][$i],
'tmp_name' => $_FILES['files']['tmp_name'][$i],
'error' => $_FILES['files']['error'][$i],
'size' => $_FILES['files']['size'][$i],
];
$fileManager->upload($id, $file);
}
if (empty($errors)) {
$published_at_sql = str_replace('T', ' ', $_POST['published_at']);
$postManager->update($id, $title, $content, $published_at_sql, $published);
if (!empty($_FILES['files']['name'][0])) {
foreach ($_FILES['files']['tmp_name'] as $i => $tmpName) {
if ($_FILES['files']['error'][$i] === UPLOAD_ERR_OK) {
$file = [
'name' => $_FILES['files']['name'][$i],
'type' => $_FILES['files']['type'][$i],
'tmp_name' => $_FILES['files']['tmp_name'][$i],
'error' => $_FILES['files']['error'][$i],
'size' => $_FILES['files']['size'][$i],
];
$fileManager->upload($id, $file);
}
}
header("Location: route.php?action=view&id=$id");
exit;
}
header("Location: route.php?action=view&id=$id");
exit;
}
$formAction = "route.php?action=edit&id=$id";
$action = 'edit';
include BASE_PATH . '/templates/post_form.php';
break;
}
$formAction = "route.php?action=edit&id=$id";
$action = 'edit';
include BASE_PATH . '/templates/post_form.php';
break;
case 'list':
default:
$posts = $postManager->getAll();