Working state but no uploads

This commit is contained in:
julien
2026-03-16 11:48:26 +01:00
parent e24ee5d622
commit 8e59daa4cd
21 changed files with 353 additions and 119 deletions

View File

@@ -16,11 +16,17 @@ APP_URL=http://localhost:8080
# Fuseau horaire # Fuseau horaire
TIMEZONE=Europe/Paris TIMEZONE=Europe/Paris
# Proxies de confiance pour interpréter X-Forwarded-For / X-Forwarded-Proto.
# Laisser vide en développement local sans proxy.
# En Docker derrière le Nginx fourni, docker-compose définit par défaut `*`.
# Exemples : TRUSTED_PROXIES=127.0.0.1,::1 ou TRUSTED_PROXIES=*
TRUSTED_PROXIES=
# ============================================================================= # =============================================================================
# Administration # Administration
# ============================================================================= # =============================================================================
# Compte administrateur (créé automatiquement au premier démarrage) # Compte administrateur (créé lors du provisionnement initial)
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@example.com ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=changeme123 ADMIN_PASSWORD=changeme123

View File

@@ -4,7 +4,7 @@
Les mêmes que pour le développement (voir [README](README.md)), plus : Les mêmes que pour le développement (voir [README](README.md)), plus :
- PHP 8.1+ avec l'extension `dom` (requise par HTMLPurifier et PHPUnit) - PHP 8.4.1+ avec l'extension `dom` (requise par HTMLPurifier et PHPUnit)
## Lancer les tests ## Lancer les tests

View File

