feat(core): accesseurs form/IP du Request, Database::transaction + DatabaseInterface, getters Response
Request::formBody() decode un POST urlencode (le login back-office est un formulaire, pas du JSON) ; Request::clientIp() resout l'IP client reelle derriere Traefik (dernier hop X-Forwarded-For valide, repli REMOTE_ADDR). Database::transaction() enveloppe un jeu d'ecritures dans un begin/commit atomique avec rollback sur exception (RG-T08) ; DatabaseInterface extrait le seam d'acces aux donnees pour rendre les services testables avec un double. Response gagne des accesseurs en lecture (body/header/headers) pour les tests de controleur. Tout est additif et retro-compatible.
This commit is contained in:
parent
8fb4fdf743
commit
a499607add
5 changed files with 286 additions and 1 deletions
|
|
@ -6,6 +6,7 @@ namespace App\Core;
|
||||||
|
|
||||||
use PDO;
|
use PDO;
|
||||||
use PDOStatement;
|
use PDOStatement;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enveloppe PDO MariaDB, requetes preparees exclusivement (anti-SQLi, Cr 4.e.1).
|
* Enveloppe PDO MariaDB, requetes preparees exclusivement (anti-SQLi, Cr 4.e.1).
|
||||||
|
|
@ -14,7 +15,7 @@ use PDOStatement;
|
||||||
* routes sans BDD (ex : la home back-office) fonctionnent meme si la base est
|
* routes sans BDD (ex : la home back-office) fonctionnent meme si la base est
|
||||||
* indisponible.
|
* indisponible.
|
||||||
*/
|
*/
|
||||||
final class Database
|
final class Database implements DatabaseInterface
|
||||||
{
|
{
|
||||||
private ?PDO $pdo = null;
|
private ?PDO $pdo = null;
|
||||||
|
|
||||||
|
|
@ -91,4 +92,30 @@ final class Database
|
||||||
{
|
{
|
||||||
return $this->query($sql, $params)->rowCount();
|
return $this->query($sql, $params)->rowCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute $fn dans une transaction atomique (RG-T08) : begin -> $fn -> commit.
|
||||||
|
* Tout Throwable declenche un rollback complet puis est repropage : jamais
|
||||||
|
* d'ecriture partielle, jamais d'echec silencieux. Le retour est void (et non
|
||||||
|
* mixed) pour rester strictement type sous PHPStan ; $fn ecrit via le $this
|
||||||
|
* qui lui est passe (memes requetes preparees, meme connexion).
|
||||||
|
*
|
||||||
|
* @param callable(DatabaseInterface): void $fn
|
||||||
|
*/
|
||||||
|
public function transaction(callable $fn): void
|
||||||
|
{
|
||||||
|
$pdo = $this->pdo();
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$fn($this);
|
||||||
|
$pdo->commit();
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
if ($pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
43
src/app/Core/DatabaseInterface.php
Normal file
43
src/app/Core/DatabaseInterface.php
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat d'acces aux donnees consomme par les services applicatifs (auth...).
|
||||||
|
* Il expose uniquement les operations dont ils ont besoin (lecture, ecriture,
|
||||||
|
* transaction atomique) sans la primitive bas niveau query()/PDOStatement.
|
||||||
|
*
|
||||||
|
* Raison d'etre : permettre aux services securite-critiques (AuthService,
|
||||||
|
* PasswordResetService, SessionGuard) d'etre testes unitairement avec un double
|
||||||
|
* en memoire, tout en gardant la classe Database concrete `final`. Le seul autre
|
||||||
|
* implementeur est ce double de test : interface justifiee, pas speculative.
|
||||||
|
*/
|
||||||
|
interface DatabaseInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string|int, mixed> $params
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function fetch(string $sql, array $params = []): ?array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string|int, mixed> $params
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function fetchAll(string $sql, array $params = []): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string|int, mixed> $params
|
||||||
|
*/
|
||||||
|
public function execute(string $sql, array $params = []): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute $fn dans une transaction atomique : commit si succes, rollback
|
||||||
|
* complet sur tout Throwable (puis repropagation).
|
||||||
|
*
|
||||||
|
* @param callable(DatabaseInterface): void $fn
|
||||||
|
*/
|
||||||
|
public function transaction(callable $fn): void;
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,10 @@ final class Request
|
||||||
private readonly array $query,
|
private readonly array $query,
|
||||||
private readonly array $headers,
|
private readonly array $headers,
|
||||||
private readonly string $rawBody,
|
private readonly string $rawBody,
|
||||||
|
// Adresse de la connexion TCP entrante (le proxy Traefik en frontal).
|
||||||
|
// Defaut vide pour conserver la compatibilite des appels a 5 arguments
|
||||||
|
// (tests existants). clientIp() s'en sert comme repli derriere X-Forwarded-For.
|
||||||
|
private readonly string $remoteAddr = '',
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,6 +48,7 @@ final class Request
|
||||||
$query,
|
$query,
|
||||||
self::extractHeaders(),
|
self::extractHeaders(),
|
||||||
(string) file_get_contents('php://input'),
|
(string) file_get_contents('php://input'),
|
||||||
|
(string) ($_SERVER['REMOTE_ADDR'] ?? ''),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,4 +147,68 @@ final class Request
|
||||||
|
|
||||||
return is_array($decoded) ? $decoded : [];
|
return is_array($decoded) ? $decoded : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode un corps application/x-www-form-urlencoded en map cle => valeur.
|
||||||
|
* Symetrique de json() : renvoie [] si le content-type n'est pas un
|
||||||
|
* formulaire urlencode, pour laisser la validation metier decider (pas de
|
||||||
|
* fatale ici). Le back-office se connecte par formulaire POST, pas par JSON.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function formBody(): array
|
||||||
|
{
|
||||||
|
$contentType = $this->header('content-type') ?? '';
|
||||||
|
|
||||||
|
if (!str_starts_with($contentType, 'application/x-www-form-urlencoded')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_str($this->rawBody, $parsed);
|
||||||
|
|
||||||
|
// parse_str peut produire des valeurs tableau (cle[]=...) ; on ne retient
|
||||||
|
// que les scalaires convertis en chaine pour tenir le contrat strict
|
||||||
|
// array<string, string> (et neutraliser une cle de type "champ[]").
|
||||||
|
$form = [];
|
||||||
|
foreach ($parsed as $key => $value) {
|
||||||
|
if (is_scalar($value)) {
|
||||||
|
$form[(string) $key] = (string) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $form;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP client reelle derriere le reverse proxy Traefik. REMOTE_ADDR est ici
|
||||||
|
* toujours l'adresse du proxy, donc on lit X-Forwarded-For et on retient le
|
||||||
|
* DERNIER hop : c'est celui ajoute par Traefik (proxy de confiance), tandis
|
||||||
|
* que les entrees de gauche sont fournies par le client et donc falsifiables.
|
||||||
|
* La valeur est validee par FILTER_VALIDATE_IP et bornee a 45 caracteres
|
||||||
|
* (taille de login_throttle.ip_address). Repli sur REMOTE_ADDR si l'en-tete
|
||||||
|
* est absent ou invalide ; sentinelle 0.0.0.0 en dernier recours.
|
||||||
|
*
|
||||||
|
* Hypothese de deploiement : un unique proxy de confiance (Traefik) est
|
||||||
|
* toujours en frontal. Sans lui, X-Forwarded-For serait falsifiable ; le
|
||||||
|
* verrou par compte (failed_login_attempts) reste alors le garde-fou.
|
||||||
|
*/
|
||||||
|
public function clientIp(): string
|
||||||
|
{
|
||||||
|
$forwarded = $this->header('x-forwarded-for');
|
||||||
|
|
||||||
|
if ($forwarded !== null && $forwarded !== '') {
|
||||||
|
$hops = explode(',', $forwarded);
|
||||||
|
$candidate = trim((string) end($hops));
|
||||||
|
|
||||||
|
if (filter_var($candidate, FILTER_VALIDATE_IP) !== false) {
|
||||||
|
return substr($candidate, 0, 45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->remoteAddr !== '' && filter_var($this->remoteAddr, FILTER_VALIDATE_IP) !== false) {
|
||||||
|
return substr($this->remoteAddr, 0, 45);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '0.0.0.0';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,24 @@ final class Response
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function body(): string
|
||||||
|
{
|
||||||
|
return $this->body;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function header(string $name): ?string
|
||||||
|
{
|
||||||
|
return $this->headers[$name] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function headers(): array
|
||||||
|
{
|
||||||
|
return $this->headers;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, string> $headers
|
* @param array<string, string> $headers
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
128
tests/Unit/Core/RequestFormBodyTest.php
Normal file
128
tests/Unit/Core/RequestFormBodyTest.php
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Core;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use App\Core\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Couvre les deux accesseurs ajoutes au Request pour l'auth back-office :
|
||||||
|
* formBody() (login = formulaire POST urlencode, pas JSON) et clientIp()
|
||||||
|
* (IP reelle derriere Traefik pour la cle de throttling par IP).
|
||||||
|
*/
|
||||||
|
final class RequestFormBodyTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $headers
|
||||||
|
*/
|
||||||
|
private function request(string $method, string $rawBody, array $headers = [], string $remoteAddr = ''): Request
|
||||||
|
{
|
||||||
|
return new Request($method, '/login', [], $headers, $rawBody, $remoteAddr);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFormBodyParsesUrlencodedBody(): void
|
||||||
|
{
|
||||||
|
$request = $this->request(
|
||||||
|
'POST',
|
||||||
|
'email=admin%40wakdo.local&password=secret+pass',
|
||||||
|
['content-type' => 'application/x-www-form-urlencoded'],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
['email' => 'admin@wakdo.local', 'password' => 'secret pass'],
|
||||||
|
$request->formBody(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFormBodyToleratesCharsetSuffixOnContentType(): void
|
||||||
|
{
|
||||||
|
$request = $this->request(
|
||||||
|
'POST',
|
||||||
|
'a=1',
|
||||||
|
['content-type' => 'application/x-www-form-urlencoded; charset=UTF-8'],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(['a' => '1'], $request->formBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFormBodyReturnsEmptyForJsonContentType(): void
|
||||||
|
{
|
||||||
|
$request = $this->request('POST', '{"email":"x"}', ['content-type' => 'application/json']);
|
||||||
|
|
||||||
|
self::assertSame([], $request->formBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFormBodyReturnsEmptyWhenContentTypeAbsent(): void
|
||||||
|
{
|
||||||
|
$request = $this->request('POST', 'email=x');
|
||||||
|
|
||||||
|
self::assertSame([], $request->formBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFormBodyDropsArrayShapedValues(): void
|
||||||
|
{
|
||||||
|
// parse_str transforme "tags[]=a&tags[]=b" en tableau : on ne garde que
|
||||||
|
// les scalaires pour tenir le contrat array<string, string>.
|
||||||
|
$request = $this->request(
|
||||||
|
'POST',
|
||||||
|
'name=ok&tags%5B%5D=a&tags%5B%5D=b',
|
||||||
|
['content-type' => 'application/x-www-form-urlencoded'],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(['name' => 'ok'], $request->formBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testClientIpUsesLastForwardedHop(): void
|
||||||
|
{
|
||||||
|
// Seul le dernier hop (ajoute par Traefik) est de confiance ; les entrees
|
||||||
|
// de gauche sont fournies par le client et donc falsifiables.
|
||||||
|
$request = $this->request(
|
||||||
|
'POST',
|
||||||
|
'',
|
||||||
|
['x-forwarded-for' => '10.0.0.9, 203.0.113.7'],
|
||||||
|
'172.18.0.2',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('203.0.113.7', $request->clientIp());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testClientIpFallsBackToRemoteAddrWhenNoForwardedHeader(): void
|
||||||
|
{
|
||||||
|
$request = $this->request('POST', '', [], '198.51.100.4');
|
||||||
|
|
||||||
|
self::assertSame('198.51.100.4', $request->clientIp());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testClientIpFallsBackWhenForwardedHopIsMalformed(): void
|
||||||
|
{
|
||||||
|
$request = $this->request(
|
||||||
|
'POST',
|
||||||
|
'',
|
||||||
|
['x-forwarded-for' => 'not-an-ip'],
|
||||||
|
'198.51.100.4',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('198.51.100.4', $request->clientIp());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testClientIpAcceptsIpv6(): void
|
||||||
|
{
|
||||||
|
$request = $this->request(
|
||||||
|
'POST',
|
||||||
|
'',
|
||||||
|
['x-forwarded-for' => '2001:db8::1'],
|
||||||
|
'172.18.0.2',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('2001:db8::1', $request->clientIp());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testClientIpReturnsSentinelWhenNothingResolvable(): void
|
||||||
|
{
|
||||||
|
$request = $this->request('POST', '', [], '');
|
||||||
|
|
||||||
|
self::assertSame('0.0.0.0', $request->clientIp());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue