Working state but no uploads
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -1,6 +1,6 @@
|
|||||||
# Slim Blog
|
# Slim Blog
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
0
database/.provision.lock
Normal 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:
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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)* |
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.',
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
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.
|
* 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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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();
|
$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',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
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