@@ -1,6 +1,6 @@
# Slim Blog # Slim Blog
![PHP](https://img.shields.io/badge/PHP-8.1%2B-777BB4?logo=php&logoColor=white) ![PHP](https://img.shields.io/badge/PHP-8.4.1%2B-777BB4?logo=php&logoColor=white)
![Slim](https://img.shields.io/badge/Slim-4-74a045) ![Slim](https://img.shields.io/badge/Slim-4-74a045)
![PHPStan](https://img.shields.io/badge/PHPStan-niveau%208-blue) ![PHPStan](https://img.shields.io/badge/PHPStan-niveau%208-blue)
![Tests](https://img.shields.io/badge/tests-355%20passing-brightgreen) ![Tests](https://img.shields.io/badge/tests-355%20passing-brightgreen)
@@ -38,7 +38,7 @@ projets (boutique, portfolio…).
## Développement ## Développement
**Prérequis :** PHP 8.1+ avec `pdo_sqlite`, `intl`, `fileinfo`, `gd` (WebP), `xml` (dom), Composer, Node.js 18+ **Prérequis :** PHP 8.4.1+ avec `pdo_sqlite`, `intl`, `fileinfo`, `gd` (WebP), `xml` (dom), Composer, Node.js 18+
```bash ```bash
git clone https://git.netig.net/netig/slim-blog git clone https://git.netig.net/netig/slim-blog
@@ -46,6 +46,7 @@ cd slim-blog
composer install composer install
npm install && npm run build npm install && npm run build
cp .env.example .env cp .env.example .env
php bin/provision.php
php -S localhost:8080 -t public php -S localhost:8080 -t public
``` ```
@@ -76,6 +77,7 @@ cd slim-blog
cp .env.example .env cp .env.example .env
# Définir APP_ENV=production, APP_URL, ADMIN_PASSWORD et la configuration SMTP # Définir APP_ENV=production, APP_URL, ADMIN_PASSWORD et la configuration SMTP
docker compose up -d --build docker compose up -d --build
docker compose exec app php bin/provision.php
``` ```
> Le démarrage en production avec `ADMIN_PASSWORD=changeme123` est bloqué intentionnellement. > Le démarrage en production avec `ADMIN_PASSWORD=changeme123` est bloqué intentionnellement.
@@ -126,6 +128,8 @@ process principal et n'apparaissent pas dans les logs Docker.
Nginx écoute sur `127.0.0.1:8888` (câblé dans `docker-compose.yml`). Nginx écoute sur `127.0.0.1:8888` (câblé dans `docker-compose.yml`).
Configurer aussi `TRUSTED_PROXIES` si vous déployez l'application derrière un autre proxy que le Nginx Docker fourni. En stack Docker par défaut, `docker-compose.yml` force `TRUSTED_PROXIES=*` côté conteneur PHP-FPM pour faire confiance au Nginx interne.
Exemple de configuration Caddy : Exemple de configuration Caddy :
```caddy ```caddy
@@ -143,6 +147,7 @@ https://blog.exemple.com {
| `APP_URL` | URL de base (liens emails, flux RSS) — inclure le port en développement | `http://localhost:8080` | | `APP_URL` | URL de base (liens emails, flux RSS) — inclure le port en développement | `http://localhost:8080` |
| `APP_NAME` | Nom du blog (flux RSS, emails) | `Slim Blog` | | `APP_NAME` | Nom du blog (flux RSS, emails) | `Slim Blog` |
| `TIMEZONE` | Fuseau horaire PHP | `Europe/Paris` | | `TIMEZONE` | Fuseau horaire PHP | `Europe/Paris` |
| `TRUSTED_PROXIES` | Proxies autorisés à fournir `X-Forwarded-For` / `X-Forwarded-Proto` | `127.0.0.1,::1` ou `*` |
| `ADMIN_USERNAME` | Nom d'utilisateur du compte admin | `admin` | | `ADMIN_USERNAME` | Nom d'utilisateur du compte admin | `admin` |
| `ADMIN_EMAIL` | Email du compte admin | `admin@example.com` | | `ADMIN_EMAIL` | Email du compte admin | `admin@example.com` |
| `ADMIN_PASSWORD` | Mot de passe admin (obligatoire en production) | *(à changer)* | | `ADMIN_PASSWORD` | Mot de passe admin (obligatoire en production) | *(à changer)* |
@@ -172,6 +177,9 @@ Le contenu du blog (articles publiés) est soumis à [CC BY-SA 4.0](https://crea
## Provisioning ## Provisioning
Le provisionnement (migrations + seed admin) peut etre execute explicitement via `php bin/provision.php`. Le provisionnement (migrations + seed admin) s'execute explicitement via `php bin/provision.php`.
En developpement, il reste activable automatiquement via `APP_AUTO_PROVISION=true`.
En production, il est recommande de le lancer separement du runtime HTTP. - Developpement local : executer `php bin/provision.php` apres `cp .env.example .env`
- Docker / production : executer `docker compose exec app php bin/provision.php` apres le demarrage du conteneur
Le runtime HTTP ne provisionne plus automatiquement la base. Si le schema n'est pas present, l'application echoue avec un message explicite demandant d'executer la commande de provisionnement.

View File

@@ -8,10 +8,6 @@ require __DIR__ . '/../vendor/autoload.php';
use App\Shared\Bootstrap; use App\Shared\Bootstrap;
use App\Shared\Database\Provisioner; use App\Shared\Database\Provisioner;
$_ENV['APP_AUTO_PROVISION'] = '0';
session_status() === PHP_SESSION_ACTIVE || session_start();
$bootstrap = Bootstrap::create(); $bootstrap = Bootstrap::create();
$container = $bootstrap->initializeInfrastructure(); $container = $bootstrap->initializeInfrastructure();
Provisioner::run($container->get(\PDO::class)); Provisioner::run($container->get(\PDO::class));

View File

@@ -172,12 +172,14 @@ return [
PasswordResetServiceInterface $passwordResetService, PasswordResetServiceInterface $passwordResetService,
AuthServiceInterface $authService, AuthServiceInterface $authService,
FlashServiceInterface $flash, FlashServiceInterface $flash,
ClientIpResolver $clientIpResolver,
): PasswordResetController { ): PasswordResetController {
return new PasswordResetController( return new PasswordResetController(
$twig, $twig,
$passwordResetService, $passwordResetService,
$authService, $authService,
$flash, $flash,
$clientIpResolver,
rtrim($_ENV['APP_URL'] ?? 'http://localhost', '/'), rtrim($_ENV['APP_URL'] ?? 'http://localhost', '/'),
); );
} }

0
database/.provision.lock Normal file
View File

View File

@@ -28,7 +28,10 @@ services:
env_file: .env env_file: .env
# Vérifie que PHP-FPM écoute sur le port 9000 avant de déclarer le service sain. # Vérifie que PHP-FPM écoute sur le port 9000 avant de déclarer le service sain.
# bash /dev/tcp est disponible sur l'image Debian php:8.3-fpm sans dépendance environment:
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-*}
# bash /dev/tcp est disponible sur l'image Debian php:8.4-fpm sans dépendance
# supplémentaire. start_period laisse le temps à entrypoint.sh de terminer # supplémentaire. start_period laisse le temps à entrypoint.sh de terminer
# (sync public/, migrations, seed) avant que les échecs ne comptent. # (sync public/, migrations, seed) avant que les échecs ne comptent.
healthcheck: healthcheck:

View File

@@ -11,7 +11,7 @@ COPY assets/ assets/
RUN npm run build RUN npm run build
# ── Stage 2 : image PHP de production ─────────────────────────────────────── # ── Stage 2 : image PHP de production ───────────────────────────────────────
FROM php:8.3-fpm FROM php:8.4-fpm
# Extensions système et PHP dans un seul layer # Extensions système et PHP dans un seul layer
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \

View File

@@ -924,13 +924,8 @@ EditorMiddleware.php — redirige si rôle != editor ni admin
`AuthController::login()` orchestre trois responsabilités dans l'ordre : vérifier le rate limit, authentifier, ouvrir la session. `AuthController::login()` orchestre trois responsabilités dans l'ordre : vérifier le rate limit, authentifier, ouvrir la session.
```php ```php
// 0. Résolution de l'IP réelle derrière un reverse proxy (Caddy/Nginx) // 0. Résolution de l'IP réelle derrière un reverse proxy approuvé
// En production Docker, REMOTE_ADDR retourne l'IP interne du proxy. $ip = $this->clientIpResolver->resolve($req);
// X-Forwarded-For contient l'IP d'origine du client — on lit le premier
// segment, qui est injecté par Nginx/Caddy et ne peut pas être forgé.
$forwarded = trim((string) ($serverParams['HTTP_X_FORWARDED_FOR'] ?? ''));
$ip = $forwarded !== '' ? trim(explode(',', $forwarded)[0])
: ($serverParams['REMOTE_ADDR'] ?? '0.0.0.0');
// 1. Vérification du rate limit (avant toute authentification) // 1. Vérification du rate limit (avant toute authentification)
$remainingMinutes = $this->authService->checkRateLimit($ip); $remainingMinutes = $this->authService->checkRateLimit($ip);
@@ -952,7 +947,7 @@ $this->authService->login($user); // écrit userId/username/role en session
> 💡 `AuthService::authenticate()` ne gère pas le rate limiting — c'est `AuthController` qui en est responsable. Cette séparation permet de tester chaque comportement indépendamment. > 💡 `AuthService::authenticate()` ne gère pas le rate limiting — c'est `AuthController` qui en est responsable. Cette séparation permet de tester chaque comportement indépendamment.
> >
> ⚠️ L'IP lue depuis `REMOTE_ADDR` derrière un proxy retourne l'IP interne du proxy — le rate-limit se verrouillerait alors pour tous les utilisateurs simultanément. La logique ci-dessus lit `HTTP_X_FORWARDED_FOR` en priorité, ce qui est sûr dans un contexte Docker où seul Nginx/Caddy contrôle cet en-tête. > ⚠️ L'IP lue depuis `REMOTE_ADDR` derrière un proxy retourne l'IP interne du proxy — le rate-limit se verrouillerait alors pour tous les utilisateurs simultanément. Le projet centralise désormais cette logique dans `ClientIpResolver` / `RequestContext` et ne fait confiance aux en-têtes `X-Forwarded-*` que pour les proxies explicitement approuvés via `TRUSTED_PROXIES`.
#### Réinitialisation de mot de passe #### Réinitialisation de mot de passe
@@ -1138,7 +1133,7 @@ Le provisionnement des données initiales (compte admin) est géré séparément
### 7.1 Développement local (sans Docker) ### 7.1 Développement local (sans Docker)
Prérequis : PHP 8.1+ avec les extensions `pdo_sqlite`, `mbstring`, `fileinfo`, `gd` (WebP), `dom`. Composer. Node.js 18+. Prérequis : PHP 8.4.1+ avec les extensions `pdo_sqlite`, `mbstring`, `fileinfo`, `gd` (WebP), `dom`. Composer. Node.js 18+.
```bash ```bash
git clone https://git.netig.net/netig/slim-blog git clone https://git.netig.net/netig/slim-blog
@@ -1206,7 +1201,7 @@ L'application est accessible sur `http://localhost:8888` une fois le service `ap
Le conteneur nginx écoute sur `127.0.0.1:8888` — uniquement sur l'interface loopback de la machine hôte. Il n'est **pas** accessible depuis Internet sans un reverse proxy configuré sur le serveur. Ce choix est délibéré : c'est le reverse proxy qui prend en charge le TLS et redirige le trafic HTTPS vers le conteneur. Le conteneur nginx écoute sur `127.0.0.1:8888` — uniquement sur l'interface loopback de la machine hôte. Il n'est **pas** accessible depuis Internet sans un reverse proxy configuré sur le serveur. Ce choix est délibéré : c'est le reverse proxy qui prend en charge le TLS et redirige le trafic HTTPS vers le conteneur.
La config Nginx interne transmet déjà les en-têtes nécessaires à PHP (`X-Forwarded-For`, `X-Forwarded-Proto`) pour que l'application connaisse l'IP réelle du client et sache si la connexion est HTTPS. La config Nginx interne transmet déjà les en-têtes nécessaires à PHP (`X-Forwarded-For`, `X-Forwarded-Proto`) pour que l'application connaisse l'IP réelle du client et sache si la connexion est HTTPS. Le conteneur `app` accepte ces en-têtes uniquement depuis les proxies listés dans `TRUSTED_PROXIES` (par défaut `*` dans `docker-compose.yml`, car PHP-FPM n'est joignable qu'à travers le Nginx interne).
Exemple minimal avec **Caddy** (`/etc/caddy/Caddyfile`) : Exemple minimal avec **Caddy** (`/etc/caddy/Caddyfile`) :
@@ -1226,6 +1221,7 @@ Caddy gère automatiquement l'obtention et le renouvellement du certificat TLS.
| APP_NAME | Nom du blog (flux RSS, e-mails) | `Slim Blog` | | APP_NAME | Nom du blog (flux RSS, e-mails) | `Slim Blog` |
| APP_URL | URL publique (liens e-mails, flux RSS) | `https://blog.exemple.com` | | APP_URL | URL publique (liens e-mails, flux RSS) | `https://blog.exemple.com` |
| TIMEZONE | Fuseau horaire PHP | `Europe/Paris` | | TIMEZONE | Fuseau horaire PHP | `Europe/Paris` |
| TRUSTED_PROXIES | Proxies autorisés à fournir `X-Forwarded-For` / `X-Forwarded-Proto` | `127.0.0.1,::1` ou `*` |
| ADMIN_USERNAME | Nom d'utilisateur du compte admin | `admin` | | ADMIN_USERNAME | Nom d'utilisateur du compte admin | `admin` |
| ADMIN_EMAIL | E-mail du compte admin | `admin@example.com` | | ADMIN_EMAIL | E-mail du compte admin | `admin@example.com` |
| ADMIN_PASSWORD | Mot de passe admin (obligatoire en production) | *(à changer)* | | ADMIN_PASSWORD | Mot de passe admin (obligatoire en production) | *(à changer)* |

View File

@@ -4,12 +4,18 @@ declare(strict_types=1);
require __DIR__.'/../vendor/autoload.php'; require __DIR__.'/../vendor/autoload.php';
use App\Shared\Bootstrap; use App\Shared\Bootstrap;
use App\Shared\Http\RequestContext;
$bootstrap = Bootstrap::create();
$bootstrap->initializeInfrastructure();
$trustedProxies = RequestContext::trustedProxiesFromEnvironment($_ENV, $_SERVER);
session_start([ session_start([
'cookie_secure' => !empty($_SERVER['HTTPS']), 'cookie_secure' => RequestContext::isHttps($_SERVER, $trustedProxies),
'cookie_httponly' => true, 'cookie_httponly' => true,
'cookie_samesite' => 'Lax', 'cookie_samesite' => 'Lax',
]); ]);
$app = Bootstrap::create()->initialize(); $app = $bootstrap->createHttpApp();
$app->run(); $app->run();

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Auth; namespace App\Auth;
use App\Auth\Exception\InvalidResetTokenException; use App\Auth\Exception\InvalidResetTokenException;
use App\Shared\Http\ClientIpResolver;
use App\Shared\Http\FlashServiceInterface; use App\Shared\Http\FlashServiceInterface;
use App\User\Exception\WeakPasswordException; use App\User\Exception\WeakPasswordException;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
@@ -34,6 +35,7 @@ final class PasswordResetController
* @param PasswordResetServiceInterface $passwordResetService Service de réinitialisation * @param PasswordResetServiceInterface $passwordResetService Service de réinitialisation
* @param AuthServiceInterface $authService Service d'authentification (rate limiting) * @param AuthServiceInterface $authService Service d'authentification (rate limiting)
* @param FlashServiceInterface $flash Service de messages flash * @param FlashServiceInterface $flash Service de messages flash
* @param ClientIpResolver $clientIpResolver Résout l'IP réelle derrière un proxy approuvé
* @param string $baseUrl URL de base de l'application (depuis APP_URL dans .env) * @param string $baseUrl URL de base de l'application (depuis APP_URL dans .env)
*/ */
public function __construct( public function __construct(
@@ -41,6 +43,7 @@ final class PasswordResetController
private readonly PasswordResetServiceInterface $passwordResetService, private readonly PasswordResetServiceInterface $passwordResetService,
private readonly AuthServiceInterface $authService, private readonly AuthServiceInterface $authService,
private readonly FlashServiceInterface $flash, private readonly FlashServiceInterface $flash,
private readonly ClientIpResolver $clientIpResolver,
private readonly string $baseUrl, private readonly string $baseUrl,
) { ) {
} }
@@ -80,13 +83,7 @@ final class PasswordResetController
*/ */
public function forgot(Request $req, Response $res): Response public function forgot(Request $req, Response $res): Response
{ {
// Résolution de l'IP réelle derrière un reverse proxy (Caddy/Nginx). $ip = $this->clientIpResolver->resolve($req);
// Même logique que AuthController::login() — voir son commentaire pour le détail.
$serverParams = $req->getServerParams();
$forwarded = trim((string) ($serverParams['HTTP_X_FORWARDED_FOR'] ?? ''));
$ip = $forwarded !== '' && $forwarded !== '0.0.0.0'
? trim(explode(',', $forwarded)[0])
: ($serverParams['REMOTE_ADDR'] ?? '0.0.0.0');
// Vérification du rate limit avant tout traitement // Vérification du rate limit avant tout traitement
$remainingMinutes = $this->authService->checkRateLimit($ip); $remainingMinutes = $this->authService->checkRateLimit($ip);

View File

@@ -4,7 +4,8 @@ declare(strict_types=1);
namespace App\Shared; namespace App\Shared;
use App\Post\PostExtension; use App\Post\PostExtension;
use App\Shared\Database\Provisioner; use App\Shared\Database\DatabaseNotProvisionedException;
use App\Shared\Database\DatabaseReadiness;
use App\Shared\Extension\AppExtension; use App\Shared\Extension\AppExtension;
use App\Shared\Extension\CsrfExtension; use App\Shared\Extension\CsrfExtension;
use App\Shared\Extension\SessionExtension; use App\Shared\Extension\SessionExtension;
@@ -14,6 +15,7 @@ use PDO;
use Psr\Container\ContainerInterface; use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Slim\App; use Slim\App;
use Slim\Csrf\Guard; use Slim\Csrf\Guard;
@@ -40,7 +42,6 @@ final class Bootstrap
public function initialize(): App public function initialize(): App
{ {
$this->initializeInfrastructure(); $this->initializeInfrastructure();
$this->runAutoProvisioningIfEnabled();
return $this->createHttpApp(); return $this->createHttpApp();
} }
@@ -138,21 +139,6 @@ final class Bootstrap
} }
} }
private function runAutoProvisioningIfEnabled(): void
{
$flag = strtolower(trim((string) ($_ENV['APP_AUTO_PROVISION'] ?? '')));
$isDev = strtolower($_ENV['APP_ENV'] ?? 'production') === 'development';
$enabled = $flag !== ''
? in_array($flag, ['1', 'true', 'yes', 'on'], true)
: $isDev;
if (!$enabled) {
return;
}
Provisioner::run($this->container->get(PDO::class));
}
private function registerMiddlewares(): void private function registerMiddlewares(): void
{ {
$this->app->addBodyParsingMiddleware(); $this->app->addBodyParsingMiddleware();
@@ -168,6 +154,17 @@ final class Bootstrap
$guard->setPersistentTokenMode(true); $guard->setPersistentTokenMode(true);
$twig->addExtension(new CsrfExtension($guard)); $twig->addExtension(new CsrfExtension($guard));
$this->app->add($guard); $this->app->add($guard);
$container = $this->container;
$this->app->add(function (
ServerRequestInterface $request,
RequestHandlerInterface $handler,
) use ($container): ResponseInterface {
DatabaseReadiness::assertProvisioned($container->get(PDO::class));
return $handler->handle($request);
});
} }
private function registerRoutes(): void private function registerRoutes(): void
@@ -180,6 +177,8 @@ final class Bootstrap
$isDev = strtolower($_ENV['APP_ENV'] ?? 'production') === 'development'; $isDev = strtolower($_ENV['APP_ENV'] ?? 'production') === 'development';
$logger = $this->container->get(LoggerInterface::class); $logger = $this->container->get(LoggerInterface::class);
$errorHandler = $this->app->addErrorMiddleware($isDev, true, true, $logger); $errorHandler = $this->app->addErrorMiddleware($isDev, true, true, $logger);
$app = $this->app;
$container = $this->container;
$errorHandler->setDefaultErrorHandler( $errorHandler->setDefaultErrorHandler(
function ( function (
@@ -188,24 +187,30 @@ final class Bootstrap
bool $displayErrorDetails, bool $displayErrorDetails,
bool $logErrors, bool $logErrors,
bool $logErrorDetails, bool $logErrorDetails,
) use ($isDev): ResponseInterface { ) use ($isDev, $app, $container): ResponseInterface {
if ($isDev) { if ($isDev && !$exception instanceof DatabaseNotProvisionedException) {
throw $exception; throw $exception;
} }
$statusCode = 500; $statusCode = 500;
if ($exception instanceof HttpException) { $message = 'Une erreur inattendue s\'est produite.';
if ($exception instanceof DatabaseNotProvisionedException) {
$statusCode = 503;
$message = $exception->getMessage();
} elseif ($exception instanceof HttpException) {
$statusCode = $exception->getCode() ?: 500; $statusCode = $exception->getCode() ?: 500;
$message = $statusCode === 404
? 'La page demandée est introuvable.'
: $message;
} }
$response = $this->app->getResponseFactory()->createResponse($statusCode); $response = $app->getResponseFactory()->createResponse($statusCode);
$twig = $this->container->get(Twig::class); $twig = $container->get(Twig::class);
return $twig->render($response, 'pages/error.twig', [ return $twig->render($response, 'pages/error.twig', [
'status' => $statusCode, 'status' => $statusCode,
'message' => $statusCode === 404 'message' => $message,
? 'La page demandée est introuvable.'
: 'Une erreur inattendue s\'est produite.',
]); ]);
} }
); );

View File

@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
namespace App\Shared\Database;
final class DatabaseNotProvisionedException extends \RuntimeException
{
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Shared\Database;
use PDO;
/**
* Vérifie que le schéma minimal existe avant de servir des requêtes HTTP.
*
* Le runtime web ne provisionne pas automatiquement la base : les migrations
* et le seed initial doivent être exécutés explicitement via `php bin/provision.php`.
*/
final class DatabaseReadiness
{
/**
* @var string[]
*/
private const REQUIRED_TABLES = ['migrations', 'users', 'posts'];
public static function assertProvisioned(PDO $db): void
{
$existingTables = self::existingTables($db);
$missingTables = array_values(array_diff(self::REQUIRED_TABLES, $existingTables));
if ($missingTables === []) {
return;
}
throw new DatabaseNotProvisionedException(
sprintf(
"Base de donnees non provisionnee : tables manquantes (%s). Executez 'php bin/provision.php' avant de servir l'application.",
implode(', ', $missingTables)
)
);
}
/**
* @return string[]
*/
private static function existingTables(PDO $db): array
{
$stmt = $db->query("SELECT name FROM sqlite_master WHERE type = 'table'");
$rows = $stmt ? $stmt->fetchAll(PDO::FETCH_COLUMN) : [];
return array_values(array_filter(array_map(
static fn (mixed $value): string => (string) $value,
$rows
)));
}
}

View File

@@ -24,8 +24,8 @@ final class Seeder
/** /**
* Exécute toutes les opérations de provisionnement. * Exécute toutes les opérations de provisionnement.
* *
* Appelé dans Bootstrap::initialize() après Migrator::run(), une fois * Appelé par le script de provisionnement après Migrator::run(),
* que le schéma est garanti à jour. * une fois que le schéma est garanti à jour.
* *
* @param PDO $db L'instance de connexion à la base de données * @param PDO $db L'instance de connexion à la base de données
*/ */

View File

@@ -23,36 +23,6 @@ final class ClientIpResolver
public function resolve(ServerRequestInterface $request): string public function resolve(ServerRequestInterface $request): string
{ {
$serverParams = $request->getServerParams(); return RequestContext::resolveClientIp($request->getServerParams(), $this->trustedProxies);
$remoteAddr = trim((string) ($serverParams['REMOTE_ADDR'] ?? ''));
if ($remoteAddr === '') {
return '0.0.0.0';
}
if (!$this->isTrustedProxy($remoteAddr)) {
return $remoteAddr;
}
$forwarded = trim((string) ($serverParams['HTTP_X_FORWARDED_FOR'] ?? ''));
if ($forwarded === '') {
return $remoteAddr;
}
$candidate = trim(explode(',', $forwarded)[0]);
return filter_var($candidate, FILTER_VALIDATE_IP) ? $candidate : $remoteAddr;
}
private function isTrustedProxy(string $remoteAddr): bool
{
foreach ($this->trustedProxies as $proxy) {
if ($proxy === '*' || $proxy === $remoteAddr) {
return true;
}
}
return false;
} }
} }

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Shared\Http;
/**
* Helpers liés au contexte HTTP courant (IP cliente, HTTPS, proxies de confiance).
*
* Les en-têtes X-Forwarded-* ne sont pris en compte que si REMOTE_ADDR
* correspond à un proxy explicitement approuvé.
*/
final class RequestContext
{
/**
* @param array<string, mixed> $serverParams
* @param string[] $trustedProxies
*/
public static function resolveClientIp(array $serverParams, array $trustedProxies = []): string
{
$remoteAddr = trim((string) ($serverParams['REMOTE_ADDR'] ?? ''));
if ($remoteAddr === '') {
return '0.0.0.0';
}
if (!self::isTrustedProxy($remoteAddr, $trustedProxies)) {
return $remoteAddr;
}
$forwarded = self::firstForwardedValue($serverParams, 'HTTP_X_FORWARDED_FOR');
return $forwarded !== null && filter_var($forwarded, FILTER_VALIDATE_IP)
? $forwarded
: $remoteAddr;
}
/**
* @param array<string, mixed> $serverParams
* @param string[] $trustedProxies
*/
public static function isHttps(array $serverParams, array $trustedProxies = []): bool
{
$https = strtolower(trim((string) ($serverParams['HTTPS'] ?? '')));
if ($https !== '' && $https !== 'off' && $https !== '0') {
return true;
}
$scheme = strtolower(trim((string) ($serverParams['REQUEST_SCHEME'] ?? '')));
if ($scheme === 'https') {
return true;
}
if ((string) ($serverParams['SERVER_PORT'] ?? '') === '443') {
return true;
}
$remoteAddr = trim((string) ($serverParams['REMOTE_ADDR'] ?? ''));
if ($remoteAddr === '' || !self::isTrustedProxy($remoteAddr, $trustedProxies)) {
return false;
}
$forwardedProto = self::firstForwardedValue($serverParams, 'HTTP_X_FORWARDED_PROTO');
return $forwardedProto === 'https';
}
/**
* @param array<string, mixed> $environment
* @param array<string, mixed> $serverParams
* @return string[]
*/
public static function trustedProxiesFromEnvironment(array $environment = [], array $serverParams = []): array
{
$candidates = [
$environment['TRUSTED_PROXIES'] ?? null,
$serverParams['TRUSTED_PROXIES'] ?? null,
getenv('TRUSTED_PROXIES') ?: null,
];
foreach ($candidates as $candidate) {
if ($candidate === null) {
continue;
}
$raw = trim((string) $candidate);
if ($raw === '') {
continue;
}
return array_values(array_filter(array_map(
static fn (string $value): string => trim($value),
explode(',', $raw)
), static fn (string $value): bool => $value !== ''));
}
return [];
}
/**
* @param string[] $trustedProxies
*/
private static function isTrustedProxy(string $remoteAddr, array $trustedProxies): bool
{
foreach ($trustedProxies as $proxy) {
if ($proxy === '*' || $proxy === $remoteAddr) {
return true;
}
}
return false;
}
/**
* @param array<string, mixed> $serverParams
*/
private static function firstForwardedValue(array $serverParams, string $header): ?string
{
$raw = trim((string) ($serverParams[$header] ?? ''));
if ($raw === '') {
return null;
}
return strtolower(trim(explode(',', $raw)[0]));
}
}

