corentin_wakdo/src/app/Core/Request.php
Corentin JOGUET 1b0b20c12d
All checks were successful
CI / secret-scan (push) Successful in 7s
CI / php-lint (push) Successful in 17s
CI / static-tests (push) Successful in 32s
CI / auto-merge (push) Has been skipped
feat: authentification back-office P2 (login/logout/reset, throttle, audit) (#11)
2026-06-15 20:18:59 +02:00

214 lines
6.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Core;
/**
* Representation immuable de la requete HTTP entrante.
*
* Construite depuis les super-globales par fromGlobals() ; le reste de
* l'application ne touche jamais $_SERVER / $_GET directement.
*/
final class Request
{
/**
* @param array<string, string> $query
* @param array<string, string> $headers
*/
public function __construct(
private readonly string $method,
private readonly string $path,
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 = '',
) {
}
public static function fromGlobals(): self
{
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
// REQUEST_URI inclut la query string ; on isole le chemin seul.
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
$path = parse_url($uri, PHP_URL_PATH);
$path = is_string($path) ? $path : '/';
$path = self::normalizePath($path);
/** @var array<string, string> $query */
$query = $_GET;
return new self(
$method,
$path,
$query,
self::extractHeaders(),
(string) file_get_contents('php://input'),
(string) ($_SERVER['REMOTE_ADDR'] ?? ''),
);
}
/**
* Garde un slash de tete et retire le slash de fin (sauf racine) pour
* que "/api/health/" et "/api/health" matchent la meme route.
*/
private static function normalizePath(string $path): string
{
if ($path === '') {
return '/';
}
if ($path[0] !== '/') {
$path = '/' . $path;
}
if ($path !== '/' && str_ends_with($path, '/')) {
$path = rtrim($path, '/');
}
return $path;
}
/**
* @return array<string, string>
*/
private static function extractHeaders(): array
{
$headers = [];
foreach ($_SERVER as $key => $value) {
if (str_starts_with($key, 'HTTP_')) {
$name = str_replace('_', '-', substr($key, 5));
$headers[strtolower($name)] = (string) $value;
}
}
// Content-Type / Content-Length ne sont pas prefixes HTTP_ par PHP.
if (isset($_SERVER['CONTENT_TYPE'])) {
$headers['content-type'] = (string) $_SERVER['CONTENT_TYPE'];
}
if (isset($_SERVER['CONTENT_LENGTH'])) {
$headers['content-length'] = (string) $_SERVER['CONTENT_LENGTH'];
}
return $headers;
}
public function method(): string
{
return $this->method;
}
public function path(): string
{
return $this->path;
}
public function query(string $key, ?string $default = null): ?string
{
return $this->query[$key] ?? $default;
}
/**
* @return array<string, string>
*/
public function allQuery(): array
{
return $this->query;
}
public function header(string $name, ?string $default = null): ?string
{
return $this->headers[strtolower($name)] ?? $default;
}
public function rawBody(): string
{
return $this->rawBody;
}
/**
* Decode le corps JSON ; renvoie un tableau vide si le corps est vide ou
* invalide, pour laisser la validation metier decider (pas de fatale ici).
*
* @return array<string, mixed>
*/
public function json(): array
{
if ($this->rawBody === '') {
return [];
}
$decoded = json_decode($this->rawBody, true);
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';
}
}