feat: authentification back-office P2 (login/logout/reset, throttle, audit) #11
5 changed files with 286 additions and 1 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 $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<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;
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
|
|
|
|||
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