Working state but no uploads
This commit is contained in:
@@ -16,11 +16,17 @@ APP_URL=http://localhost:8080
|
||||
# Fuseau horaire
|
||||
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
|
||||
# =============================================================================
|
||||
|
||||
# Compte administrateur (créé automatiquement au premier démarrage)
|
||||
# Compte administrateur (créé lors du provisionnement initial)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=changeme123
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
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
|
||||
|
||||
|
||||
18
README.md
18
README.md
@@ -1,6 +1,6 @@
|
||||
# Slim Blog
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -38,7 +38,7 @@ projets (boutique, portfolio…).
|
||||
|
||||
## 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
|
||||
git clone https://git.netig.net/netig/slim-blog
|
||||
@@ -46,6 +46,7 @@ cd slim-blog
|
||||
composer install
|
||||
npm install && npm run build
|
||||
cp .env.example .env
|
||||
php bin/provision.php
|
||||
php -S localhost:8080 -t public
|
||||
```
|
||||
|
||||
@@ -76,6 +77,7 @@ cd slim-blog
|
||||
cp .env.example .env
|
||||
# Définir APP_ENV=production, APP_URL, ADMIN_PASSWORD et la configuration SMTP
|
||||
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.
|
||||
@@ -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`).
|
||||
|
||||
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 :
|
||||
|
||||
```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_NAME` | Nom du blog (flux RSS, emails) | `Slim Blog` |
|
||||
| `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_EMAIL` | Email du compte admin | `admin@example.com` |
|
||||
| `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
|
||||
|
||||
Le provisionnement (migrations + seed admin) peut etre 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.
|
||||
Le provisionnement (migrations + seed admin) s'execute explicitement via `php bin/provision.php`.
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -8,10 +8,6 @@ require __DIR__ . '/../vendor/autoload.php';
|
||||
use App\Shared\Bootstrap;
|
||||
use App\Shared\Database\Provisioner;
|
||||
|
||||
$_ENV['APP_AUTO_PROVISION'] = '0';
|
||||
|
||||
session_status() === PHP_SESSION_ACTIVE || session_start();
|
||||
|
||||
$bootstrap = Bootstrap::create();
|
||||
$container = $bootstrap->initializeInfrastructure();
|
||||
Provisioner::run($container->get(\PDO::class));
|
||||
|
||||
@@ -172,12 +172,14 @@ return [
|
||||
PasswordResetServiceInterface $passwordResetService,
|
||||
AuthServiceInterface $authService,
|
||||
FlashServiceInterface $flash,
|
||||
ClientIpResolver $clientIpResolver,
|
||||
): PasswordResetController {
|
||||
return new PasswordResetController(
|
||||
$twig,
|
||||
$passwordResetService,
|
||||
$authService,
|
||||
$flash,
|
||||
$clientIpResolver,
|
||||
rtrim($_ENV['APP_URL'] ?? 'http://localhost', '/'),
|
||||
);
|
||||
}
|
||||
|
||||
0
database/.provision.lock
Normal file
0
database/.provision.lock
Normal file
@@ -28,7 +28,10 @@ services:
|
||||
env_file: .env
|
||||
|
||||
# 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
|
||||
# (sync public/, migrations, seed) avant que les échecs ne comptent.
|
||||
healthcheck:
|
||||
|
||||
@@ -11,7 +11,7 @@ COPY assets/ assets/
|
||||
RUN npm run build
|
||||
|
||||
# ── 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
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
@@ -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.
|
||||
|
||||
```php
|
||||
// 0. Résolution de l'IP réelle derrière un reverse proxy (Caddy/Nginx)
|
||||
// En production Docker, REMOTE_ADDR retourne l'IP interne du proxy.
|
||||
// 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');
|
||||
// 0. Résolution de l'IP réelle derrière un reverse proxy approuvé
|
||||
$ip = $this->clientIpResolver->resolve($req);
|
||||
|
||||
// 1. Vérification du rate limit (avant toute authentification)
|
||||
$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.
|
||||
>
|
||||
> ⚠️ 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
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`) :
|
||||
|
||||
@@ -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_URL | URL publique (liens e-mails, flux RSS) | `https://blog.exemple.com` |
|
||||
| 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_EMAIL | E-mail du compte admin | `admin@example.com` |
|
||||
| ADMIN_PASSWORD | Mot de passe admin (obligatoire en production) | *(à changer)* |
|
||||
|
||||
@@ -4,12 +4,18 @@ declare(strict_types=1);
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
use App\Shared\Bootstrap;
|
||||
use App\Shared\Http\RequestContext;
|
||||
|
||||
$bootstrap = Bootstrap::create();
|
||||
$bootstrap->initializeInfrastructure();
|
||||
|
||||
$trustedProxies = RequestContext::trustedProxiesFromEnvironment($_ENV, $_SERVER);
|
||||
|
||||
session_start([
|
||||
'cookie_secure' => !empty($_SERVER['HTTPS']),
|
||||
'cookie_secure' => RequestContext::isHttps($_SERVER, $trustedProxies),
|
||||
'cookie_httponly' => true,
|
||||
'cookie_samesite' => 'Lax',
|
||||
]);
|
||||
|
||||
$app = Bootstrap::create()->initialize();
|
||||
$app = $bootstrap->createHttpApp();
|
||||
$app->run();
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace App\Auth;
|
||||
|
||||
use App\Auth\Exception\InvalidResetTokenException;
|
||||
use App\Shared\Http\ClientIpResolver;
|
||||
use App\Shared\Http\FlashServiceInterface;
|
||||
use App\User\Exception\WeakPasswordException;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
@@ -34,6 +35,7 @@ final class PasswordResetController
|
||||
* @param PasswordResetServiceInterface $passwordResetService Service de réinitialisation
|
||||
* @param AuthServiceInterface $authService Service d'authentification (rate limiting)
|
||||
* @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)
|
||||
*/
|
||||
public function __construct(
|
||||
@@ -41,6 +43,7 @@ final class PasswordResetController
|
||||
private readonly PasswordResetServiceInterface $passwordResetService,
|
||||
private readonly AuthServiceInterface $authService,
|
||||
private readonly FlashServiceInterface $flash,
|
||||
private readonly ClientIpResolver $clientIpResolver,
|
||||
private readonly string $baseUrl,
|
||||
) {
|
||||
}
|
||||
@@ -80,13 +83,7 @@ final class PasswordResetController
|
||||
*/
|
||||
public function forgot(Request $req, Response $res): Response
|
||||
{
|
||||
// Résolution de l'IP réelle derrière un reverse proxy (Caddy/Nginx).
|
||||
// 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');
|
||||
$ip = $this->clientIpResolver->resolve($req);
|
||||
|
||||
// Vérification du rate limit avant tout traitement
|
||||
$remainingMinutes = $this->authService->checkRateLimit($ip);
|
||||
|
||||
@@ -4,7 +4,8 @@ declare(strict_types=1);
|
||||
namespace App\Shared;
|
||||
|
||||
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\CsrfExtension;
|
||||
use App\Shared\Extension\SessionExtension;
|
||||
@@ -14,6 +15,7 @@ use PDO;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Slim\App;
|
||||
use Slim\Csrf\Guard;
|
||||
@@ -40,7 +42,6 @@ final class Bootstrap
|
||||
public function initialize(): App
|
||||
{
|
||||
$this->initializeInfrastructure();
|
||||
$this->runAutoProvisioningIfEnabled();
|
||||
|
||||
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
|
||||
{
|
||||
$this->app->addBodyParsingMiddleware();
|
||||
@@ -168,6 +154,17 @@ final class Bootstrap
|
||||
$guard->setPersistentTokenMode(true);
|
||||
$twig->addExtension(new CsrfExtension($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
|
||||
@@ -180,6 +177,8 @@ final class Bootstrap
|
||||
$isDev = strtolower($_ENV['APP_ENV'] ?? 'production') === 'development';
|
||||
$logger = $this->container->get(LoggerInterface::class);
|
||||
$errorHandler = $this->app->addErrorMiddleware($isDev, true, true, $logger);
|
||||
$app = $this->app;
|
||||
$container = $this->container;
|
||||
|
||||
$errorHandler->setDefaultErrorHandler(
|
||||
function (
|
||||
@@ -188,24 +187,30 @@ final class Bootstrap
|
||||
bool $displayErrorDetails,
|
||||
bool $logErrors,
|
||||
bool $logErrorDetails,
|
||||
) use ($isDev): ResponseInterface {
|
||||
if ($isDev) {
|
||||
) use ($isDev, $app, $container): ResponseInterface {
|
||||
if ($isDev && !$exception instanceof DatabaseNotProvisionedException) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$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;
|
||||
$message = $statusCode === 404
|
||||
? 'La page demandée est introuvable.'
|
||||
: $message;
|
||||
}
|
||||
|
||||
$response = $this->app->getResponseFactory()->createResponse($statusCode);
|
||||
$twig = $this->container->get(Twig::class);
|
||||
$response = $app->getResponseFactory()->createResponse($statusCode);
|
||||
$twig = $container->get(Twig::class);
|
||||
|
||||
return $twig->render($response, 'pages/error.twig', [
|
||||
'status' => $statusCode,
|
||||
'message' => $statusCode === 404
|
||||
? 'La page demandée est introuvable.'
|
||||
: 'Une erreur inattendue s\'est produite.',
|
||||
'message' => $message,
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
8
src/Shared/Database/DatabaseNotProvisionedException.php
Normal file
8
src/Shared/Database/DatabaseNotProvisionedException.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Database;
|
||||
|
||||
final class DatabaseNotProvisionedException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
51
src/Shared/Database/DatabaseReadiness.php
Normal file
51
src/Shared/Database/DatabaseReadiness.php
Normal 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
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,8 @@ final class Seeder
|
||||
/**
|
||||
* Exécute toutes les opérations de provisionnement.
|
||||
*
|
||||
* Appelé dans Bootstrap::initialize() après Migrator::run(), une fois
|
||||
* que le schéma est garanti à jour.
|
||||
* Appelé par le script de provisionnement après Migrator::run(),
|
||||
* une fois que le schéma est garanti à jour.
|
||||
*
|
||||
* @param PDO $db L'instance de connexion à la base de données
|
||||
*/
|
||||
|
||||
@@ -23,36 +23,6 @@ final class ClientIpResolver
|
||||
|
||||
public function resolve(ServerRequestInterface $request): string
|
||||
{
|
||||
$serverParams = $request->getServerParams();
|
||||
$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;
|
||||
return RequestContext::resolveClientIp($request->getServerParams(), $this->trustedProxies);
|
||||
}
|
||||
}
|
||||
|
||||
127
src/Shared/Http/RequestContext.php
Normal file
127
src/Shared/Http/RequestContext.php
Normal 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]));
|
||||
}
|
||||
}
|
||||
@@ -95,10 +95,12 @@ final class SessionManager implements SessionManagerInterface
|
||||
$sessionName = session_name();
|
||||
|
||||
if ($sessionName !== false) {
|
||||
$trustedProxies = RequestContext::trustedProxiesFromEnvironment($_ENV, $_SERVER);
|
||||
|
||||
setcookie($sessionName, '', [
|
||||
'expires' => time() - 3600,
|
||||
'path' => '/',
|
||||
'secure' => !empty($_SERVER['HTTPS']),
|
||||
'secure' => RequestContext::isHttps($_SERVER, $trustedProxies),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Auth\AuthServiceInterface;
|
||||
use App\Auth\PasswordResetController;
|
||||
use App\Auth\Exception\InvalidResetTokenException;
|
||||
use App\Auth\PasswordResetServiceInterface;
|
||||
use App\Shared\Http\ClientIpResolver;
|
||||
use App\Shared\Http\FlashServiceInterface;
|
||||
use App\User\Exception\WeakPasswordException;
|
||||
use App\User\User;
|
||||
@@ -42,6 +43,7 @@ final class PasswordResetControllerTest extends ControllerTestCase
|
||||
/** @var FlashServiceInterface&MockObject */
|
||||
private FlashServiceInterface $flash;
|
||||
|
||||
private ClientIpResolver $clientIpResolver;
|
||||
private PasswordResetController $controller;
|
||||
|
||||
private const BASE_URL = 'https://example.com';
|
||||
@@ -52,6 +54,7 @@ final class PasswordResetControllerTest extends ControllerTestCase
|
||||
$this->passwordResetService = $this->createMock(PasswordResetServiceInterface::class);
|
||||
$this->authService = $this->createMock(AuthServiceInterface::class);
|
||||
$this->flash = $this->createMock(FlashServiceInterface::class);
|
||||
$this->clientIpResolver = new ClientIpResolver(['*']);
|
||||
|
||||
// Par défaut : IP non verrouillée
|
||||
$this->authService->method('checkRateLimit')->willReturn(0);
|
||||
@@ -61,6 +64,7 @@ final class PasswordResetControllerTest extends ControllerTestCase
|
||||
$this->passwordResetService,
|
||||
$this->authService,
|
||||
$this->flash,
|
||||
$this->clientIpResolver,
|
||||
self::BASE_URL,
|
||||
);
|
||||
}
|
||||
@@ -97,20 +101,27 @@ final class PasswordResetControllerTest extends ControllerTestCase
|
||||
public function testForgotRedirectsWhenRateLimited(): void
|
||||
{
|
||||
$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(
|
||||
$this->view,
|
||||
$this->passwordResetService,
|
||||
$authService,
|
||||
$this->flash,
|
||||
$this->clientIpResolver,
|
||||
self::BASE_URL,
|
||||
);
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->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());
|
||||
|
||||
$this->assertRedirectTo($res, '/password/forgot');
|
||||
@@ -122,19 +133,26 @@ final class PasswordResetControllerTest extends ControllerTestCase
|
||||
public function testForgotDoesNotCallServiceWhenRateLimited(): void
|
||||
{
|
||||
$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(
|
||||
$this->view,
|
||||
$this->passwordResetService,
|
||||
$authService,
|
||||
$this->flash,
|
||||
$this->clientIpResolver,
|
||||
self::BASE_URL,
|
||||
);
|
||||
|
||||
$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());
|
||||
}
|
||||
|
||||
@@ -146,9 +164,14 @@ final class PasswordResetControllerTest extends ControllerTestCase
|
||||
*/
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
@@ -14,25 +14,6 @@ use Slim\App;
|
||||
|
||||
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
|
||||
{
|
||||
@@ -55,10 +36,8 @@ final class BootstrapTest extends TestCase
|
||||
self::assertSame($app, $bootstrap->createHttpApp());
|
||||
}
|
||||
|
||||
public function testInitializeReturnsPreloadedAppWhenAutoProvisionIsDisabled(): void
|
||||
public function testInitializeReturnsPreloadedApp(): void
|
||||
{
|
||||
$_ENV['APP_AUTO_PROVISION'] = '0';
|
||||
|
||||
$bootstrap = Bootstrap::create();
|
||||
$container = $this->createStub(ContainerInterface::class);
|
||||
$app = AppFactory::create();
|
||||
|
||||
55
tests/Shared/RequestContextTest.php
Normal file
55
tests/Shared/RequestContextTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user