View File

@@ -95,10 +95,12 @@ final class SessionManager implements SessionManagerInterface
$sessionName = session_name(); $sessionName = session_name();
if ($sessionName !== false) { if ($sessionName !== false) {
$trustedProxies = RequestContext::trustedProxiesFromEnvironment($_ENV, $_SERVER);
setcookie($sessionName, '', [ setcookie($sessionName, '', [
'expires' => time() - 3600, 'expires' => time() - 3600,
'path' => '/', 'path' => '/',
'secure' => !empty($_SERVER['HTTPS']), 'secure' => RequestContext::isHttps($_SERVER, $trustedProxies),
'httponly' => true, 'httponly' => true,
'samesite' => 'Lax', 'samesite' => 'Lax',
]); ]);

View File

@@ -7,6 +7,7 @@ use App\Auth\AuthServiceInterface;
use App\Auth\PasswordResetController; use App\Auth\PasswordResetController;
use App\Auth\Exception\InvalidResetTokenException; use App\Auth\Exception\InvalidResetTokenException;
use App\Auth\PasswordResetServiceInterface; use App\Auth\PasswordResetServiceInterface;
use App\Shared\Http\ClientIpResolver;
use App\Shared\Http\FlashServiceInterface; use App\Shared\Http\FlashServiceInterface;
use App\User\Exception\WeakPasswordException; use App\User\Exception\WeakPasswordException;
use App\User\User; use App\User\User;
@@ -42,6 +43,7 @@ final class PasswordResetControllerTest extends ControllerTestCase
/** @var FlashServiceInterface&MockObject */ /** @var FlashServiceInterface&MockObject */
private FlashServiceInterface $flash; private FlashServiceInterface $flash;
private ClientIpResolver $clientIpResolver;
private PasswordResetController $controller; private PasswordResetController $controller;
private const BASE_URL = 'https://example.com'; private const BASE_URL = 'https://example.com';
@@ -52,6 +54,7 @@ final class PasswordResetControllerTest extends ControllerTestCase
$this->passwordResetService = $this->createMock(PasswordResetServiceInterface::class); $this->passwordResetService = $this->createMock(PasswordResetServiceInterface::class);
$this->authService = $this->createMock(AuthServiceInterface::class); $this->authService = $this->createMock(AuthServiceInterface::class);
$this->flash = $this->createMock(FlashServiceInterface::class); $this->flash = $this->createMock(FlashServiceInterface::class);
$this->clientIpResolver = new ClientIpResolver(['*']);
// Par défaut : IP non verrouillée // Par défaut : IP non verrouillée
$this->authService->method('checkRateLimit')->willReturn(0); $this->authService->method('checkRateLimit')->willReturn(0);
@@ -61,6 +64,7 @@ final class PasswordResetControllerTest extends ControllerTestCase
$this->passwordResetService, $this->passwordResetService,
$this->authService, $this->authService,
$this->flash, $this->flash,
$this->clientIpResolver,
self::BASE_URL, self::BASE_URL,
); );
} }
@@ -97,20 +101,27 @@ final class PasswordResetControllerTest extends ControllerTestCase
public function testForgotRedirectsWhenRateLimited(): void public function testForgotRedirectsWhenRateLimited(): void
{ {
$authService = $this->createMock(AuthServiceInterface::class); $authService = $this->createMock(AuthServiceInterface::class);
$authService->method('checkRateLimit')->willReturn(10); $authService->expects($this->once())
->method('checkRateLimit')
->with('203.0.113.5')
->willReturn(10);
$controller = new PasswordResetController( $controller = new PasswordResetController(
$this->view, $this->view,
$this->passwordResetService, $this->passwordResetService,
$authService, $authService,
$this->flash, $this->flash,
$this->clientIpResolver,
self::BASE_URL, self::BASE_URL,
); );
$this->flash->expects($this->once())->method('set') $this->flash->expects($this->once())->method('set')
->with('reset_error', $this->stringContains('Trop de demandes')); ->with('reset_error', $this->stringContains('Trop de demandes'));
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']); $req = $this->makePost('/password/forgot', ['email' => 'alice@example.com'], [
'REMOTE_ADDR' => '127.0.0.1',
'HTTP_X_FORWARDED_FOR' => '203.0.113.5, 198.51.100.12',
]);
$res = $controller->forgot($req, $this->makeResponse()); $res = $controller->forgot($req, $this->makeResponse());
$this->assertRedirectTo($res, '/password/forgot'); $this->assertRedirectTo($res, '/password/forgot');
@@ -122,19 +133,26 @@ final class PasswordResetControllerTest extends ControllerTestCase
public function testForgotDoesNotCallServiceWhenRateLimited(): void public function testForgotDoesNotCallServiceWhenRateLimited(): void
{ {
$authService = $this->createMock(AuthServiceInterface::class); $authService = $this->createMock(AuthServiceInterface::class);
$authService->method('checkRateLimit')->willReturn(5); $authService->expects($this->once())
->method('checkRateLimit')
->with('203.0.113.5')
->willReturn(5);
$controller = new PasswordResetController( $controller = new PasswordResetController(
$this->view, $this->view,
$this->passwordResetService, $this->passwordResetService,
$authService, $authService,
$this->flash, $this->flash,
$this->clientIpResolver,
self::BASE_URL, self::BASE_URL,
); );
$this->passwordResetService->expects($this->never())->method('requestReset'); $this->passwordResetService->expects($this->never())->method('requestReset');
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']); $req = $this->makePost('/password/forgot', ['email' => 'alice@example.com'], [
'REMOTE_ADDR' => '127.0.0.1',
'HTTP_X_FORWARDED_FOR' => '203.0.113.5, 198.51.100.12',
]);
$controller->forgot($req, $this->makeResponse()); $controller->forgot($req, $this->makeResponse());
} }
@@ -146,9 +164,14 @@ final class PasswordResetControllerTest extends ControllerTestCase
*/ */
public function testForgotAlwaysRecordsFailure(): void public function testForgotAlwaysRecordsFailure(): void
{ {
$this->authService->expects($this->once())->method('recordFailure'); $this->authService->expects($this->once())
->method('recordFailure')
->with('203.0.113.5');
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']); $req = $this->makePost('/password/forgot', ['email' => 'alice@example.com'], [
'REMOTE_ADDR' => '127.0.0.1',
'HTTP_X_FORWARDED_FOR' => '203.0.113.5, 198.51.100.12',
]);
$this->controller->forgot($req, $this->makeResponse()); $this->controller->forgot($req, $this->makeResponse());
} }

