diff --git a/src/app/Core/Database.php b/src/app/Core/Database.php index d149d5e..4ab5c67 100644 --- a/src/app/Core/Database.php +++ b/src/app/Core/Database.php @@ -6,6 +6,7 @@ namespace App\Core; use PDO; use PDOStatement; +use Throwable; /** * 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 * indisponible. */ -final class Database +final class Database implements DatabaseInterface { private ?PDO $pdo = null; @@ -91,4 +92,30 @@ final class Database { 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; + } + } } diff --git a/src/app/Core/DatabaseInterface.php b/src/app/Core/DatabaseInterface.php new file mode 100644 index 0000000..b2b7710 --- /dev/null +++ b/src/app/Core/DatabaseInterface.php @@ -0,0 +1,43 @@ + $params + * @return array|null + */ + public function fetch(string $sql, array $params = []): ?array; + + /** + * @param array $params + * @return array> + */ + public function fetchAll(string $sql, array $params = []): array; + + /** + * @param array $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; +} diff --git a/src/app/Core/Request.php b/src/app/Core/Request.php index 0e124b9..eb5e1cf 100644 --- a/src/app/Core/Request.php +++ b/src/app/Core/Request.php @@ -22,6 +22,10 @@ final class Request private readonly array $query, private readonly array $headers, 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, self::extractHeaders(), (string) file_get_contents('php://input'), + (string) ($_SERVER['REMOTE_ADDR'] ?? ''), ); } @@ -142,4 +147,68 @@ final class Request 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 + */ + 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 (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'; + } } diff --git a/src/app/Core/Response.php b/src/app/Core/Response.php index 294e370..49a11ea 100644 --- a/src/app/Core/Response.php +++ b/src/app/Core/Response.php @@ -47,6 +47,24 @@ final class Response return $this; } + public function body(): string + { + return $this->body; + } + + public function header(string $name): ?string + { + return $this->headers[$name] ?? null; + } + + /** + * @return array + */ + public function headers(): array + { + return $this->headers; + } + /** * @param array $headers */ diff --git a/tests/Unit/Core/RequestFormBodyTest.php b/tests/Unit/Core/RequestFormBodyTest.php new file mode 100644 index 0000000..ee3667a --- /dev/null +++ b/tests/Unit/Core/RequestFormBodyTest.php @@ -0,0 +1,128 @@ + $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. + $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()); + } +}