View File

@@ -14,25 +14,6 @@ use Slim\App;
final class BootstrapTest extends TestCase final class BootstrapTest extends TestCase
{ {
private array $envBackup = [];
protected function setUp(): void
{
$this->envBackup = [
'APP_AUTO_PROVISION' => $_ENV['APP_AUTO_PROVISION'] ?? null,
];
}
protected function tearDown(): void
{
foreach ($this->envBackup as $key => $value) {
if ($value === null) {
unset($_ENV[$key]);
} else {
$_ENV[$key] = $value;
}
}
}
public function testInitializeInfrastructureReturnsPreloadedContainer(): void public function testInitializeInfrastructureReturnsPreloadedContainer(): void
{ {
@@ -55,10 +36,8 @@ final class BootstrapTest extends TestCase
self::assertSame($app, $bootstrap->createHttpApp()); self::assertSame($app, $bootstrap->createHttpApp());
} }
public function testInitializeReturnsPreloadedAppWhenAutoProvisionIsDisabled(): void public function testInitializeReturnsPreloadedApp(): void
{ {
$_ENV['APP_AUTO_PROVISION'] = '0';
$bootstrap = Bootstrap::create(); $bootstrap = Bootstrap::create();
$container = $this->createStub(ContainerInterface::class); $container = $this->createStub(ContainerInterface::class);
$app = AppFactory::create(); $app = AppFactory::create();

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Http\RequestContext;
use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class RequestContextTest extends TestCase
{
public function testIsHttpsReturnsTrueWhenNativeHttpsFlagIsEnabled(): void
{
self::assertTrue(RequestContext::isHttps([
'HTTPS' => 'on',
]));
}
public function testIsHttpsReturnsTrueWhenTrustedProxyForwardsHttps(): void
{
self::assertTrue(RequestContext::isHttps([
'REMOTE_ADDR' => '127.0.0.1',
'HTTP_X_FORWARDED_PROTO' => 'https, http',
], ['127.0.0.1']));
}
public function testIsHttpsIgnoresForwardedProtoWhenProxyIsNotTrusted(): void
{
self::assertFalse(RequestContext::isHttps([
'REMOTE_ADDR' => '10.0.0.5',
'HTTP_X_FORWARDED_PROTO' => 'https',
], ['127.0.0.1']));
}
public function testTrustedProxiesFromEnvironmentTrimsValues(): void
{
self::assertSame(['127.0.0.1', '::1'], RequestContext::trustedProxiesFromEnvironment([
'TRUSTED_PROXIES' => ' 127.0.0.1 , ::1 ',
]));
}
public function testTrustedProxiesFromEnvironmentFallsBackToProcessEnvWhenDotenvValueIsBlank(): void
{
putenv('TRUSTED_PROXIES=*');
try {
self::assertSame(['*'], RequestContext::trustedProxiesFromEnvironment([
'TRUSTED_PROXIES' => '',
]));
} finally {
putenv('TRUSTED_PROXIES');
}
}
}