feat(auth): authentification back-office (login, logout, reinitialisation mot de passe)
Implemente mlt.md section 12 : AUTHENTICATE_USER (12.1), LOGOUT_USER (12.2), RESET_PASSWORD (12.3). Sessions PHP + argon2id, regeneration d'ID a la connexion, idle 4h / absolu 10h via SessionGuard (cable en P3), jeton CSRF synchroniseur, backoff degressif anti brute-force par compte et par IP source (login_throttle), audit_log append-only (login_success/failed, password_reset), defenses anti-enumeration d'email (timing + profil d'ecritures identique), fail-closed sur erreur base. Vues login/forgot/reset rendues serveur. Routes posees sur le vhost admin (pas de prefixe /admin : docroot = public/admin). PHPUnit sans Composer (unit + integration DB auto-skippee sans base) et PHPStan L6 restent verts.
This commit is contained in:
parent
a499607add
commit
5835be0e66
30 changed files with 3018 additions and 0 deletions
|
|
@ -17,6 +17,10 @@
|
||||||
<testsuite name="unit">
|
<testsuite name="unit">
|
||||||
<directory>tests/Unit</directory>
|
<directory>tests/Unit</directory>
|
||||||
</testsuite>
|
</testsuite>
|
||||||
|
<!-- Tests d'integration DB : auto-skip si WAKDO_DB_TESTS != 1 (CI sans base). -->
|
||||||
|
<testsuite name="integration">
|
||||||
|
<directory>tests/Integration</directory>
|
||||||
|
</testsuite>
|
||||||
</testsuites>
|
</testsuites>
|
||||||
<source>
|
<source>
|
||||||
<include>
|
<include>
|
||||||
|
|
|
||||||
35
src/app/Auth/AuthResult.php
Normal file
35
src/app/Auth/AuthResult.php
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resultat immuable d'une operation d'authentification (login ou confirmation
|
||||||
|
* de reinitialisation). Le controleur mappe ce resultat vers une reponse HTTP
|
||||||
|
* sans re-deriver les branches de securite.
|
||||||
|
*
|
||||||
|
* Le message d'echec par defaut est unique et generique (anti-enumeration) :
|
||||||
|
* identifiants faux, compte inactif et throttle partagent le meme texte.
|
||||||
|
*/
|
||||||
|
final class AuthResult
|
||||||
|
{
|
||||||
|
private function __construct(
|
||||||
|
public readonly bool $success,
|
||||||
|
public readonly ?int $userId,
|
||||||
|
public readonly ?int $roleId,
|
||||||
|
public readonly ?string $redirectTo,
|
||||||
|
public readonly ?string $error,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function success(int $userId, int $roleId, string $redirectTo): self
|
||||||
|
{
|
||||||
|
return new self(true, $userId, $roleId, $redirectTo, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function failure(string $error = 'Email ou mot de passe incorrect'): self
|
||||||
|
{
|
||||||
|
return new self(false, null, null, null, $error);
|
||||||
|
}
|
||||||
|
}
|
||||||
280
src/app/Auth/AuthService.php
Normal file
280
src/app/Auth/AuthService.php
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use App\Core\Config;
|
||||||
|
use App\Core\DatabaseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentification back-office : AUTHENTICATE_USER (mlt.md 12.1) et
|
||||||
|
* LOGOUT_USER (12.2). Requetes preparees inline (pas de repository : jeu de
|
||||||
|
* requetes fixe et borne, une seule famille d'operations). Le temps est injecte
|
||||||
|
* (?int $now) pour des comparaisons de verrou deterministes en test.
|
||||||
|
*
|
||||||
|
* Fail-closed : toute exception PDO remonte ; aucune session n'est jamais
|
||||||
|
* ouverte sur une erreur de base de donnees.
|
||||||
|
*/
|
||||||
|
final class AuthService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DatabaseInterface $db,
|
||||||
|
private readonly Config $config,
|
||||||
|
private readonly SessionManager $session,
|
||||||
|
private readonly PasswordHasher $hasher,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordre strict (12.1) : RG-1 lookup (toujours, pour payer le cout SELECT sur
|
||||||
|
* hit comme sur miss) -> PRE-3 gate compte+IP -> RG-2 verify (leurre si miss).
|
||||||
|
* Succes : RG-3 regenerate + rotate CSRF, RG-4 session, RG-5/RG-9 reset+audit
|
||||||
|
* (une transaction), RG-7 redirection dynamique. Echec : RG-8 backoff degressif
|
||||||
|
* compte + upsert IP + audit (une transaction). Message d'echec unique (ERR-1/3).
|
||||||
|
*/
|
||||||
|
public function authenticate(string $email, string $password, string $ip, ?int $now = null): AuthResult
|
||||||
|
{
|
||||||
|
$now ??= time();
|
||||||
|
$accountPolicy = ThrottlePolicy::fromConfig($this->config, 'account');
|
||||||
|
$ipPolicy = ThrottlePolicy::fromConfig($this->config, 'ip');
|
||||||
|
|
||||||
|
// RG-1 : recherche systematique (hit ou miss) afin que le cout du SELECT
|
||||||
|
// soit paye dans les deux cas (limite l'oracle de timing par enumeration).
|
||||||
|
$user = $this->findActiveUserByEmail($email);
|
||||||
|
|
||||||
|
// PRE-3 : porte de throttling AVANT toute verification de mot de passe.
|
||||||
|
$accountLockedUntil = $user !== null ? $this->stringOrNull($user['lockout_until'] ?? null) : null;
|
||||||
|
$accountLocked = $accountPolicy->isLockedUntil($accountLockedUntil, $now);
|
||||||
|
$ipLocked = $ipPolicy->isLockedUntil($this->ipLockoutUntil($ip), $now);
|
||||||
|
|
||||||
|
if ($accountLocked || $ipLocked) {
|
||||||
|
// ERR-3 : meme message generique ; ne revele pas l'existence ni le verrou.
|
||||||
|
// Pas d'increment : le compteur tourne deja, le verrou est actif.
|
||||||
|
return AuthResult::failure();
|
||||||
|
}
|
||||||
|
|
||||||
|
// RG-2 : email inconnu -> verify leurre (timing) puis echec generique.
|
||||||
|
if ($user === null) {
|
||||||
|
$this->hasher->verifyDecoy($password);
|
||||||
|
$this->recordFailure(null, null, 0, $ip, $accountPolicy, $ipPolicy, $now);
|
||||||
|
|
||||||
|
return AuthResult::failure();
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int) ($user['id'] ?? 0);
|
||||||
|
$roleId = (int) ($user['role_id'] ?? 0);
|
||||||
|
|
||||||
|
if (!$this->hasher->verify($password, (string) ($user['password_hash'] ?? ''))) {
|
||||||
|
$attempts = (int) ($user['failed_login_attempts'] ?? 0);
|
||||||
|
$this->recordFailure($userId, $roleId, $attempts, $ip, $accountPolicy, $ipPolicy, $now);
|
||||||
|
|
||||||
|
return AuthResult::failure();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Succes : RG-3 (anti-fixation) d'abord (change l'ID, pas encore d'identite).
|
||||||
|
$this->session->regenerate();
|
||||||
|
|
||||||
|
// RG-5 + RG-9 : reset compteurs + clear IP + audit succes, une transaction.
|
||||||
|
// Fait AVANT de poser l'identite en session : si la base echoue, aucune
|
||||||
|
// session authentifiee ne subsiste (fail-closed, D9).
|
||||||
|
$this->recordSuccess($userId, $roleId, $ip, $now);
|
||||||
|
|
||||||
|
// RG-4 : identite + horodatages pour les bornes idle/absolue (RG-6),
|
||||||
|
// puis rotation du jeton CSRF anterieur a l'authentification.
|
||||||
|
$this->session->set('user_id', $userId);
|
||||||
|
$this->session->set('role_id', $roleId);
|
||||||
|
$this->session->set('logged_in_at', $now);
|
||||||
|
$this->session->set('last_activity', $now);
|
||||||
|
Csrf::rotate($this->session);
|
||||||
|
|
||||||
|
$routeRaw = $user['default_route'] ?? null;
|
||||||
|
$defaultRoute = is_string($routeRaw) && $routeRaw !== '' ? $routeRaw : '/';
|
||||||
|
|
||||||
|
return AuthResult::success($userId, $roleId, $defaultRoute);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LOGOUT_USER (12.2) : efface puis detruit la session. Aucune I/O base.
|
||||||
|
*/
|
||||||
|
public function logout(): void
|
||||||
|
{
|
||||||
|
$this->session->clear();
|
||||||
|
$this->session->destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-1 : utilisateur actif par email, joint a son role pour la route de
|
||||||
|
* redirection dynamique (RG-7). Requete preparee (RG-T06).
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function findActiveUserByEmail(string $email): ?array
|
||||||
|
{
|
||||||
|
return $this->db->fetch(
|
||||||
|
'SELECT u.id, u.password_hash, u.role_id, u.failed_login_attempts, u.lockout_until, r.default_route '
|
||||||
|
. 'FROM user u JOIN role r ON r.id = u.role_id '
|
||||||
|
. 'WHERE u.email = :email AND u.is_active = 1 LIMIT 1',
|
||||||
|
['email' => $email],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ipLockoutUntil(string $ip): ?string
|
||||||
|
{
|
||||||
|
$row = $this->db->fetch(
|
||||||
|
'SELECT lockout_until FROM login_throttle WHERE ip_address = :ip',
|
||||||
|
['ip' => $ip],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $row === null ? null : $this->stringOrNull($row['lockout_until'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-8 : enregistre l'echec sur les deux dimensions (compte si connu + IP)
|
||||||
|
* et une ligne audit_log, le tout dans une seule transaction atomique (RG-T08).
|
||||||
|
*/
|
||||||
|
private function recordFailure(
|
||||||
|
?int $userId,
|
||||||
|
?int $roleId,
|
||||||
|
int $currentAttempts,
|
||||||
|
string $ip,
|
||||||
|
ThrottlePolicy $accountPolicy,
|
||||||
|
ThrottlePolicy $ipPolicy,
|
||||||
|
int $now,
|
||||||
|
): void {
|
||||||
|
$nowDt = date('Y-m-d H:i:s', $now);
|
||||||
|
$windowSeconds = $this->config->int('IP_THROTTLE_WINDOW_SECONDS', 900);
|
||||||
|
|
||||||
|
$windowCutoff = date('Y-m-d H:i:s', $now - $windowSeconds);
|
||||||
|
|
||||||
|
$this->db->transaction(function (DatabaseInterface $db) use (
|
||||||
|
$userId,
|
||||||
|
$roleId,
|
||||||
|
$currentAttempts,
|
||||||
|
$ip,
|
||||||
|
$accountPolicy,
|
||||||
|
$ipPolicy,
|
||||||
|
$now,
|
||||||
|
$nowDt,
|
||||||
|
$windowCutoff,
|
||||||
|
): void {
|
||||||
|
// Dimension compte. Pour ne pas reveler par le timing si l'email existe
|
||||||
|
// (anti-enumeration, RG-2), on emet la MEME requete dans les deux cas :
|
||||||
|
// sur email inconnu, un UPDATE sur id = 0 (aucune ligne touchee car les
|
||||||
|
// PK user sont AUTO_INCREMENT >= 1), donc meme profil d'I/O, effet nul.
|
||||||
|
if ($userId !== null) {
|
||||||
|
$newAttempts = $currentAttempts + 1;
|
||||||
|
$lockSeconds = $accountPolicy->lockoutSeconds($newAttempts);
|
||||||
|
$lockUntil = $lockSeconds > 0 ? date('Y-m-d H:i:s', $now + $lockSeconds) : null;
|
||||||
|
|
||||||
|
$db->execute(
|
||||||
|
'UPDATE user SET failed_login_attempts = :attempts, last_failed_login_at = :now, '
|
||||||
|
. 'lockout_until = :lock WHERE id = :id',
|
||||||
|
['attempts' => $newAttempts, 'now' => $nowDt, 'lock' => $lockUntil, 'id' => $userId],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$db->execute(
|
||||||
|
'UPDATE user SET failed_login_attempts = :attempts, last_failed_login_at = :now, '
|
||||||
|
. 'lockout_until = :lock WHERE id = :id',
|
||||||
|
['attempts' => 0, 'now' => $nowDt, 'lock' => null, 'id' => 0],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dimension IP : increment ATOMIQUE cote SQL (failed_attempts + 1) pour
|
||||||
|
// eviter le lost-update sous concurrence ; la fenetre glissante est
|
||||||
|
// reinitialisee en SQL si elle a expire. Le verrou de ligne pris par
|
||||||
|
// l'upsert serialise les tentatives concurrentes sur la meme IP.
|
||||||
|
// Placeholders distincts : en prepare reelle (EMULATE_PREPARES = false)
|
||||||
|
// un meme nom ne peut pas etre lie plusieurs fois.
|
||||||
|
$db->execute(
|
||||||
|
'INSERT INTO login_throttle (ip_address, failed_attempts, window_started_at, last_attempt_at) '
|
||||||
|
. 'VALUES (:ip, 1, :now_i, :now_li) '
|
||||||
|
. 'ON DUPLICATE KEY UPDATE '
|
||||||
|
. 'failed_attempts = IF(window_started_at < :cutoff, 1, failed_attempts + 1), '
|
||||||
|
. 'window_started_at = IF(window_started_at < :cutoff2, :now_w, window_started_at), '
|
||||||
|
. 'last_attempt_at = :now_lu',
|
||||||
|
[
|
||||||
|
'ip' => $ip,
|
||||||
|
'now_i' => $nowDt,
|
||||||
|
'now_li' => $nowDt,
|
||||||
|
'cutoff' => $windowCutoff,
|
||||||
|
'cutoff2' => $windowCutoff,
|
||||||
|
'now_w' => $nowDt,
|
||||||
|
'now_lu' => $nowDt,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Relit le compteur post-increment (valeur autoritaire ecrite ci-dessus,
|
||||||
|
// ligne deja verrouillee par cette transaction) pour calculer le backoff
|
||||||
|
// IP en PHP via ThrottlePolicy, puis pose le verrou.
|
||||||
|
$row = $db->fetch('SELECT failed_attempts FROM login_throttle WHERE ip_address = :ip', ['ip' => $ip]);
|
||||||
|
$ipAttempts = (int) ($row['failed_attempts'] ?? 1);
|
||||||
|
$ipLockSeconds = $ipPolicy->lockoutSeconds($ipAttempts);
|
||||||
|
$ipLockUntil = $ipLockSeconds > 0 ? date('Y-m-d H:i:s', $now + $ipLockSeconds) : null;
|
||||||
|
|
||||||
|
$db->execute(
|
||||||
|
'UPDATE login_throttle SET lockout_until = :lock WHERE ip_address = :ip',
|
||||||
|
['lock' => $ipLockUntil, 'ip' => $ip],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->writeAudit($db, 'auth.login_failed', $userId, $roleId, 'Echec de connexion');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-9 : remise a zero du compteur compte + clear du throttle IP + audit du
|
||||||
|
* succes, une seule transaction (RG-T08).
|
||||||
|
*/
|
||||||
|
private function recordSuccess(int $userId, int $roleId, string $ip, int $now): void
|
||||||
|
{
|
||||||
|
$nowDt = date('Y-m-d H:i:s', $now);
|
||||||
|
|
||||||
|
$this->db->transaction(function (DatabaseInterface $db) use ($userId, $roleId, $ip, $nowDt): void {
|
||||||
|
$db->execute(
|
||||||
|
'UPDATE user SET failed_login_attempts = 0, lockout_until = NULL, last_login_at = :now WHERE id = :id',
|
||||||
|
['now' => $nowDt, 'id' => $userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear de la ligne IP : 0 ligne affectee si aucune n'existait (benin).
|
||||||
|
// Placeholders distincts (cf. recordFailure : prepare reelle, un nom
|
||||||
|
// ne peut etre lie qu'une fois).
|
||||||
|
$db->execute(
|
||||||
|
'UPDATE login_throttle SET failed_attempts = 0, lockout_until = NULL, '
|
||||||
|
. 'window_started_at = :now_w, last_attempt_at = :now_l WHERE ip_address = :ip',
|
||||||
|
['now_w' => $nowDt, 'now_l' => $nowDt, 'ip' => $ip],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->writeAudit($db, 'auth.login_success', $userId, $roleId, 'Connexion reussie');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-T14 : audit_log strictement en INSERT (jamais d'UPDATE/DELETE). summary
|
||||||
|
* non personnel ; details laisse NULL pour un evenement d'auth (aucune PII).
|
||||||
|
*/
|
||||||
|
private function writeAudit(
|
||||||
|
DatabaseInterface $db,
|
||||||
|
string $actionCode,
|
||||||
|
?int $userId,
|
||||||
|
?int $roleId,
|
||||||
|
string $summary,
|
||||||
|
): void {
|
||||||
|
$db->execute(
|
||||||
|
'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) '
|
||||||
|
. 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)',
|
||||||
|
[
|
||||||
|
'uid' => $userId,
|
||||||
|
'rid' => $roleId,
|
||||||
|
'code' => $actionCode,
|
||||||
|
'etype' => $userId !== null ? 'user' : null,
|
||||||
|
'eid' => $userId,
|
||||||
|
'summary' => $summary,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stringOrNull(mixed $value): ?string
|
||||||
|
{
|
||||||
|
return is_string($value) ? $value : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/app/Auth/Csrf.php
Normal file
60
src/app/Auth/Csrf.php
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jeton CSRF synchroniseur stocke en session (RG-T01). Choisi plutot que le
|
||||||
|
* double-submit (plus faible derriere un domaine parent partage) ou un HMAC
|
||||||
|
* stateless (inutile puisqu'on a deja un etat serveur en session).
|
||||||
|
*
|
||||||
|
* Comparaison en temps constant (hash_equals) ; le jeton est re-genere apres
|
||||||
|
* session_regenerate_id pour qu'un jeton plante avant l'authentification ne
|
||||||
|
* puisse pas etre rejoue.
|
||||||
|
*/
|
||||||
|
final class Csrf
|
||||||
|
{
|
||||||
|
private const KEY = '_csrf';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jeton stable de la session : genere une fois (32 octets CSPRNG en hex)
|
||||||
|
* puis reutilise tant que la session vit.
|
||||||
|
*/
|
||||||
|
public static function token(SessionManager $session): string
|
||||||
|
{
|
||||||
|
$existing = $session->get(self::KEY);
|
||||||
|
if (is_string($existing) && $existing !== '') {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::rotate($session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai uniquement si un jeton existe en session et egale (temps constant) le
|
||||||
|
* jeton soumis. Toute absence (pas de jeton, soumission vide) renvoie false.
|
||||||
|
*/
|
||||||
|
public static function validate(SessionManager $session, ?string $submitted): bool
|
||||||
|
{
|
||||||
|
$stored = $session->get(self::KEY);
|
||||||
|
|
||||||
|
if (!is_string($stored) || $stored === '' || $submitted === null || $submitted === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash_equals($stored, $submitted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-genere le jeton (apres regeneration d'ID de session sur login reussi) :
|
||||||
|
* invalide tout jeton anterieur a l'authentification.
|
||||||
|
*/
|
||||||
|
public static function rotate(SessionManager $session): string
|
||||||
|
{
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$session->set(self::KEY, $token);
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app/Auth/GuardResult.php
Normal file
22
src/app/Auth/GuardResult.php
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resultat immuable d'une verification de garde de session (RG-6 + RG-T02).
|
||||||
|
* $reason documente la cause d'un rejet pour que le controleur appelant (P3)
|
||||||
|
* decide de la suite (redirection login, message). Valeurs possibles :
|
||||||
|
* 'no_session' | 'idle_timeout' | 'absolute_timeout' | 'inactive' | null (OK).
|
||||||
|
*/
|
||||||
|
final class GuardResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly bool $authenticated,
|
||||||
|
public readonly ?int $userId,
|
||||||
|
public readonly ?int $roleId,
|
||||||
|
public readonly ?string $reason,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/app/Auth/LogMailer.php
Normal file
18
src/app/Auth/LogMailer.php
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation de developpement : aucune infra mail en P2, on journalise le
|
||||||
|
* lien de reinitialisation (error_log -> logs du conteneur) pour pouvoir le
|
||||||
|
* recuperer en dev. Le lien contient le token brut, qui n'est jamais persiste.
|
||||||
|
*/
|
||||||
|
final class LogMailer implements Mailer
|
||||||
|
{
|
||||||
|
public function sendPasswordReset(string $email, string $resetUrl): void
|
||||||
|
{
|
||||||
|
error_log(sprintf('[wakdo][password-reset] %s -> %s', $email, $resetUrl));
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/app/Auth/Mailer.php
Normal file
16
src/app/Auth/Mailer.php
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seam d'envoi du lien de reinitialisation de mot de passe. Interface justifiee
|
||||||
|
* (contrairement a un repository) car une implementation SMTP reelle est
|
||||||
|
* explicitement prevue pour une phase ulterieure : elle se branchera ici sans
|
||||||
|
* toucher PasswordResetService.
|
||||||
|
*/
|
||||||
|
interface Mailer
|
||||||
|
{
|
||||||
|
public function sendPasswordReset(string $email, string $resetUrl): void;
|
||||||
|
}
|
||||||
73
src/app/Auth/PasswordHasher.php
Normal file
73
src/app/Auth/PasswordHasher.php
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use App\Core\Config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enveloppe argon2id de password_hash / password_verify avec les couts lus dans
|
||||||
|
* l'environnement (.env / docker-compose). Porte aussi le leurre de timing
|
||||||
|
* utilise quand l'email est inconnu (anti-enumeration, mlt.md 12.1 RG-2).
|
||||||
|
*/
|
||||||
|
final class PasswordHasher
|
||||||
|
{
|
||||||
|
// Cache a l'echelle du process (worker PHP-FPM) : le PasswordHasher est
|
||||||
|
// instancie a chaque requete, mais le leurre doit etre calcule une seule fois
|
||||||
|
// par worker (voir decoyHash()).
|
||||||
|
private static ?string $decoy = null;
|
||||||
|
|
||||||
|
public function __construct(private readonly Config $config)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hash(string $plain): string
|
||||||
|
{
|
||||||
|
return password_hash($plain, PASSWORD_ARGON2ID, $this->options());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(string $plain, string $hash): bool
|
||||||
|
{
|
||||||
|
return password_verify($plain, $hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifie le mot de passe soumis contre un leurre argon2id de meme cout, et
|
||||||
|
* jette le resultat. But : egaliser le temps CPU du chemin "email inconnu"
|
||||||
|
* avec celui du chemin "mauvais mot de passe", pour ne pas reveler par le
|
||||||
|
* timing si un compte existe (RG-2). Le leurre est calcule une fois par
|
||||||
|
* process sur un secret jetable ; il ne correspond a aucun mot de passe reel.
|
||||||
|
*/
|
||||||
|
public function verifyDecoy(string $plain): void
|
||||||
|
{
|
||||||
|
password_verify($plain, $this->decoyHash());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{memory_cost: int, time_cost: int, threads: int}
|
||||||
|
*/
|
||||||
|
private function options(): array
|
||||||
|
{
|
||||||
|
// Defauts alignes sur .env.example / OWASP (64 MiB, 4 iterations, 1 thread).
|
||||||
|
return [
|
||||||
|
'memory_cost' => $this->config->int('ARGON2_MEMORY_COST', 65536),
|
||||||
|
'time_cost' => $this->config->int('ARGON2_TIME_COST', 4),
|
||||||
|
'threads' => $this->config->int('ARGON2_THREADS', 1),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decoyHash(): string
|
||||||
|
{
|
||||||
|
// Cache statique par process : le hash argon2id du leurre est couteux et
|
||||||
|
// n'est calcule qu'une fois par worker, puis reutilise. Sans ce cache,
|
||||||
|
// comme le PasswordHasher est instancie a chaque requete, chaque tentative
|
||||||
|
// sur email inconnu paierait un password_hash supplementaire absent du
|
||||||
|
// chemin email connu -> ecart de timing reintroduisant l'oracle d'enumeration.
|
||||||
|
if (self::$decoy === null) {
|
||||||
|
self::$decoy = password_hash(bin2hex(random_bytes(16)), PASSWORD_ARGON2ID, $this->options());
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$decoy;
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/app/Auth/PasswordResetService.php
Normal file
127
src/app/Auth/PasswordResetService.php
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use App\Core\Config;
|
||||||
|
use App\Core\DatabaseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reinitialisation de mot de passe (mlt.md 12.3), en deux phases : demande puis
|
||||||
|
* confirmation. Sans fuite d'enumeration (reponse neutre), token CSPRNG hashe au
|
||||||
|
* repos, usage unique, confirmation transactionnelle.
|
||||||
|
*/
|
||||||
|
final class PasswordResetService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DatabaseInterface $db,
|
||||||
|
private readonly Config $config,
|
||||||
|
private readonly PasswordHasher $hasher,
|
||||||
|
private readonly Mailer $mailer,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase demande (RG-1/RG-2). Retour void : la reponse cote controleur est
|
||||||
|
* neutre que l'email existe ou non (anti-enumeration). Si l'email resout un
|
||||||
|
* utilisateur actif : token CSPRNG 32 octets, on stocke son hash SHA-256 et
|
||||||
|
* une expiration NOW()+TTL, et on envoie le token BRUT une seule fois.
|
||||||
|
*/
|
||||||
|
public function requestReset(string $email, string $baseUrl, ?int $now = null): void
|
||||||
|
{
|
||||||
|
$now ??= time();
|
||||||
|
|
||||||
|
$user = $this->db->fetch(
|
||||||
|
'SELECT id FROM user WHERE email = :email AND is_active = 1 LIMIT 1',
|
||||||
|
['email' => $email],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int) ($user['id'] ?? 0);
|
||||||
|
|
||||||
|
// Token a haute entropie (256 bits). Stocke en SHA-256 : un hash rapide
|
||||||
|
// suffit (la robustesse vient de l'entropie, pas d'un KDF lent), et le
|
||||||
|
// brut n'est jamais persiste. Voir comment de confirmReset().
|
||||||
|
$rawToken = bin2hex(random_bytes(32));
|
||||||
|
$tokenHash = hash('sha256', $rawToken);
|
||||||
|
$ttl = $this->config->int('PASSWORD_RESET_TTL', 3600);
|
||||||
|
$expiresAt = date('Y-m-d H:i:s', $now + $ttl);
|
||||||
|
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE user SET password_reset_token_hash = :hash, password_reset_expires_at = :exp WHERE id = :id',
|
||||||
|
['hash' => $tokenHash, 'exp' => $expiresAt, 'id' => $userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
$resetUrl = rtrim($baseUrl, '/') . '/reset_password?token=' . $rawToken;
|
||||||
|
$this->mailer->sendPasswordReset($email, $resetUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase confirmation (RG-3/RG-4). Hash du token soumis, recherche par hash +
|
||||||
|
* expiration future (la recherche par egalite sur un token 256 bits EST la
|
||||||
|
* comparaison ; pas de souci de temps constant car ce n'est pas un secret a
|
||||||
|
* faible entropie et la colonne n'est jamais renvoyee au client). Min 8
|
||||||
|
* caracteres, nouveau hash argon2id, token efface (usage unique), compteurs
|
||||||
|
* remis a zero, audit_log : le tout dans une transaction.
|
||||||
|
*/
|
||||||
|
public function confirmReset(string $rawToken, string $newPassword, ?int $now = null): AuthResult
|
||||||
|
{
|
||||||
|
$now ??= time();
|
||||||
|
|
||||||
|
if (strlen($newPassword) < 8) {
|
||||||
|
return AuthResult::failure('Le mot de passe doit contenir au moins 8 caracteres.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rawToken === '') {
|
||||||
|
return AuthResult::failure('Lien invalide ou expire.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokenHash = hash('sha256', $rawToken);
|
||||||
|
$nowDt = date('Y-m-d H:i:s', $now);
|
||||||
|
|
||||||
|
$user = $this->db->fetch(
|
||||||
|
'SELECT id, role_id, password_reset_token_hash FROM user '
|
||||||
|
. 'WHERE password_reset_token_hash = :hash AND password_reset_expires_at > :now '
|
||||||
|
. 'AND is_active = 1 LIMIT 1',
|
||||||
|
['hash' => $tokenHash, 'now' => $nowDt],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
return AuthResult::failure('Lien invalide ou expire.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int) ($user['id'] ?? 0);
|
||||||
|
$roleId = (int) ($user['role_id'] ?? 0);
|
||||||
|
$newHash = $this->hasher->hash($newPassword);
|
||||||
|
|
||||||
|
$this->db->transaction(function (DatabaseInterface $db) use ($userId, $roleId, $newHash): void {
|
||||||
|
// Usage unique : on efface token + expiration et on remet les
|
||||||
|
// compteurs anti brute-force a zero (le compte redevient utilisable).
|
||||||
|
$db->execute(
|
||||||
|
'UPDATE user SET password_hash = :hash, password_reset_token_hash = NULL, '
|
||||||
|
. 'password_reset_expires_at = NULL, failed_login_attempts = 0, lockout_until = NULL '
|
||||||
|
. 'WHERE id = :id',
|
||||||
|
['hash' => $newHash, 'id' => $userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
$db->execute(
|
||||||
|
'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) '
|
||||||
|
. 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)',
|
||||||
|
[
|
||||||
|
'uid' => $userId,
|
||||||
|
'rid' => $roleId,
|
||||||
|
'code' => 'auth.password_reset',
|
||||||
|
'etype' => 'user',
|
||||||
|
'eid' => $userId,
|
||||||
|
'summary' => 'Reinitialisation du mot de passe',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return AuthResult::success($userId, $roleId, '/login?reset=ok');
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/app/Auth/SessionGuard.php
Normal file
67
src/app/Auth/SessionGuard.php
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use App\Core\Config;
|
||||||
|
use App\Core\DatabaseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garde de session pour les requetes authentifiees (mlt.md 12.1 RG-6 + RG-T02).
|
||||||
|
*
|
||||||
|
* NOTE DE PERIMETRE : concu et teste en P2, mais CABLE en P3. Quand les pages
|
||||||
|
* admin deviendront dynamiques, chaque controleur protege appellera check() en
|
||||||
|
* tete d'action et agira sur le GuardResult (rediriger vers /login si false).
|
||||||
|
*/
|
||||||
|
final class SessionGuard
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SessionManager $session,
|
||||||
|
private readonly DatabaseInterface $db,
|
||||||
|
private readonly Config $config,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifie la session : presence d'identite, borne d'inactivite (idle) et
|
||||||
|
* borne absolue (RG-6), puis re-verification is_active = 1 en base (RG-T02).
|
||||||
|
* Sur succes, rafraichit last_activity (fenetre idle glissante).
|
||||||
|
*/
|
||||||
|
public function check(?int $now = null): GuardResult
|
||||||
|
{
|
||||||
|
$now ??= time();
|
||||||
|
|
||||||
|
$userId = $this->session->getInt('user_id');
|
||||||
|
$roleId = $this->session->getInt('role_id');
|
||||||
|
$loggedInAt = $this->session->getInt('logged_in_at');
|
||||||
|
$lastActivity = $this->session->getInt('last_activity');
|
||||||
|
|
||||||
|
if ($userId === null || $roleId === null || $loggedInAt === null) {
|
||||||
|
return new GuardResult(false, null, null, 'no_session');
|
||||||
|
}
|
||||||
|
|
||||||
|
$idleLimit = $this->config->int('SESSION_LIFETIME_IDLE', 14400);
|
||||||
|
$absoluteLimit = $this->config->int('SESSION_LIFETIME_ABSOLUTE', 36000);
|
||||||
|
|
||||||
|
if ($lastActivity === null || ($now - $lastActivity) > $idleLimit) {
|
||||||
|
return new GuardResult(false, null, null, 'idle_timeout');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($now - $loggedInAt) > $absoluteLimit) {
|
||||||
|
return new GuardResult(false, null, null, 'absolute_timeout');
|
||||||
|
}
|
||||||
|
|
||||||
|
// RG-T02 : is_active re-verifie a chaque requete (un compte desactive en
|
||||||
|
// cours de session perd l'acces des la requete suivante).
|
||||||
|
$row = $this->db->fetch('SELECT is_active FROM user WHERE id = :id', ['id' => $userId]);
|
||||||
|
|
||||||
|
if ($row === null || (int) ($row['is_active'] ?? 0) !== 1) {
|
||||||
|
return new GuardResult(false, null, null, 'inactive');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->session->set('last_activity', $now);
|
||||||
|
|
||||||
|
return new GuardResult(true, $userId, $roleId, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
172
src/app/Auth/SessionManager.php
Normal file
172
src/app/Auth/SessionManager.php
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use App\Core\Config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seul fichier autorise a toucher $_SESSION, les fonctions session_* et le
|
||||||
|
* cookie de session. Tout le reste de l'auth opere sur cette facade injectee,
|
||||||
|
* ce qui rend les services et le CSRF testables sans session reelle.
|
||||||
|
*
|
||||||
|
* En mode test (testMode = true), aucune session PHP n'est demarree : l'etat
|
||||||
|
* vit dans un sac memoire. Indispensable car PHPUnit tourne avec
|
||||||
|
* beStrictAboutOutputDuringTests : un session_start emettrait un en-tete et
|
||||||
|
* ferait echouer la suite.
|
||||||
|
*/
|
||||||
|
final class SessionManager
|
||||||
|
{
|
||||||
|
/** @var array<string, mixed> */
|
||||||
|
private array $bag = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Config $config,
|
||||||
|
private readonly bool $testMode = false,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demarre la session du vhost admin avec des cookies durcis. Idempotent :
|
||||||
|
* le front controller peut l'avoir deja demarree avant le dispatch.
|
||||||
|
*/
|
||||||
|
public function start(): void
|
||||||
|
{
|
||||||
|
if ($this->testMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defense : ne pas tenter de poser le cookie si la sortie a commence.
|
||||||
|
if (headers_sent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// lifetime=0 : cookie de session ; les bornes idle 4h / absolue 10h sont
|
||||||
|
// appliquees applicativement par SessionGuard (RG-6), pas par le cookie.
|
||||||
|
// secure+httponly+SameSite=Strict : back-office, aucune entree cross-site.
|
||||||
|
session_set_cookie_params([
|
||||||
|
'lifetime' => 0,
|
||||||
|
'path' => '/',
|
||||||
|
'secure' => true,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Strict',
|
||||||
|
]);
|
||||||
|
session_name($this->config->get('SESSION_NAME', 'WAKDO_SID') ?? 'WAKDO_SID');
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenere l'identifiant de session (RG-3) : protege contre la fixation de
|
||||||
|
* session apres une authentification reussie.
|
||||||
|
*/
|
||||||
|
public function regenerate(): void
|
||||||
|
{
|
||||||
|
if ($this->testMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
session_regenerate_id(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $key): mixed
|
||||||
|
{
|
||||||
|
if ($this->testMode) {
|
||||||
|
return $this->bag[$key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $_SESSION[$key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accesseur type : evite qu'une valeur mixed de session ne file dans un
|
||||||
|
* parametre lie PDO ou un calcul d'entier (friction PHPStan L6).
|
||||||
|
* Les identifiants et timestamps stockes sont des entiers positifs.
|
||||||
|
*/
|
||||||
|
public function getInt(string $key): ?int
|
||||||
|
{
|
||||||
|
$value = $this->get($key);
|
||||||
|
|
||||||
|
if (is_int($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value) && ctype_digit($value)) {
|
||||||
|
return (int) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function set(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
if ($this->testMode) {
|
||||||
|
$this->bag[$key] = $value;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Efface les donnees de session (RG-1 de LOGOUT_USER).
|
||||||
|
*/
|
||||||
|
public function clear(): void
|
||||||
|
{
|
||||||
|
if ($this->testMode) {
|
||||||
|
$this->bag = [];
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expire le cookie de session cote client puis detruit la session serveur
|
||||||
|
* (RG-2 + RG-3 de LOGOUT_USER). Le cookie reprend les memes attributs durcis.
|
||||||
|
*/
|
||||||
|
public function destroy(): void
|
||||||
|
{
|
||||||
|
if ($this->testMode) {
|
||||||
|
$this->bag = [];
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ini_get('session.use_cookies') !== false) {
|
||||||
|
$name = session_name();
|
||||||
|
if ($name !== false) {
|
||||||
|
setcookie($name, '', [
|
||||||
|
'expires' => time() - 3600,
|
||||||
|
'path' => '/',
|
||||||
|
'secure' => true,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Strict',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
session_destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function id(): string
|
||||||
|
{
|
||||||
|
if ($this->testMode) {
|
||||||
|
return 'test-session';
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = session_id();
|
||||||
|
|
||||||
|
return $id === false ? '' : $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/app/Auth/ThrottlePolicy.php
Normal file
85
src/app/Auth/ThrottlePolicy.php
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use App\Core\Config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Math pure du throttling anti brute-force (mlt.md 12.1 RG-8). Sans I/O ni
|
||||||
|
* superglobale : c'est le calcul de securite le plus delicat (backoff degressif
|
||||||
|
* + evaluation du verrou), donc isole ici pour etre entierement testable.
|
||||||
|
*
|
||||||
|
* La meme courbe sert aux deux dimensions : par compte (user.lockout_until,
|
||||||
|
* seuil ACCOUNT_LOCKOUT_THRESHOLD) et par IP source (login_throttle.lockout_until,
|
||||||
|
* seuil IP_THROTTLE_MAX_ATTEMPTS), instanciees via fromConfig().
|
||||||
|
*/
|
||||||
|
final class ThrottlePolicy
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly int $threshold,
|
||||||
|
private readonly int $baseSeconds,
|
||||||
|
private readonly int $maxSeconds,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backoff degressif : 0 sous le seuil ; au seuil = base ; puis doublement
|
||||||
|
* base * 2^(tentatives - seuil), plafonne a maxSeconds. Ce n'est pas un
|
||||||
|
* verrou definitif : il ralentit la force brute sans priver de service un
|
||||||
|
* compte legitime victime de fautes de frappe (RG-8).
|
||||||
|
*/
|
||||||
|
public function lockoutSeconds(int $attempts): int
|
||||||
|
{
|
||||||
|
if ($attempts < $this->threshold) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$exponent = $attempts - $this->threshold;
|
||||||
|
|
||||||
|
// Garde anti-debordement : au-dela d'un exposant raisonnable, 2^exposant
|
||||||
|
// depasserait PHP_INT_MAX. Comme le resultat est de toute facon plafonne,
|
||||||
|
// on court-circuite des que la valeur ne peut que depasser le plafond.
|
||||||
|
if ($exponent >= 31) {
|
||||||
|
return $this->maxSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seconds = $this->baseSeconds * (2 ** $exponent);
|
||||||
|
|
||||||
|
return (int) min($seconds, $this->maxSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai si le verrou ($lockoutUntil, datetime 'Y-m-d H:i:s' ou null) est
|
||||||
|
* strictement dans le futur a l'instant $now (timestamp Unix injecte pour
|
||||||
|
* des comparaisons deterministes en test). null/vide/illisible => pas de verrou.
|
||||||
|
*/
|
||||||
|
public function isLockedUntil(?string $lockoutUntil, int $now): bool
|
||||||
|
{
|
||||||
|
if ($lockoutUntil === null || $lockoutUntil === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$until = strtotime($lockoutUntil);
|
||||||
|
|
||||||
|
return $until !== false && $until > $now;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit la politique pour la dimension 'account' (par compte) ou 'ip'
|
||||||
|
* (par IP source). RG-8 precise "le meme backoff degressif" pour l'IP, donc
|
||||||
|
* la dimension IP reutilise base/max et prend IP_THROTTLE_MAX_ATTEMPTS comme seuil.
|
||||||
|
*/
|
||||||
|
public static function fromConfig(Config $config, string $dimension): self
|
||||||
|
{
|
||||||
|
$base = $config->int('ACCOUNT_LOCKOUT_BASE_SECONDS', 60);
|
||||||
|
$max = $config->int('ACCOUNT_LOCKOUT_MAX_SECONDS', 900);
|
||||||
|
|
||||||
|
if ($dimension === 'ip') {
|
||||||
|
return new self($config->int('IP_THROTTLE_MAX_ATTEMPTS', 20), $base, $max);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self($config->int('ACCOUNT_LOCKOUT_THRESHOLD', 5), $base, $max);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/app/Controllers/AuthController.php
Normal file
127
src/app/Controllers/AuthController.php
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use Throwable;
|
||||||
|
use App\Auth\AuthService;
|
||||||
|
use App\Auth\Csrf;
|
||||||
|
use App\Auth\PasswordHasher;
|
||||||
|
use App\Auth\SessionManager;
|
||||||
|
use App\Core\Controller;
|
||||||
|
use App\Core\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connexion / deconnexion du back-office (mlt.md 12.1 et 12.2). Rendu serveur :
|
||||||
|
* GET /login affiche le formulaire (jeton CSRF en champ cache), POST /login
|
||||||
|
* authentifie puis redirige (302) vers role.default_route, POST /logout detruit
|
||||||
|
* la session.
|
||||||
|
*
|
||||||
|
* Le Router n'injecte que (Request, Config, Database) ; le controleur fabrique
|
||||||
|
* donc son graphe de services via des hooks proteges, surchargeables en test.
|
||||||
|
*
|
||||||
|
* Non `final` a dessein : les tests sous-classent ce controleur pour surcharger
|
||||||
|
* sessionManager()/authService() et injecter des doubles (seam de testabilite).
|
||||||
|
*/
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
private const GENERIC_ERROR = 'Email ou mot de passe incorrect';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $params
|
||||||
|
*/
|
||||||
|
public function showLogin(array $params = []): Response
|
||||||
|
{
|
||||||
|
$notice = $this->request->query('reset') === 'ok'
|
||||||
|
? 'Mot de passe reinitialise. Vous pouvez vous connecter.'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return $this->renderLogin(null, $notice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $params
|
||||||
|
*/
|
||||||
|
public function login(array $params = []): Response
|
||||||
|
{
|
||||||
|
$form = $this->request->formBody();
|
||||||
|
|
||||||
|
// PRE-2 / ERR-2 : jeton CSRF valide sinon 403, avant tout traitement.
|
||||||
|
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
|
||||||
|
return $this->renderLogin('Session expiree, merci de reessayer.', null, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RG-T18 : validation et bornes de longueur cote serveur.
|
||||||
|
$email = trim($form['email'] ?? '');
|
||||||
|
$password = $form['password'] ?? '';
|
||||||
|
|
||||||
|
if ($email === '' || $password === '' || strlen($email) > 254 || strlen($password) > 4096) {
|
||||||
|
return $this->renderLogin(self::GENERIC_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->authService()->authenticate($email, $password, $this->request->clientIp());
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
// Fail-closed : une panne base ne doit jamais authentifier. On ne
|
||||||
|
// divulgue rien, on re-affiche le formulaire avec le message generique.
|
||||||
|
error_log('[wakdo][auth] login failure: ' . $exception->getMessage());
|
||||||
|
|
||||||
|
return $this->renderLogin(self::GENERIC_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result->success && $result->redirectTo !== null) {
|
||||||
|
return $this->redirect($result->redirectTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->renderLogin($result->error ?? self::GENERIC_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $params
|
||||||
|
*/
|
||||||
|
public function logout(array $params = []): Response
|
||||||
|
{
|
||||||
|
$form = $this->request->formBody();
|
||||||
|
|
||||||
|
// D11 : deconnexion en POST garde par CSRF (un GET forgeable pourrait
|
||||||
|
// deconnecter un poste en plein service). CSRF invalide -> 403, pas de destroy.
|
||||||
|
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
|
||||||
|
return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->authService()->logout();
|
||||||
|
|
||||||
|
return $this->redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sessionManager(): SessionManager
|
||||||
|
{
|
||||||
|
return new SessionManager($this->config);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authService(): AuthService
|
||||||
|
{
|
||||||
|
return new AuthService(
|
||||||
|
$this->database,
|
||||||
|
$this->config,
|
||||||
|
$this->sessionManager(),
|
||||||
|
new PasswordHasher($this->config),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function redirect(string $location, int $status = 302): Response
|
||||||
|
{
|
||||||
|
return Response::make('', $status, ['Location' => $location]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderLogin(?string $error, ?string $notice = null, int $status = 200): Response
|
||||||
|
{
|
||||||
|
return $this->view('auth/login', [
|
||||||
|
'title' => 'Connexion - Wakdo Admin',
|
||||||
|
'csrfToken' => Csrf::token($this->sessionManager()),
|
||||||
|
'error' => $error,
|
||||||
|
'notice' => $notice,
|
||||||
|
], $status);
|
||||||
|
}
|
||||||
|
}
|
||||||
150
src/app/Controllers/PasswordResetController.php
Normal file
150
src/app/Controllers/PasswordResetController.php
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use Throwable;
|
||||||
|
use App\Auth\Csrf;
|
||||||
|
use App\Auth\LogMailer;
|
||||||
|
use App\Auth\PasswordHasher;
|
||||||
|
use App\Auth\PasswordResetService;
|
||||||
|
use App\Auth\SessionManager;
|
||||||
|
use App\Core\Controller;
|
||||||
|
use App\Core\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reinitialisation de mot de passe (mlt.md 12.3), rendu serveur en deux phases :
|
||||||
|
* demande (GET/POST /forgot_password) puis confirmation (GET/POST /reset_password).
|
||||||
|
* La phase demande renvoie toujours une reponse neutre (anti-enumeration).
|
||||||
|
*
|
||||||
|
* Non `final` a dessein : les tests sous-classent ce controleur pour surcharger
|
||||||
|
* sessionManager()/resetService() et injecter des doubles (seam de testabilite).
|
||||||
|
*/
|
||||||
|
class PasswordResetController extends Controller
|
||||||
|
{
|
||||||
|
private const NEUTRAL_NOTICE = 'Si un compte correspond a cet email, un lien de reinitialisation a ete envoye.';
|
||||||
|
private const INVALID_LINK = 'Lien invalide ou expire.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $params
|
||||||
|
*/
|
||||||
|
public function showRequest(array $params = []): Response
|
||||||
|
{
|
||||||
|
return $this->view('auth/forgot', [
|
||||||
|
'title' => 'Mot de passe oublie - Wakdo Admin',
|
||||||
|
'csrfToken' => Csrf::token($this->sessionManager()),
|
||||||
|
'notice' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $params
|
||||||
|
*/
|
||||||
|
public function submitRequest(array $params = []): Response
|
||||||
|
{
|
||||||
|
$form = $this->request->formBody();
|
||||||
|
|
||||||
|
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
|
||||||
|
return $this->view('auth/forgot', [
|
||||||
|
'title' => 'Mot de passe oublie - Wakdo Admin',
|
||||||
|
'csrfToken' => Csrf::token($this->sessionManager()),
|
||||||
|
'notice' => null,
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = trim($form['email'] ?? '');
|
||||||
|
|
||||||
|
// Reponse neutre quoi qu'il arrive (existence, validite, meme panne base).
|
||||||
|
if ($email !== '' && strlen($email) <= 254) {
|
||||||
|
try {
|
||||||
|
$this->resetService()->requestReset($email, $this->baseUrl());
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
error_log('[wakdo][auth] reset request failure: ' . $exception->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->view('auth/forgot', [
|
||||||
|
'title' => 'Mot de passe oublie - Wakdo Admin',
|
||||||
|
'csrfToken' => Csrf::token($this->sessionManager()),
|
||||||
|
'notice' => self::NEUTRAL_NOTICE,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $params
|
||||||
|
*/
|
||||||
|
public function showConfirm(array $params = []): Response
|
||||||
|
{
|
||||||
|
return $this->renderConfirm($this->request->query('token') ?? '', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $params
|
||||||
|
*/
|
||||||
|
public function submitConfirm(array $params = []): Response
|
||||||
|
{
|
||||||
|
$form = $this->request->formBody();
|
||||||
|
$token = $form['token'] ?? '';
|
||||||
|
|
||||||
|
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
|
||||||
|
return $this->renderConfirm($token, 'Session expiree, merci de reessayer.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$password = $form['password'] ?? '';
|
||||||
|
$confirm = $form['password_confirm'] ?? '';
|
||||||
|
|
||||||
|
if ($password !== $confirm) {
|
||||||
|
return $this->renderConfirm($token, 'Les mots de passe ne correspondent pas.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->resetService()->confirmReset($token, $password);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
error_log('[wakdo][auth] reset confirm failure: ' . $exception->getMessage());
|
||||||
|
|
||||||
|
return $this->renderConfirm($token, self::INVALID_LINK);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result->success && $result->redirectTo !== null) {
|
||||||
|
return $this->redirect($result->redirectTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->renderConfirm($token, $result->error ?? self::INVALID_LINK);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sessionManager(): SessionManager
|
||||||
|
{
|
||||||
|
return new SessionManager($this->config);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resetService(): PasswordResetService
|
||||||
|
{
|
||||||
|
return new PasswordResetService(
|
||||||
|
$this->database,
|
||||||
|
$this->config,
|
||||||
|
new PasswordHasher($this->config),
|
||||||
|
new LogMailer(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function baseUrl(): string
|
||||||
|
{
|
||||||
|
return $this->config->get('APP_URL_ADMIN', '') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function redirect(string $location, int $status = 302): Response
|
||||||
|
{
|
||||||
|
return Response::make('', $status, ['Location' => $location]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderConfirm(string $token, ?string $error, int $status = 200): Response
|
||||||
|
{
|
||||||
|
return $this->view('auth/reset', [
|
||||||
|
'title' => 'Nouveau mot de passe - Wakdo Admin',
|
||||||
|
'csrfToken' => Csrf::token($this->sessionManager()),
|
||||||
|
'token' => $token,
|
||||||
|
'error' => $error,
|
||||||
|
], $status);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/app/Views/auth/forgot.php
Normal file
35
src/app/Views/auth/forgot.php
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragment de la demande de reinitialisation (phase 1 de 12.3), injecte dans
|
||||||
|
* layout.php. La reponse est neutre : aucun indice sur l'existence du compte.
|
||||||
|
*
|
||||||
|
* @var string $csrfToken
|
||||||
|
* @var string|null $notice
|
||||||
|
*/
|
||||||
|
|
||||||
|
$token = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
|
$noticeMessage = isset($notice) && is_string($notice) ? $notice : null;
|
||||||
|
?>
|
||||||
|
<main class="login-page">
|
||||||
|
<h1>Mot de passe oublie</h1>
|
||||||
|
|
||||||
|
<?php if ($noticeMessage !== null): ?>
|
||||||
|
<p role="status"><?= htmlspecialchars($noticeMessage, ENT_QUOTES, 'UTF-8') ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" action="/forgot_password">
|
||||||
|
<input type="hidden" name="_csrf" value="<?= $token ?>">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Adresse e-mail</label>
|
||||||
|
<input type="email" id="email" name="email" autocomplete="email" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">Envoyer le lien</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p><a href="/login">Retour a la connexion</a></p>
|
||||||
|
</main>
|
||||||
47
src/app/Views/auth/login.php
Normal file
47
src/app/Views/auth/login.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragment du formulaire de connexion back-office, injecte dans layout.php.
|
||||||
|
* Tout texte dynamique est echappe (RG-T15). action POST /login, jeton CSRF cache.
|
||||||
|
*
|
||||||
|
* @var string $csrfToken
|
||||||
|
* @var string|null $error
|
||||||
|
* @var string|null $notice
|
||||||
|
*/
|
||||||
|
|
||||||
|
$token = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
|
$errorMessage = isset($error) && is_string($error) ? $error : null;
|
||||||
|
$noticeMessage = isset($notice) && is_string($notice) ? $notice : null;
|
||||||
|
?>
|
||||||
|
<main class="login-page">
|
||||||
|
<h1>Wakdo Admin</h1>
|
||||||
|
<p><small>Back-office de gestion</small></p>
|
||||||
|
|
||||||
|
<?php if ($noticeMessage !== null): ?>
|
||||||
|
<p role="status"><?= htmlspecialchars($noticeMessage, ENT_QUOTES, 'UTF-8') ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($errorMessage !== null): ?>
|
||||||
|
<p role="alert"><?= htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<input type="hidden" name="_csrf" value="<?= $token ?>">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Adresse e-mail</label>
|
||||||
|
<input type="email" id="email" name="email" autocomplete="email" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Mot de passe</label>
|
||||||
|
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">Se connecter</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p><a href="/forgot_password">Mot de passe oublie ?</a></p>
|
||||||
|
</main>
|
||||||
43
src/app/Views/auth/reset.php
Normal file
43
src/app/Views/auth/reset.php
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragment de la confirmation de reinitialisation (phase 2 de 12.3), injecte
|
||||||
|
* dans layout.php. Le token brut transite en champ cache (usage unique cote service).
|
||||||
|
*
|
||||||
|
* @var string $csrfToken
|
||||||
|
* @var string $token
|
||||||
|
* @var string|null $error
|
||||||
|
*/
|
||||||
|
|
||||||
|
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
|
$resetToken = htmlspecialchars($token ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
|
$errorMessage = isset($error) && is_string($error) ? $error : null;
|
||||||
|
?>
|
||||||
|
<main class="login-page">
|
||||||
|
<h1>Nouveau mot de passe</h1>
|
||||||
|
|
||||||
|
<?php if ($errorMessage !== null): ?>
|
||||||
|
<p role="alert"><?= htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" action="/reset_password">
|
||||||
|
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
||||||
|
<input type="hidden" name="token" value="<?= $resetToken ?>">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Nouveau mot de passe</label>
|
||||||
|
<input type="password" id="password" name="password" autocomplete="new-password" minlength="8" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password_confirm">Confirmer le mot de passe</label>
|
||||||
|
<input type="password" id="password_confirm" name="password_confirm" autocomplete="new-password" minlength="8" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">Reinitialiser</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p><a href="/login">Retour a la connexion</a></p>
|
||||||
|
</main>
|
||||||
|
|
@ -10,8 +10,11 @@ declare(strict_types=1);
|
||||||
* "/", "/api/health", etc.
|
* "/", "/api/health", etc.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
use App\Auth\SessionManager;
|
||||||
|
use App\Controllers\AuthController;
|
||||||
use App\Controllers\HealthController;
|
use App\Controllers\HealthController;
|
||||||
use App\Controllers\HomeController;
|
use App\Controllers\HomeController;
|
||||||
|
use App\Controllers\PasswordResetController;
|
||||||
use App\Core\Autoloader;
|
use App\Core\Autoloader;
|
||||||
use App\Core\Config;
|
use App\Core\Config;
|
||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
|
|
@ -36,10 +39,24 @@ try {
|
||||||
// donc la home back-office reste servie meme base indisponible.
|
// donc la home back-office reste servie meme base indisponible.
|
||||||
$database = new Database($config);
|
$database = new Database($config);
|
||||||
|
|
||||||
|
// Demarre la session du vhost admin avant le dispatch (effet de bord global,
|
||||||
|
// hors du Core stateless). Les controleurs y rattachent leur SessionManager.
|
||||||
|
(new SessionManager($config))->start();
|
||||||
|
|
||||||
$router = new Router($config, $database);
|
$router = new Router($config, $database);
|
||||||
$router->add('GET', '/', [HomeController::class, 'index']);
|
$router->add('GET', '/', [HomeController::class, 'index']);
|
||||||
$router->add('GET', '/api/health', [HealthController::class, 'index']);
|
$router->add('GET', '/api/health', [HealthController::class, 'index']);
|
||||||
|
|
||||||
|
// Authentification back-office (mlt.md section 12). Le docroot du vhost admin
|
||||||
|
// etant src/public/admin, le Router voit "/login" (pas de prefixe "/admin").
|
||||||
|
$router->add('GET', '/login', [AuthController::class, 'showLogin']);
|
||||||
|
$router->add('POST', '/login', [AuthController::class, 'login']);
|
||||||
|
$router->add('POST', '/logout', [AuthController::class, 'logout']);
|
||||||
|
$router->add('GET', '/forgot_password', [PasswordResetController::class, 'showRequest']);
|
||||||
|
$router->add('POST', '/forgot_password', [PasswordResetController::class, 'submitRequest']);
|
||||||
|
$router->add('GET', '/reset_password', [PasswordResetController::class, 'showConfirm']);
|
||||||
|
$router->add('POST', '/reset_password', [PasswordResetController::class, 'submitConfirm']);
|
||||||
|
|
||||||
$response = $router->dispatch(Request::fromGlobals());
|
$response = $router->dispatch(Request::fromGlobals());
|
||||||
$response->send();
|
$response->send();
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
|
|
|
||||||
189
tests/Integration/AuthServiceDbTest.php
Normal file
189
tests/Integration/AuthServiceDbTest.php
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Integration;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Throwable;
|
||||||
|
use App\Auth\AuthService;
|
||||||
|
use App\Auth\PasswordHasher;
|
||||||
|
use App\Auth\SessionManager;
|
||||||
|
use App\Core\Config;
|
||||||
|
use App\Core\Database;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test d'integration de AUTHENTICATE_USER contre une vraie MariaDB (schema migre
|
||||||
|
* + seede). Il valide le SQL reel (requetes preparees, transaction, upsert
|
||||||
|
* login_throttle) que les tests unitaires a FakeDatabase ne peuvent pas exercer.
|
||||||
|
*
|
||||||
|
* Auto-skip : ne s'execute que si WAKDO_DB_TESTS=1 ET qu'une base est joignable.
|
||||||
|
* La CI (sans base) le saute donc, et il ne touche jamais la base par defaut.
|
||||||
|
*
|
||||||
|
* Isolation : chaque test cree son propre utilisateur jetable (email .invalid
|
||||||
|
* unique) et le supprime en tearDown, avec sa ligne login_throttle (IP de test
|
||||||
|
* dans le bloc documentation TEST-NET-2) et ses lignes audit_log.
|
||||||
|
*/
|
||||||
|
final class AuthServiceDbTest extends TestCase
|
||||||
|
{
|
||||||
|
private const TEST_IP = '198.51.100.250';
|
||||||
|
private const PASSWORD = 'IntegrationPass1';
|
||||||
|
|
||||||
|
private Database $db;
|
||||||
|
private Config $config;
|
||||||
|
private int $userId = 0;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
if (getenv('WAKDO_DB_TESTS') !== '1') {
|
||||||
|
self::markTestSkipped('Tests DB desactives (definir WAKDO_DB_TESTS=1 + DB_* pour les activer).');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->config = new Config();
|
||||||
|
$this->db = new Database($this->config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->db->fetch('SELECT 1');
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
self::markTestSkipped('Base de donnees injoignable: ' . $exception->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cleanupThrottle();
|
||||||
|
$this->userId = $this->createDisposableUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
if ($this->userId === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordre compatible FK : audit (actor SET NULL mais on retire nos lignes),
|
||||||
|
// throttle (par IP), puis l'utilisateur jetable.
|
||||||
|
$this->db->execute('DELETE FROM audit_log WHERE actor_user_id = :id', ['id' => $this->userId]);
|
||||||
|
$this->cleanupThrottle();
|
||||||
|
$this->db->execute('DELETE FROM user WHERE id = :id', ['id' => $this->userId]);
|
||||||
|
$this->userId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function service(): AuthService
|
||||||
|
{
|
||||||
|
return new AuthService(
|
||||||
|
$this->db,
|
||||||
|
$this->config,
|
||||||
|
new SessionManager($this->config, true),
|
||||||
|
new PasswordHasher($this->config),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSuccessfulLoginPersistsResetCountersAndAuditSuccess(): void
|
||||||
|
{
|
||||||
|
$result = $this->service()->authenticate($this->email(), self::PASSWORD, self::TEST_IP);
|
||||||
|
|
||||||
|
self::assertTrue($result->success);
|
||||||
|
|
||||||
|
$user = $this->db->fetch(
|
||||||
|
'SELECT failed_login_attempts, lockout_until, last_login_at FROM user WHERE id = :id',
|
||||||
|
['id' => $this->userId],
|
||||||
|
);
|
||||||
|
self::assertNotNull($user);
|
||||||
|
self::assertSame(0, (int) ($user['failed_login_attempts'] ?? -1));
|
||||||
|
self::assertNull($user['lockout_until']);
|
||||||
|
self::assertNotNull($user['last_login_at']);
|
||||||
|
|
||||||
|
self::assertSame('auth.login_success', $this->lastAuditAction());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFailedLoginIncrementsAccountAndCreatesThrottleAndAuditFailure(): void
|
||||||
|
{
|
||||||
|
$result = $this->service()->authenticate($this->email(), 'WRONG-PASSWORD', self::TEST_IP);
|
||||||
|
|
||||||
|
self::assertFalse($result->success);
|
||||||
|
|
||||||
|
$user = $this->db->fetch(
|
||||||
|
'SELECT failed_login_attempts FROM user WHERE id = :id',
|
||||||
|
['id' => $this->userId],
|
||||||
|
);
|
||||||
|
self::assertNotNull($user);
|
||||||
|
self::assertSame(1, (int) ($user['failed_login_attempts'] ?? -1));
|
||||||
|
|
||||||
|
$throttle = $this->db->fetch(
|
||||||
|
'SELECT failed_attempts FROM login_throttle WHERE ip_address = :ip',
|
||||||
|
['ip' => self::TEST_IP],
|
||||||
|
);
|
||||||
|
self::assertNotNull($throttle);
|
||||||
|
self::assertSame(1, (int) ($throttle['failed_attempts'] ?? -1));
|
||||||
|
|
||||||
|
self::assertSame('auth.login_failed', $this->lastAuditAction());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testThrottleGateRejectsWhenAccountLocked(): void
|
||||||
|
{
|
||||||
|
// Pose un verrou compte dans le futur, puis tente avec le BON mot de passe :
|
||||||
|
// la porte PRE-3 doit refuser avant toute verification.
|
||||||
|
$future = date('Y-m-d H:i:s', time() + 600);
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE user SET lockout_until = :lock WHERE id = :id',
|
||||||
|
['lock' => $future, 'id' => $this->userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $this->service()->authenticate($this->email(), self::PASSWORD, self::TEST_IP);
|
||||||
|
|
||||||
|
self::assertFalse($result->success);
|
||||||
|
// last_login_at reste nul : aucune authentification n'a abouti.
|
||||||
|
$user = $this->db->fetch('SELECT last_login_at FROM user WHERE id = :id', ['id' => $this->userId]);
|
||||||
|
self::assertNotNull($user);
|
||||||
|
self::assertNull($user['last_login_at']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function email(): string
|
||||||
|
{
|
||||||
|
return 'it-auth-' . $this->userId . '@wakdo.invalid';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createDisposableUser(): int
|
||||||
|
{
|
||||||
|
$roleRow = $this->db->fetch('SELECT id FROM role ORDER BY id LIMIT 1');
|
||||||
|
$roleId = (int) ($roleRow['id'] ?? 0);
|
||||||
|
self::assertGreaterThan(0, $roleId, 'aucun role seede: migration/seed requis');
|
||||||
|
|
||||||
|
$hash = (new PasswordHasher($this->config))->hash(self::PASSWORD);
|
||||||
|
// Email provisoire pour obtenir l'id, puis on le rend unique par id.
|
||||||
|
$this->db->execute(
|
||||||
|
'INSERT INTO user (email, password_hash, first_name, last_name, role_id, is_active) '
|
||||||
|
. 'VALUES (:email, :hash, :fn, :ln, :role, 1)',
|
||||||
|
[
|
||||||
|
'email' => 'it-auth-pending-' . bin2hex(random_bytes(6)) . '@wakdo.invalid',
|
||||||
|
'hash' => $hash,
|
||||||
|
'fn' => 'Integration',
|
||||||
|
'ln' => 'Test',
|
||||||
|
'role' => $roleId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$row = $this->db->fetch('SELECT LAST_INSERT_ID() AS id');
|
||||||
|
$id = (int) ($row['id'] ?? 0);
|
||||||
|
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE user SET email = :email WHERE id = :id',
|
||||||
|
['email' => 'it-auth-' . $id . '@wakdo.invalid', 'id' => $id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cleanupThrottle(): void
|
||||||
|
{
|
||||||
|
$this->db->execute('DELETE FROM login_throttle WHERE ip_address = :ip', ['ip' => self::TEST_IP]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lastAuditAction(): ?string
|
||||||
|
{
|
||||||
|
$row = $this->db->fetch(
|
||||||
|
'SELECT action_code FROM audit_log WHERE actor_user_id = :id ORDER BY id DESC LIMIT 1',
|
||||||
|
['id' => $this->userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $row === null ? null : (string) ($row['action_code'] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
155
tests/Support/FakeDatabase.php
Normal file
155
tests/Support/FakeDatabase.php
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Support;
|
||||||
|
|
||||||
|
use App\Core\DatabaseInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Double de test de DatabaseInterface : aucune connexion reelle. Les lectures
|
||||||
|
* sont scriptees par des "boutons" types (userRow, ipLockoutUntil,
|
||||||
|
* ipFailedAttempts), les ecritures sont enregistrees pour assertion, et les
|
||||||
|
* transactions tracent begin/commit/rollback. Permet de tester les branches de
|
||||||
|
* securite d'AuthService / PasswordResetService sans base de donnees.
|
||||||
|
*/
|
||||||
|
final class FakeDatabase implements DatabaseInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Reponse de la recherche utilisateur (RG-1) ; null = email inconnu.
|
||||||
|
*
|
||||||
|
* @var array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public ?array $userRow = null;
|
||||||
|
|
||||||
|
/** lockout_until renvoye pour la porte de throttling IP ; null = pas de verrou. */
|
||||||
|
public ?string $ipLockoutUntil = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compteur login_throttle relu apres l'upsert atomique (sert au calcul du
|
||||||
|
* backoff IP en PHP) ; null => 1 par defaut cote service.
|
||||||
|
*
|
||||||
|
* @var array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public ?array $throttleRow = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reponse de la recherche par token de reinitialisation (12.3) ; null = aucun.
|
||||||
|
*
|
||||||
|
* @var array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public ?array $resetUserRow = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reponse de la recherche par email (phase demande de reinitialisation) ; null = inconnu.
|
||||||
|
*
|
||||||
|
* @var array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public ?array $emailLookupRow = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reponse de la verification is_active du SessionGuard (RG-T02) ; null = absent.
|
||||||
|
*
|
||||||
|
* @var array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public ?array $guardUserRow = null;
|
||||||
|
|
||||||
|
/** Si non nul, execute() leve cette exception (simulation panne DB -> fail-closed). */
|
||||||
|
public ?RuntimeException $failOnExecute = null;
|
||||||
|
|
||||||
|
/** @var list<array{sql: string, params: array<string|int, mixed>}> */
|
||||||
|
public array $writes = [];
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
public array $transactionEvents = [];
|
||||||
|
|
||||||
|
public function fetch(string $sql, array $params = []): ?array
|
||||||
|
{
|
||||||
|
if (str_contains($sql, 'FROM user u JOIN role')) {
|
||||||
|
return $this->userRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($sql, 'password_reset_token_hash')) {
|
||||||
|
return $this->resetUserRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($sql, 'SELECT id FROM user WHERE email')) {
|
||||||
|
return $this->emailLookupRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($sql, 'SELECT is_active FROM user WHERE id')) {
|
||||||
|
return $this->guardUserRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($sql, 'SELECT lockout_until FROM login_throttle')) {
|
||||||
|
return ['lockout_until' => $this->ipLockoutUntil];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($sql, 'SELECT failed_attempts FROM login_throttle')) {
|
||||||
|
return $this->throttleRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetchAll(string $sql, array $params = []): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(string $sql, array $params = []): int
|
||||||
|
{
|
||||||
|
if ($this->failOnExecute !== null) {
|
||||||
|
throw $this->failOnExecute;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->writes[] = ['sql' => $sql, 'params' => $params];
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function transaction(callable $fn): void
|
||||||
|
{
|
||||||
|
$this->transactionEvents[] = 'begin';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$fn($this);
|
||||||
|
$this->transactionEvents[] = 'commit';
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
$this->transactionEvents[] = 'rollback';
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function wrote(string $needle): bool
|
||||||
|
{
|
||||||
|
foreach ($this->writes as $write) {
|
||||||
|
if (str_contains($write['sql'], $needle)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codes d'action audit_log inseres (dans l'ordre).
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function auditActions(): array
|
||||||
|
{
|
||||||
|
$codes = [];
|
||||||
|
|
||||||
|
foreach ($this->writes as $write) {
|
||||||
|
if (str_contains($write['sql'], 'INSERT INTO audit_log')) {
|
||||||
|
$code = $write['params']['code'] ?? null;
|
||||||
|
$codes[] = is_string($code) ? $code : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $codes;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
tests/Support/SpyMailer.php
Normal file
22
tests/Support/SpyMailer.php
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Support;
|
||||||
|
|
||||||
|
use App\Auth\Mailer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Double de Mailer : capture les appels au lieu d'envoyer. Permet d'asserter
|
||||||
|
* qu'un lien de reinitialisation a (ou n'a pas) ete emis et d'en inspecter l'URL.
|
||||||
|
*/
|
||||||
|
final class SpyMailer implements Mailer
|
||||||
|
{
|
||||||
|
/** @var list<array{email: string, resetUrl: string}> */
|
||||||
|
public array $sent = [];
|
||||||
|
|
||||||
|
public function sendPasswordReset(string $email, string $resetUrl): void
|
||||||
|
{
|
||||||
|
$this->sent[] = ['email' => $email, 'resetUrl' => $resetUrl];
|
||||||
|
}
|
||||||
|
}
|
||||||
197
tests/Unit/Auth/AuthControllerTest.php
Normal file
197
tests/Unit/Auth/AuthControllerTest.php
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Auth;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use App\Auth\AuthService;
|
||||||
|
use App\Auth\Csrf;
|
||||||
|
use App\Auth\PasswordHasher;
|
||||||
|
use App\Auth\SessionManager;
|
||||||
|
use App\Controllers\AuthController;
|
||||||
|
use App\Core\Config;
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Request;
|
||||||
|
use App\Tests\Support\FakeDatabase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sous-classe de test : surcharge les hooks de fabrication pour injecter une
|
||||||
|
* session en mode test et un FakeDatabase, sans toucher le Router ni la base.
|
||||||
|
*/
|
||||||
|
final class TestAuthController extends AuthController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
Request $request,
|
||||||
|
Config $config,
|
||||||
|
Database $database,
|
||||||
|
private readonly SessionManager $testSession,
|
||||||
|
private readonly FakeDatabase $fakeDb,
|
||||||
|
) {
|
||||||
|
parent::__construct($request, $config, $database);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sessionManager(): SessionManager
|
||||||
|
{
|
||||||
|
return $this->testSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authService(): AuthService
|
||||||
|
{
|
||||||
|
return new AuthService($this->fakeDb, $this->config, $this->testSession, new PasswordHasher($this->config));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AuthControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $touchedKeys = [];
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->setEnv('ACCOUNT_LOCKOUT_THRESHOLD', '5');
|
||||||
|
$this->setEnv('ACCOUNT_LOCKOUT_BASE_SECONDS', '60');
|
||||||
|
$this->setEnv('ACCOUNT_LOCKOUT_MAX_SECONDS', '900');
|
||||||
|
$this->setEnv('IP_THROTTLE_MAX_ATTEMPTS', '20');
|
||||||
|
$this->setEnv('IP_THROTTLE_WINDOW_SECONDS', '900');
|
||||||
|
$this->setEnv('ARGON2_MEMORY_COST', '1024');
|
||||||
|
$this->setEnv('ARGON2_TIME_COST', '1');
|
||||||
|
$this->setEnv('ARGON2_THREADS', '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
foreach ($this->touchedKeys as $key) {
|
||||||
|
putenv($key);
|
||||||
|
}
|
||||||
|
$this->touchedKeys = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setEnv(string $key, string $value): void
|
||||||
|
{
|
||||||
|
$this->touchedKeys[] = $key;
|
||||||
|
putenv($key . '=' . $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $form
|
||||||
|
*/
|
||||||
|
private function postRequest(array $form, string $path = '/login'): Request
|
||||||
|
{
|
||||||
|
return new Request(
|
||||||
|
'POST',
|
||||||
|
$path,
|
||||||
|
[],
|
||||||
|
['content-type' => 'application/x-www-form-urlencoded'],
|
||||||
|
http_build_query($form),
|
||||||
|
'203.0.113.5',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getRequest(string $path = '/login'): Request
|
||||||
|
{
|
||||||
|
return new Request('GET', $path, [], [], '', '203.0.113.5');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function controller(Request $request, SessionManager $session, FakeDatabase $db): TestAuthController
|
||||||
|
{
|
||||||
|
return new TestAuthController($request, new Config(), new Database(new Config()), $session, $db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $overrides
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function userRow(string $password, array $overrides = []): array
|
||||||
|
{
|
||||||
|
return array_merge([
|
||||||
|
'id' => 7,
|
||||||
|
'role_id' => 3,
|
||||||
|
'password_hash' => (new PasswordHasher(new Config()))->hash($password),
|
||||||
|
'failed_login_attempts' => 0,
|
||||||
|
'lockout_until' => null,
|
||||||
|
'default_route' => '/admin/dashboard',
|
||||||
|
], $overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testShowLoginRendersCsrfField(): void
|
||||||
|
{
|
||||||
|
$session = new SessionManager(new Config(), true);
|
||||||
|
$response = $this->controller($this->getRequest(), $session, new FakeDatabase())->showLogin();
|
||||||
|
|
||||||
|
self::assertSame(200, $response->status());
|
||||||
|
self::assertStringContainsString('name="_csrf"', $response->body());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLoginRejectsInvalidCsrfWith403(): void
|
||||||
|
{
|
||||||
|
$session = new SessionManager(new Config(), true);
|
||||||
|
Csrf::token($session);
|
||||||
|
$db = new FakeDatabase();
|
||||||
|
|
||||||
|
$request = $this->postRequest(['_csrf' => 'wrong', 'email' => 'admin@wakdo.local', 'password' => 'x']);
|
||||||
|
$response = $this->controller($request, $session, $db)->login();
|
||||||
|
|
||||||
|
self::assertSame(403, $response->status());
|
||||||
|
// L'authentification n'a pas tourne : aucune ecriture base.
|
||||||
|
self::assertSame([], $db->writes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLoginBadCredentialsRendersGenericErrorWithoutRedirect(): void
|
||||||
|
{
|
||||||
|
$session = new SessionManager(new Config(), true);
|
||||||
|
$token = Csrf::token($session);
|
||||||
|
$db = new FakeDatabase();
|
||||||
|
$db->userRow = $this->userRow('right-password');
|
||||||
|
|
||||||
|
$request = $this->postRequest(['_csrf' => $token, 'email' => 'admin@wakdo.local', 'password' => 'WRONG']);
|
||||||
|
$response = $this->controller($request, $session, $db)->login();
|
||||||
|
|
||||||
|
self::assertSame(200, $response->status());
|
||||||
|
self::assertNull($response->header('Location'));
|
||||||
|
self::assertStringContainsString('Email ou mot de passe incorrect', $response->body());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLoginSuccessRedirectsToDefaultRoute(): void
|
||||||
|
{
|
||||||
|
$session = new SessionManager(new Config(), true);
|
||||||
|
$token = Csrf::token($session);
|
||||||
|
$db = new FakeDatabase();
|
||||||
|
$db->userRow = $this->userRow('correct-password');
|
||||||
|
|
||||||
|
$request = $this->postRequest(['_csrf' => $token, 'email' => 'admin@wakdo.local', 'password' => 'correct-password']);
|
||||||
|
$response = $this->controller($request, $session, $db)->login();
|
||||||
|
|
||||||
|
self::assertSame(302, $response->status());
|
||||||
|
self::assertSame('/admin/dashboard', $response->header('Location'));
|
||||||
|
self::assertSame(7, $session->getInt('user_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogoutRequiresValidCsrf(): void
|
||||||
|
{
|
||||||
|
$session = new SessionManager(new Config(), true);
|
||||||
|
Csrf::token($session);
|
||||||
|
$session->set('user_id', 7);
|
||||||
|
|
||||||
|
$request = $this->postRequest(['_csrf' => 'wrong'], '/logout');
|
||||||
|
$response = $this->controller($request, $session, new FakeDatabase())->logout();
|
||||||
|
|
||||||
|
self::assertSame(403, $response->status());
|
||||||
|
// Session intacte : la deconnexion forgee est refusee.
|
||||||
|
self::assertSame(7, $session->getInt('user_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogoutWithValidCsrfClearsSessionAndRedirects(): void
|
||||||
|
{
|
||||||
|
$session = new SessionManager(new Config(), true);
|
||||||
|
$token = Csrf::token($session);
|
||||||
|
$session->set('user_id', 7);
|
||||||
|
|
||||||
|
$request = $this->postRequest(['_csrf' => $token], '/logout');
|
||||||
|
$response = $this->controller($request, $session, new FakeDatabase())->logout();
|
||||||
|
|
||||||
|
self::assertSame(302, $response->status());
|
||||||
|
self::assertSame('/login', $response->header('Location'));
|
||||||
|
self::assertNull($session->getInt('user_id'));
|
||||||
|
}
|
||||||
|
}
|
||||||
315
tests/Unit/Auth/AuthServiceTest.php
Normal file
315
tests/Unit/Auth/AuthServiceTest.php
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Auth;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use RuntimeException;
|
||||||
|
use App\Auth\AuthService;
|
||||||
|
use App\Auth\Csrf;
|
||||||
|
use App\Auth\PasswordHasher;
|
||||||
|
use App\Auth\SessionManager;
|
||||||
|
use App\Core\Config;
|
||||||
|
use App\Tests\Support\FakeDatabase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Branches de securite d'AUTHENTICATE_USER (mlt.md 12.1) testees avec un
|
||||||
|
* FakeDatabase (aucune base), un vrai PasswordHasher a cout reduit et une
|
||||||
|
* session en mode test. Le temps est fige via le parametre $now.
|
||||||
|
*/
|
||||||
|
final class AuthServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private const NOW = 1_700_000_000;
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $touchedKeys = [];
|
||||||
|
|
||||||
|
private FakeDatabase $db;
|
||||||
|
private SessionManager $session;
|
||||||
|
private PasswordHasher $hasher;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
// Politique de throttling deterministe + argon2id a cout reduit.
|
||||||
|
$this->setEnv('ACCOUNT_LOCKOUT_THRESHOLD', '5');
|
||||||
|
$this->setEnv('ACCOUNT_LOCKOUT_BASE_SECONDS', '60');
|
||||||
|
$this->setEnv('ACCOUNT_LOCKOUT_MAX_SECONDS', '900');
|
||||||
|
$this->setEnv('IP_THROTTLE_MAX_ATTEMPTS', '20');
|
||||||
|
$this->setEnv('IP_THROTTLE_WINDOW_SECONDS', '900');
|
||||||
|
$this->setEnv('ARGON2_MEMORY_COST', '1024');
|
||||||
|
$this->setEnv('ARGON2_TIME_COST', '1');
|
||||||
|
$this->setEnv('ARGON2_THREADS', '1');
|
||||||
|
|
||||||
|
$this->db = new FakeDatabase();
|
||||||
|
$this->session = new SessionManager(new Config(), true);
|
||||||
|
$this->hasher = new PasswordHasher(new Config());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
foreach ($this->touchedKeys as $key) {
|
||||||
|
putenv($key);
|
||||||
|
}
|
||||||
|
$this->touchedKeys = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setEnv(string $key, string $value): void
|
||||||
|
{
|
||||||
|
$this->touchedKeys[] = $key;
|
||||||
|
putenv($key . '=' . $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function service(): AuthService
|
||||||
|
{
|
||||||
|
return new AuthService($this->db, new Config(), $this->session, $this->hasher);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $overrides
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function userRow(array $overrides = []): array
|
||||||
|
{
|
||||||
|
return array_merge([
|
||||||
|
'id' => 7,
|
||||||
|
'password_hash' => $this->hasher->hash('correct horse'),
|
||||||
|
'role_id' => 3,
|
||||||
|
'failed_login_attempts' => 0,
|
||||||
|
'lockout_until' => null,
|
||||||
|
'default_route' => '/admin/dashboard',
|
||||||
|
], $overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownEmailFailsAndRecordsIpFailure(): void
|
||||||
|
{
|
||||||
|
$this->db->userRow = null;
|
||||||
|
|
||||||
|
$result = $this->service()->authenticate('ghost@wakdo.local', 'whatever', '203.0.113.1', self::NOW);
|
||||||
|
|
||||||
|
self::assertFalse($result->success);
|
||||||
|
self::assertSame('Email ou mot de passe incorrect', $result->error);
|
||||||
|
self::assertNull($this->session->getInt('user_id'));
|
||||||
|
self::assertTrue($this->db->wrote('INSERT INTO login_throttle'));
|
||||||
|
self::assertSame(['auth.login_failed'], $this->db->auditActions());
|
||||||
|
self::assertSame(['begin', 'commit'], $this->db->transactionEvents);
|
||||||
|
// Anti-enumeration : meme profil d'I/O que le chemin email connu, via un
|
||||||
|
// UPDATE user no-op sur id = 0 (ne touche aucune ligne, ne revele rien).
|
||||||
|
self::assertTrue($this->db->wrote('UPDATE user SET failed_login_attempts'));
|
||||||
|
self::assertSame(0, $this->firstWrite('UPDATE user SET failed_login_attempts')['params']['id'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFailureWriteProfileIsIdenticalForKnownAndUnknownEmail(): void
|
||||||
|
{
|
||||||
|
// Email inconnu.
|
||||||
|
$this->db->userRow = null;
|
||||||
|
$this->service()->authenticate('ghost@wakdo.local', 'whatever', '203.0.113.9', self::NOW);
|
||||||
|
$unknownWrites = count($this->db->writes);
|
||||||
|
|
||||||
|
// Email connu, mauvais mot de passe (instances neuves pour isoler le compteur).
|
||||||
|
$db2 = new FakeDatabase();
|
||||||
|
$db2->userRow = $this->userRow();
|
||||||
|
$service2 = new AuthService($db2, new Config(), new SessionManager(new Config(), true), $this->hasher);
|
||||||
|
$service2->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.9', self::NOW);
|
||||||
|
$knownWrites = count($db2->writes);
|
||||||
|
|
||||||
|
self::assertSame($knownWrites, $unknownWrites, 'meme nombre d ecritures (anti-enumeration)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAccountLockedIsRejectedBeforeAnyWrite(): void
|
||||||
|
{
|
||||||
|
// lockout_until dans le futur : porte PRE-3, aucun increment ni ecriture.
|
||||||
|
$this->db->userRow = $this->userRow([
|
||||||
|
'lockout_until' => date('Y-m-d H:i:s', self::NOW + 120),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->service()->authenticate('admin@wakdo.local', 'correct horse', '203.0.113.1', self::NOW);
|
||||||
|
|
||||||
|
self::assertFalse($result->success);
|
||||||
|
self::assertSame([], $this->db->writes);
|
||||||
|
self::assertSame([], $this->db->transactionEvents);
|
||||||
|
self::assertNull($this->session->getInt('user_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIpLockedIsRejectedBeforeAnyWrite(): void
|
||||||
|
{
|
||||||
|
$this->db->userRow = $this->userRow();
|
||||||
|
$this->db->ipLockoutUntil = date('Y-m-d H:i:s', self::NOW + 300);
|
||||||
|
|
||||||
|
$result = $this->service()->authenticate('admin@wakdo.local', 'correct horse', '203.0.113.1', self::NOW);
|
||||||
|
|
||||||
|
self::assertFalse($result->success);
|
||||||
|
self::assertSame([], $this->db->writes);
|
||||||
|
self::assertNull($this->session->getInt('user_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWrongPasswordRecordsAccountAndIpFailure(): void
|
||||||
|
{
|
||||||
|
$this->db->userRow = $this->userRow(['failed_login_attempts' => 0]);
|
||||||
|
|
||||||
|
$result = $this->service()->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.1', self::NOW);
|
||||||
|
|
||||||
|
self::assertFalse($result->success);
|
||||||
|
self::assertTrue($this->db->wrote('UPDATE user SET failed_login_attempts'));
|
||||||
|
self::assertTrue($this->db->wrote('INSERT INTO login_throttle'));
|
||||||
|
self::assertSame(['auth.login_failed'], $this->db->auditActions());
|
||||||
|
self::assertSame(['begin', 'commit'], $this->db->transactionEvents);
|
||||||
|
self::assertNull($this->session->getInt('user_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWrongPasswordSetsLockoutOnceThresholdReached(): void
|
||||||
|
{
|
||||||
|
// 4 echecs deja enregistres : le 5e (= seuil) doit poser un lockout_until.
|
||||||
|
$this->db->userRow = $this->userRow(['failed_login_attempts' => 4]);
|
||||||
|
|
||||||
|
$this->service()->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.1', self::NOW);
|
||||||
|
|
||||||
|
$userUpdate = $this->firstWrite('UPDATE user SET failed_login_attempts');
|
||||||
|
self::assertSame(5, $userUpdate['params']['attempts'] ?? null);
|
||||||
|
self::assertSame(date('Y-m-d H:i:s', self::NOW + 60), $userUpdate['params']['lock'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWrongPasswordBelowThresholdLeavesLockoutNull(): void
|
||||||
|
{
|
||||||
|
$this->db->userRow = $this->userRow(['failed_login_attempts' => 0]);
|
||||||
|
|
||||||
|
$this->service()->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.1', self::NOW);
|
||||||
|
|
||||||
|
$userUpdate = $this->firstWrite('UPDATE user SET failed_login_attempts');
|
||||||
|
self::assertSame(1, $userUpdate['params']['attempts'] ?? null);
|
||||||
|
self::assertArrayHasKey('lock', $userUpdate['params']);
|
||||||
|
self::assertNull($userUpdate['params']['lock']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIpUpsertUsesAtomicIncrementAndSqlWindowReset(): void
|
||||||
|
{
|
||||||
|
$this->db->userRow = $this->userRow(['failed_login_attempts' => 0]);
|
||||||
|
|
||||||
|
$this->service()->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.1', self::NOW);
|
||||||
|
|
||||||
|
$upsert = $this->firstWrite('INSERT INTO login_throttle');
|
||||||
|
// Increment atomique cote SQL (pas un literal PHP) -> immunise au lost-update.
|
||||||
|
self::assertStringContainsString('failed_attempts + 1', $upsert['sql']);
|
||||||
|
// Reset de fenetre decide en SQL, borne stricte sur window_started_at.
|
||||||
|
self::assertStringContainsString('IF(window_started_at < :cutoff', $upsert['sql']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIpThrottleSetsLockWhenThresholdReached(): void
|
||||||
|
{
|
||||||
|
// La relecture post-upsert renvoie 20 (= IP_THROTTLE_MAX_ATTEMPTS) : verrou pose.
|
||||||
|
$this->db->userRow = $this->userRow(['failed_login_attempts' => 0]);
|
||||||
|
$this->db->throttleRow = ['failed_attempts' => 20];
|
||||||
|
|
||||||
|
$this->service()->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.1', self::NOW);
|
||||||
|
|
||||||
|
$lockWrite = $this->firstWrite('UPDATE login_throttle SET lockout_until = :lock');
|
||||||
|
self::assertSame(date('Y-m-d H:i:s', self::NOW + 60), $lockWrite['params']['lock'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIpThrottleLeavesLockNullBelowThreshold(): void
|
||||||
|
{
|
||||||
|
$this->db->userRow = $this->userRow(['failed_login_attempts' => 0]);
|
||||||
|
$this->db->throttleRow = ['failed_attempts' => 3];
|
||||||
|
|
||||||
|
$this->service()->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.1', self::NOW);
|
||||||
|
|
||||||
|
$lockWrite = $this->firstWrite('UPDATE login_throttle SET lockout_until = :lock');
|
||||||
|
self::assertArrayHasKey('lock', $lockWrite['params']);
|
||||||
|
self::assertNull($lockWrite['params']['lock']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCorrectCredentialsSucceedAndOpenSession(): void
|
||||||
|
{
|
||||||
|
$this->db->userRow = $this->userRow();
|
||||||
|
|
||||||
|
$result = $this->service()->authenticate('admin@wakdo.local', 'correct horse', '203.0.113.1', self::NOW);
|
||||||
|
|
||||||
|
self::assertTrue($result->success);
|
||||||
|
self::assertSame(7, $result->userId);
|
||||||
|
self::assertSame(3, $result->roleId);
|
||||||
|
self::assertSame('/admin/dashboard', $result->redirectTo);
|
||||||
|
|
||||||
|
self::assertSame(7, $this->session->getInt('user_id'));
|
||||||
|
self::assertSame(3, $this->session->getInt('role_id'));
|
||||||
|
self::assertSame(self::NOW, $this->session->getInt('logged_in_at'));
|
||||||
|
self::assertSame(self::NOW, $this->session->getInt('last_activity'));
|
||||||
|
|
||||||
|
// RG-5/RG-9 : reset compteur + clear throttle + audit succes, 1 transaction.
|
||||||
|
self::assertTrue($this->db->wrote('UPDATE user SET failed_login_attempts = 0'));
|
||||||
|
self::assertTrue($this->db->wrote('UPDATE login_throttle SET failed_attempts = 0'));
|
||||||
|
self::assertSame(['auth.login_success'], $this->db->auditActions());
|
||||||
|
self::assertSame(['begin', 'commit'], $this->db->transactionEvents);
|
||||||
|
|
||||||
|
// RG-5 : last_login_at pose a l'instant fige (assertion explicite, pas
|
||||||
|
// seulement le prefixe de la requete).
|
||||||
|
self::assertSame(date('Y-m-d H:i:s', self::NOW), $this->firstWrite('last_login_at')['params']['now'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSuccessRotatesCsrfToken(): void
|
||||||
|
{
|
||||||
|
$this->db->userRow = $this->userRow();
|
||||||
|
$before = Csrf::token($this->session);
|
||||||
|
|
||||||
|
$this->service()->authenticate('admin@wakdo.local', 'correct horse', '203.0.113.1', self::NOW);
|
||||||
|
|
||||||
|
self::assertFalse(Csrf::validate($this->session, $before));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFailClosedWhenDatabaseThrowsOnFailurePath(): void
|
||||||
|
{
|
||||||
|
$this->db->userRow = $this->userRow();
|
||||||
|
$this->db->failOnExecute = new RuntimeException('db down');
|
||||||
|
|
||||||
|
$threw = false;
|
||||||
|
try {
|
||||||
|
$this->service()->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.1', self::NOW);
|
||||||
|
} catch (RuntimeException) {
|
||||||
|
$threw = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertTrue($threw, 'une panne DB doit remonter, pas etre avalee');
|
||||||
|
self::assertSame(['begin', 'rollback'], $this->db->transactionEvents);
|
||||||
|
self::assertNull($this->session->getInt('user_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFailClosedOnSuccessPathDoesNotOpenSession(): void
|
||||||
|
{
|
||||||
|
// Mot de passe correct mais la base echoue pendant recordSuccess :
|
||||||
|
// l'identite ne doit jamais etre posee en session (ecriture avant identite).
|
||||||
|
$this->db->userRow = $this->userRow();
|
||||||
|
$this->db->failOnExecute = new RuntimeException('db down');
|
||||||
|
|
||||||
|
$threw = false;
|
||||||
|
try {
|
||||||
|
$this->service()->authenticate('admin@wakdo.local', 'correct horse', '203.0.113.1', self::NOW);
|
||||||
|
} catch (RuntimeException) {
|
||||||
|
$threw = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertTrue($threw);
|
||||||
|
self::assertNull($this->session->getInt('user_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogoutClearsSession(): void
|
||||||
|
{
|
||||||
|
$this->session->set('user_id', 7);
|
||||||
|
|
||||||
|
$this->service()->logout();
|
||||||
|
|
||||||
|
self::assertNull($this->session->getInt('user_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{sql: string, params: array<string|int, mixed>}
|
||||||
|
*/
|
||||||
|
private function firstWrite(string $needle): array
|
||||||
|
{
|
||||||
|
foreach ($this->db->writes as $write) {
|
||||||
|
if (str_contains($write['sql'], $needle)) {
|
||||||
|
return $write;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::fail('aucune ecriture ne contient: ' . $needle);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
tests/Unit/Auth/CsrfTest.php
Normal file
74
tests/Unit/Auth/CsrfTest.php
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Auth;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use App\Auth\Csrf;
|
||||||
|
use App\Auth\SessionManager;
|
||||||
|
use App\Core\Config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSRF synchroniseur teste sur un SessionManager en mode test (sac memoire),
|
||||||
|
* donc sans session PHP reelle ni effet de bord d'en-tete.
|
||||||
|
*/
|
||||||
|
final class CsrfTest extends TestCase
|
||||||
|
{
|
||||||
|
private function session(): SessionManager
|
||||||
|
{
|
||||||
|
return new SessionManager(new Config(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTokenIsHighEntropyHex(): void
|
||||||
|
{
|
||||||
|
$token = Csrf::token($this->session());
|
||||||
|
|
||||||
|
// 32 octets CSPRNG en hexadecimal => 64 caracteres.
|
||||||
|
self::assertSame(64, strlen($token));
|
||||||
|
self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTokenIsStableAcrossCalls(): void
|
||||||
|
{
|
||||||
|
$session = $this->session();
|
||||||
|
|
||||||
|
self::assertSame(Csrf::token($session), Csrf::token($session));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateAcceptsCorrectToken(): void
|
||||||
|
{
|
||||||
|
$session = $this->session();
|
||||||
|
$token = Csrf::token($session);
|
||||||
|
|
||||||
|
self::assertTrue(Csrf::validate($session, $token));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateRejectsWrongOrEmptyToken(): void
|
||||||
|
{
|
||||||
|
$session = $this->session();
|
||||||
|
Csrf::token($session);
|
||||||
|
|
||||||
|
self::assertFalse(Csrf::validate($session, 'wrong'));
|
||||||
|
self::assertFalse(Csrf::validate($session, ''));
|
||||||
|
self::assertFalse(Csrf::validate($session, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateFalseWhenNoTokenYet(): void
|
||||||
|
{
|
||||||
|
// Aucun token genere en session : meme une soumission non vide echoue.
|
||||||
|
self::assertFalse(Csrf::validate($this->session(), 'anything'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRotateChangesTokenAndInvalidatesOld(): void
|
||||||
|
{
|
||||||
|
$session = $this->session();
|
||||||
|
$old = Csrf::token($session);
|
||||||
|
|
||||||
|
$new = Csrf::rotate($session);
|
||||||
|
|
||||||
|
self::assertNotSame($old, $new);
|
||||||
|
self::assertFalse(Csrf::validate($session, $old));
|
||||||
|
self::assertTrue(Csrf::validate($session, $new));
|
||||||
|
}
|
||||||
|
}
|
||||||
87
tests/Unit/Auth/PasswordHasherTest.php
Normal file
87
tests/Unit/Auth/PasswordHasherTest.php
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Auth;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use App\Auth\PasswordHasher;
|
||||||
|
use App\Core\Config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifie le hash argon2id (cout pilote par l'environnement) et le leurre de
|
||||||
|
* timing. Les couts sont volontairement abaisses ici pour garder la suite rapide.
|
||||||
|
*/
|
||||||
|
final class PasswordHasherTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $touchedKeys = [];
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
// Cout reduit : les tests ne valident pas la robustesse cryptographique
|
||||||
|
// (couverte par les valeurs de prod) mais la mecanique hash/verify/cout.
|
||||||
|
$this->setEnv('ARGON2_MEMORY_COST', '1024');
|
||||||
|
$this->setEnv('ARGON2_TIME_COST', '1');
|
||||||
|
$this->setEnv('ARGON2_THREADS', '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
foreach ($this->touchedKeys as $key) {
|
||||||
|
putenv($key);
|
||||||
|
}
|
||||||
|
$this->touchedKeys = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setEnv(string $key, string $value): void
|
||||||
|
{
|
||||||
|
$this->touchedKeys[] = $key;
|
||||||
|
putenv($key . '=' . $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasher(): PasswordHasher
|
||||||
|
{
|
||||||
|
return new PasswordHasher(new Config());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHashIsVerifiable(): void
|
||||||
|
{
|
||||||
|
$hasher = $this->hasher();
|
||||||
|
$hash = $hasher->hash('WakdoAdmin2026!');
|
||||||
|
|
||||||
|
self::assertTrue($hasher->verify('WakdoAdmin2026!', $hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWrongPasswordIsRejected(): void
|
||||||
|
{
|
||||||
|
$hasher = $this->hasher();
|
||||||
|
$hash = $hasher->hash('correct horse');
|
||||||
|
|
||||||
|
self::assertFalse($hasher->verify('battery staple', $hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHashUsesArgon2idAlgorithm(): void
|
||||||
|
{
|
||||||
|
$info = password_get_info($this->hasher()->hash('x'));
|
||||||
|
|
||||||
|
self::assertSame('argon2id', $info['algoName']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHashEmbedsConfiguredCost(): void
|
||||||
|
{
|
||||||
|
$info = password_get_info($this->hasher()->hash('x'));
|
||||||
|
|
||||||
|
self::assertSame(1024, $info['options']['memory_cost'] ?? null);
|
||||||
|
self::assertSame(1, $info['options']['time_cost'] ?? null);
|
||||||
|
self::assertSame(1, $info['options']['threads'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyDecoyRunsWithoutThrowing(): void
|
||||||
|
{
|
||||||
|
// Le leurre ne doit jamais lever ni valider un mot de passe : il ne sert
|
||||||
|
// qu'a consommer un temps CPU comparable au chemin nominal.
|
||||||
|
$this->expectNotToPerformAssertions();
|
||||||
|
$this->hasher()->verifyDecoy('any-submitted-password');
|
||||||
|
}
|
||||||
|
}
|
||||||
175
tests/Unit/Auth/PasswordResetControllerTest.php
Normal file
175
tests/Unit/Auth/PasswordResetControllerTest.php
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Auth;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use App\Auth\Csrf;
|
||||||
|
use App\Auth\PasswordHasher;
|
||||||
|
use App\Auth\PasswordResetService;
|
||||||
|
use App\Auth\SessionManager;
|
||||||
|
use App\Controllers\PasswordResetController;
|
||||||
|
use App\Core\Config;
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Request;
|
||||||
|
use App\Tests\Support\FakeDatabase;
|
||||||
|
use App\Tests\Support\SpyMailer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sous-classe de test : injecte session test, FakeDatabase et SpyMailer dans le
|
||||||
|
* controleur de reinitialisation.
|
||||||
|
*/
|
||||||
|
final class TestPasswordResetController extends PasswordResetController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
Request $request,
|
||||||
|
Config $config,
|
||||||
|
Database $database,
|
||||||
|
private readonly SessionManager $testSession,
|
||||||
|
private readonly FakeDatabase $fakeDb,
|
||||||
|
private readonly SpyMailer $spyMailer,
|
||||||
|
) {
|
||||||
|
parent::__construct($request, $config, $database);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sessionManager(): SessionManager
|
||||||
|
{
|
||||||
|
return $this->testSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resetService(): PasswordResetService
|
||||||
|
{
|
||||||
|
return new PasswordResetService($this->fakeDb, $this->config, new PasswordHasher($this->config), $this->spyMailer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class PasswordResetControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $touchedKeys = [];
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->setEnv('PASSWORD_RESET_TTL', '3600');
|
||||||
|
$this->setEnv('APP_URL_ADMIN', 'https://admin.wakdo.test');
|
||||||
|
$this->setEnv('ARGON2_MEMORY_COST', '1024');
|
||||||
|
$this->setEnv('ARGON2_TIME_COST', '1');
|
||||||
|
$this->setEnv('ARGON2_THREADS', '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
foreach ($this->touchedKeys as $key) {
|
||||||
|
putenv($key);
|
||||||
|
}
|
||||||
|
$this->touchedKeys = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setEnv(string $key, string $value): void
|
||||||
|
{
|
||||||
|
$this->touchedKeys[] = $key;
|
||||||
|
putenv($key . '=' . $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $form
|
||||||
|
*/
|
||||||
|
private function post(array $form, string $path): Request
|
||||||
|
{
|
||||||
|
return new Request(
|
||||||
|
'POST',
|
||||||
|
$path,
|
||||||
|
[],
|
||||||
|
['content-type' => 'application/x-www-form-urlencoded'],
|
||||||
|
http_build_query($form),
|
||||||
|
'203.0.113.5',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function controller(
|
||||||
|
Request $request,
|
||||||
|
SessionManager $session,
|
||||||
|
FakeDatabase $db,
|
||||||
|
SpyMailer $mailer,
|
||||||
|
): TestPasswordResetController {
|
||||||
|
return new TestPasswordResetController($request, new Config(), new Database(new Config()), $session, $db, $mailer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testShowRequestRendersCsrfField(): void
|
||||||
|
{
|
||||||
|
$session = new SessionManager(new Config(), true);
|
||||||
|
$request = new Request('GET', '/forgot_password', [], [], '', '203.0.113.5');
|
||||||
|
|
||||||
|
$response = $this->controller($request, $session, new FakeDatabase(), new SpyMailer())->showRequest();
|
||||||
|
|
||||||
|
self::assertSame(200, $response->status());
|
||||||
|
self::assertStringContainsString('name="_csrf"', $response->body());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSubmitRequestRejectsInvalidCsrf(): void
|
||||||
|
{
|
||||||
|
$session = new SessionManager(new Config(), true);
|
||||||
|
Csrf::token($session);
|
||||||
|
$mailer = new SpyMailer();
|
||||||
|
|
||||||
|
$request = $this->post(['_csrf' => 'wrong', 'email' => 'admin@wakdo.local'], '/forgot_password');
|
||||||
|
$response = $this->controller($request, $session, new FakeDatabase(), $mailer)->submitRequest();
|
||||||
|
|
||||||
|
self::assertSame(403, $response->status());
|
||||||
|
self::assertSame([], $mailer->sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSubmitRequestUnknownEmailIsNeutralAndSilent(): void
|
||||||
|
{
|
||||||
|
$session = new SessionManager(new Config(), true);
|
||||||
|
$token = Csrf::token($session);
|
||||||
|
$db = new FakeDatabase();
|
||||||
|
$db->emailLookupRow = null;
|
||||||
|
$mailer = new SpyMailer();
|
||||||
|
|
||||||
|
$request = $this->post(['_csrf' => $token, 'email' => 'ghost@wakdo.local'], '/forgot_password');
|
||||||
|
$response = $this->controller($request, $session, $db, $mailer)->submitRequest();
|
||||||
|
|
||||||
|
self::assertSame(200, $response->status());
|
||||||
|
self::assertStringContainsString('Si un compte', $response->body());
|
||||||
|
self::assertSame([], $mailer->sent);
|
||||||
|
self::assertSame([], $db->writes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSubmitConfirmPasswordMismatchRendersError(): void
|
||||||
|
{
|
||||||
|
$session = new SessionManager(new Config(), true);
|
||||||
|
$token = Csrf::token($session);
|
||||||
|
|
||||||
|
$request = $this->post([
|
||||||
|
'_csrf' => $token,
|
||||||
|
'token' => 'raw-token',
|
||||||
|
'password' => 'longenough1',
|
||||||
|
'password_confirm' => 'different01',
|
||||||
|
], '/reset_password');
|
||||||
|
$response = $this->controller($request, $session, new FakeDatabase(), new SpyMailer())->submitConfirm();
|
||||||
|
|
||||||
|
self::assertSame(200, $response->status());
|
||||||
|
self::assertStringContainsString('ne correspondent pas', $response->body());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSubmitConfirmValidTokenRedirectsToLogin(): void
|
||||||
|
{
|
||||||
|
$session = new SessionManager(new Config(), true);
|
||||||
|
$token = Csrf::token($session);
|
||||||
|
$db = new FakeDatabase();
|
||||||
|
$db->resetUserRow = ['id' => 7, 'role_id' => 3, 'password_reset_token_hash' => hash('sha256', 'raw-token')];
|
||||||
|
|
||||||
|
$request = $this->post([
|
||||||
|
'_csrf' => $token,
|
||||||
|
'token' => 'raw-token',
|
||||||
|
'password' => 'brandnewpassword',
|
||||||
|
'password_confirm' => 'brandnewpassword',
|
||||||
|
], '/reset_password');
|
||||||
|
$response = $this->controller($request, $session, $db, new SpyMailer())->submitConfirm();
|
||||||
|
|
||||||
|
self::assertSame(302, $response->status());
|
||||||
|
self::assertSame('/login?reset=ok', $response->header('Location'));
|
||||||
|
}
|
||||||
|
}
|
||||||
154
tests/Unit/Auth/PasswordResetServiceTest.php
Normal file
154
tests/Unit/Auth/PasswordResetServiceTest.php
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Auth;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use App\Auth\PasswordHasher;
|
||||||
|
use App\Auth\PasswordResetService;
|
||||||
|
use App\Core\Config;
|
||||||
|
use App\Tests\Support\FakeDatabase;
|
||||||
|
use App\Tests\Support\SpyMailer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RESET_PASSWORD (mlt.md 12.3) : neutralite anti-enumeration, token CSPRNG hashe
|
||||||
|
* au repos, usage unique, confirmation transactionnelle. FakeDatabase + SpyMailer.
|
||||||
|
*/
|
||||||
|
final class PasswordResetServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private const NOW = 1_700_000_000;
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $touchedKeys = [];
|
||||||
|
|
||||||
|
private FakeDatabase $db;
|
||||||
|
private SpyMailer $mailer;
|
||||||
|
private PasswordHasher $hasher;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->setEnv('PASSWORD_RESET_TTL', '3600');
|
||||||
|
$this->setEnv('ARGON2_MEMORY_COST', '1024');
|
||||||
|
$this->setEnv('ARGON2_TIME_COST', '1');
|
||||||
|
$this->setEnv('ARGON2_THREADS', '1');
|
||||||
|
|
||||||
|
$this->db = new FakeDatabase();
|
||||||
|
$this->mailer = new SpyMailer();
|
||||||
|
$this->hasher = new PasswordHasher(new Config());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
foreach ($this->touchedKeys as $key) {
|
||||||
|
putenv($key);
|
||||||
|
}
|
||||||
|
$this->touchedKeys = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setEnv(string $key, string $value): void
|
||||||
|
{
|
||||||
|
$this->touchedKeys[] = $key;
|
||||||
|
putenv($key . '=' . $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function service(): PasswordResetService
|
||||||
|
{
|
||||||
|
return new PasswordResetService($this->db, new Config(), $this->hasher, $this->mailer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRequestUnknownEmailWritesNothingAndSendsNoMail(): void
|
||||||
|
{
|
||||||
|
$this->db->emailLookupRow = null;
|
||||||
|
|
||||||
|
$this->service()->requestReset('ghost@wakdo.local', 'https://admin.wakdo.test', self::NOW);
|
||||||
|
|
||||||
|
self::assertSame([], $this->db->writes);
|
||||||
|
self::assertSame([], $this->mailer->sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRequestActiveUserStoresHashAndMailsRawTokenOnce(): void
|
||||||
|
{
|
||||||
|
$this->db->emailLookupRow = ['id' => 7];
|
||||||
|
|
||||||
|
$this->service()->requestReset('admin@wakdo.local', 'https://admin.wakdo.test', self::NOW);
|
||||||
|
|
||||||
|
self::assertCount(1, $this->mailer->sent);
|
||||||
|
$url = $this->mailer->sent[0]['resetUrl'];
|
||||||
|
self::assertStringStartsWith('https://admin.wakdo.test/reset_password?token=', $url);
|
||||||
|
|
||||||
|
$query = (string) parse_url($url, PHP_URL_QUERY);
|
||||||
|
parse_str($query, $parsed);
|
||||||
|
$rawToken = is_string($parsed['token'] ?? null) ? $parsed['token'] : '';
|
||||||
|
self::assertSame(64, strlen($rawToken));
|
||||||
|
|
||||||
|
$write = $this->firstWrite('password_reset_token_hash = :hash');
|
||||||
|
$storedHash = $write['params']['hash'] ?? null;
|
||||||
|
// Le brut n'est jamais persiste : ce qui est stocke est son SHA-256.
|
||||||
|
self::assertSame(hash('sha256', $rawToken), $storedHash);
|
||||||
|
self::assertNotSame($rawToken, $storedHash);
|
||||||
|
self::assertSame(date('Y-m-d H:i:s', self::NOW + 3600), $write['params']['exp'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConfirmShortPasswordIsRejectedBeforeAnyWrite(): void
|
||||||
|
{
|
||||||
|
$this->db->resetUserRow = ['id' => 7, 'role_id' => 3, 'password_reset_token_hash' => hash('sha256', 'tok')];
|
||||||
|
|
||||||
|
$result = $this->service()->confirmReset('tok', 'short', self::NOW);
|
||||||
|
|
||||||
|
self::assertFalse($result->success);
|
||||||
|
self::assertSame([], $this->db->writes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConfirmUnknownOrExpiredTokenFails(): void
|
||||||
|
{
|
||||||
|
// resetUserRow null = aucune ligne (token inconnu, expire, ou deja consomme).
|
||||||
|
$this->db->resetUserRow = null;
|
||||||
|
|
||||||
|
$result = $this->service()->confirmReset('whatever', 'newpassword123', self::NOW);
|
||||||
|
|
||||||
|
self::assertFalse($result->success);
|
||||||
|
self::assertSame('Lien invalide ou expire.', $result->error);
|
||||||
|
self::assertSame([], $this->db->writes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConfirmValidTokenResetsPassword(): void
|
||||||
|
{
|
||||||
|
$raw = 'a-valid-raw-token';
|
||||||
|
$this->db->resetUserRow = [
|
||||||
|
'id' => 7,
|
||||||
|
'role_id' => 3,
|
||||||
|
'password_reset_token_hash' => hash('sha256', $raw),
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->service()->confirmReset($raw, 'brandnewpassword', self::NOW);
|
||||||
|
|
||||||
|
self::assertTrue($result->success);
|
||||||
|
self::assertSame(7, $result->userId);
|
||||||
|
self::assertSame('/login?reset=ok', $result->redirectTo);
|
||||||
|
|
||||||
|
// Nouveau mot de passe argon2id verifiable + token efface (usage unique).
|
||||||
|
$write = $this->firstWrite('SET password_hash = :hash');
|
||||||
|
$newHash = $write['params']['hash'] ?? '';
|
||||||
|
self::assertIsString($newHash);
|
||||||
|
self::assertTrue($this->hasher->verify('brandnewpassword', $newHash));
|
||||||
|
self::assertStringContainsString('password_reset_token_hash = NULL', $write['sql']);
|
||||||
|
|
||||||
|
self::assertSame(['auth.password_reset'], $this->db->auditActions());
|
||||||
|
self::assertSame(['begin', 'commit'], $this->db->transactionEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{sql: string, params: array<string|int, mixed>}
|
||||||
|
*/
|
||||||
|
private function firstWrite(string $needle): array
|
||||||
|
{
|
||||||
|
foreach ($this->db->writes as $write) {
|
||||||
|
if (str_contains($write['sql'], $needle)) {
|
||||||
|
return $write;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::fail('aucune ecriture ne contient: ' . $needle);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
tests/Unit/Auth/SessionGuardTest.php
Normal file
121
tests/Unit/Auth/SessionGuardTest.php
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Auth;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use App\Auth\SessionGuard;
|
||||||
|
use App\Auth\SessionManager;
|
||||||
|
use App\Core\Config;
|
||||||
|
use App\Tests\Support\FakeDatabase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garde de session (RG-6 + RG-T02) : presence d'identite, bornes idle/absolue,
|
||||||
|
* re-verification is_active. Temps fige, session en mode test, is_active fake.
|
||||||
|
*/
|
||||||
|
final class SessionGuardTest extends TestCase
|
||||||
|
{
|
||||||
|
private const NOW = 1_700_000_000;
|
||||||
|
private const IDLE = 14400; // 4h
|
||||||
|
private const ABSOLUTE = 36000; // 10h
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $touchedKeys = [];
|
||||||
|
|
||||||
|
private FakeDatabase $db;
|
||||||
|
private SessionManager $session;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->setEnv('SESSION_LIFETIME_IDLE', (string) self::IDLE);
|
||||||
|
$this->setEnv('SESSION_LIFETIME_ABSOLUTE', (string) self::ABSOLUTE);
|
||||||
|
|
||||||
|
$this->db = new FakeDatabase();
|
||||||
|
$this->session = new SessionManager(new Config(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
foreach ($this->touchedKeys as $key) {
|
||||||
|
putenv($key);
|
||||||
|
}
|
||||||
|
$this->touchedKeys = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setEnv(string $key, string $value): void
|
||||||
|
{
|
||||||
|
$this->touchedKeys[] = $key;
|
||||||
|
putenv($key . '=' . $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function guard(): SessionGuard
|
||||||
|
{
|
||||||
|
return new SessionGuard($this->session, $this->db, new Config());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedSession(int $loggedInAt, int $lastActivity): void
|
||||||
|
{
|
||||||
|
$this->session->set('user_id', 7);
|
||||||
|
$this->session->set('role_id', 3);
|
||||||
|
$this->session->set('logged_in_at', $loggedInAt);
|
||||||
|
$this->session->set('last_activity', $lastActivity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoSessionIsRejected(): void
|
||||||
|
{
|
||||||
|
$result = $this->guard()->check(self::NOW);
|
||||||
|
|
||||||
|
self::assertFalse($result->authenticated);
|
||||||
|
self::assertSame('no_session', $result->reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidSessionWithinWindowsRefreshesActivity(): void
|
||||||
|
{
|
||||||
|
$this->seedSession(self::NOW - 100, self::NOW - 50);
|
||||||
|
$this->db->guardUserRow = ['is_active' => 1];
|
||||||
|
|
||||||
|
$result = $this->guard()->check(self::NOW);
|
||||||
|
|
||||||
|
self::assertTrue($result->authenticated);
|
||||||
|
self::assertSame(7, $result->userId);
|
||||||
|
self::assertSame(3, $result->roleId);
|
||||||
|
self::assertNull($result->reason);
|
||||||
|
// Fenetre idle glissante : last_activity rafraichi a now.
|
||||||
|
self::assertSame(self::NOW, $this->session->getInt('last_activity'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIdleTimeoutIsRejected(): void
|
||||||
|
{
|
||||||
|
$this->seedSession(self::NOW - 200, self::NOW - (self::IDLE + 1));
|
||||||
|
$this->db->guardUserRow = ['is_active' => 1];
|
||||||
|
|
||||||
|
$result = $this->guard()->check(self::NOW);
|
||||||
|
|
||||||
|
self::assertFalse($result->authenticated);
|
||||||
|
self::assertSame('idle_timeout', $result->reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAbsoluteTimeoutIsRejected(): void
|
||||||
|
{
|
||||||
|
// Activite recente (idle OK) mais session ouverte depuis plus de 10h.
|
||||||
|
$this->seedSession(self::NOW - (self::ABSOLUTE + 1), self::NOW - 10);
|
||||||
|
$this->db->guardUserRow = ['is_active' => 1];
|
||||||
|
|
||||||
|
$result = $this->guard()->check(self::NOW);
|
||||||
|
|
||||||
|
self::assertFalse($result->authenticated);
|
||||||
|
self::assertSame('absolute_timeout', $result->reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInactiveUserIsRejected(): void
|
||||||
|
{
|
||||||
|
$this->seedSession(self::NOW - 100, self::NOW - 50);
|
||||||
|
$this->db->guardUserRow = ['is_active' => 0];
|
||||||
|
|
||||||
|
$result = $this->guard()->check(self::NOW);
|
||||||
|
|
||||||
|
self::assertFalse($result->authenticated);
|
||||||
|
self::assertSame('inactive', $result->reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
tests/Unit/Auth/ThrottlePolicyTest.php
Normal file
133
tests/Unit/Auth/ThrottlePolicyTest.php
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Auth;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use App\Auth\ThrottlePolicy;
|
||||||
|
use App\Core\Config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le backoff degressif est le calcul de securite le plus delicat de l'auth :
|
||||||
|
* on le verrouille par des cas explicites (seuil, doublement, plafond, debordement).
|
||||||
|
*/
|
||||||
|
final class ThrottlePolicyTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $touchedKeys = [];
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
foreach ($this->touchedKeys as $key) {
|
||||||
|
putenv($key);
|
||||||
|
}
|
||||||
|
$this->touchedKeys = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setEnv(string $key, string $value): void
|
||||||
|
{
|
||||||
|
$this->touchedKeys[] = $key;
|
||||||
|
putenv($key . '=' . $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function policy(int $threshold = 5, int $base = 60, int $max = 900): ThrottlePolicy
|
||||||
|
{
|
||||||
|
return new ThrottlePolicy($threshold, $base, $max);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoLockoutBelowThreshold(): void
|
||||||
|
{
|
||||||
|
$policy = $this->policy();
|
||||||
|
|
||||||
|
self::assertSame(0, $policy->lockoutSeconds(0));
|
||||||
|
self::assertSame(0, $policy->lockoutSeconds(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBaseDelayAtThreshold(): void
|
||||||
|
{
|
||||||
|
self::assertSame(60, $this->policy()->lockoutSeconds(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{0: int, 1: int}>
|
||||||
|
*/
|
||||||
|
public static function degressiveCurveProvider(): array
|
||||||
|
{
|
||||||
|
// threshold=5, base=60, max=900 : 60, 120, 240, 480, puis plafond 900.
|
||||||
|
return [
|
||||||
|
[5, 60],
|
||||||
|
[6, 120],
|
||||||
|
[7, 240],
|
||||||
|
[8, 480],
|
||||||
|
[9, 900], // 60*2^4 = 960 -> plafonne a 900
|
||||||
|
[10, 900], // au-dela : reste plafonne
|
||||||
|
[20, 900],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('degressiveCurveProvider')]
|
||||||
|
public function testDegressiveCurveIsCappedAtMax(int $attempts, int $expected): void
|
||||||
|
{
|
||||||
|
self::assertSame($expected, $this->policy()->lockoutSeconds($attempts));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoIntegerOverflowForHugeAttemptCount(): void
|
||||||
|
{
|
||||||
|
// Un compteur enorme ne doit jamais deborder en negatif ni lever : on
|
||||||
|
// reste plafonne au maximum configure.
|
||||||
|
self::assertSame(900, $this->policy()->lockoutSeconds(1000));
|
||||||
|
self::assertSame(900, $this->policy()->lockoutSeconds(PHP_INT_MAX));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsLockedUntilFutureIsTrue(): void
|
||||||
|
{
|
||||||
|
$now = 1_000_000;
|
||||||
|
$future = date('Y-m-d H:i:s', $now + 120);
|
||||||
|
|
||||||
|
self::assertTrue($this->policy()->isLockedUntil($future, $now));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsLockedUntilPastOrNullIsFalse(): void
|
||||||
|
{
|
||||||
|
$now = 1_000_000;
|
||||||
|
$past = date('Y-m-d H:i:s', $now - 1);
|
||||||
|
|
||||||
|
self::assertFalse($this->policy()->isLockedUntil($past, $now));
|
||||||
|
self::assertFalse($this->policy()->isLockedUntil(null, $now));
|
||||||
|
self::assertFalse($this->policy()->isLockedUntil('', $now));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsLockedUntilUnparseableIsFalse(): void
|
||||||
|
{
|
||||||
|
self::assertFalse($this->policy()->isLockedUntil('not-a-date', 1_000_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFromConfigAccountReadsAccountKeys(): void
|
||||||
|
{
|
||||||
|
$this->setEnv('ACCOUNT_LOCKOUT_THRESHOLD', '3');
|
||||||
|
$this->setEnv('ACCOUNT_LOCKOUT_BASE_SECONDS', '30');
|
||||||
|
$this->setEnv('ACCOUNT_LOCKOUT_MAX_SECONDS', '600');
|
||||||
|
|
||||||
|
$policy = ThrottlePolicy::fromConfig(new Config(), 'account');
|
||||||
|
|
||||||
|
self::assertSame(0, $policy->lockoutSeconds(2));
|
||||||
|
self::assertSame(30, $policy->lockoutSeconds(3));
|
||||||
|
self::assertSame(60, $policy->lockoutSeconds(4));
|
||||||
|
self::assertSame(600, $policy->lockoutSeconds(99));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFromConfigIpUsesIpThresholdWithSharedCurve(): void
|
||||||
|
{
|
||||||
|
$this->setEnv('IP_THROTTLE_MAX_ATTEMPTS', '20');
|
||||||
|
$this->setEnv('ACCOUNT_LOCKOUT_BASE_SECONDS', '60');
|
||||||
|
$this->setEnv('ACCOUNT_LOCKOUT_MAX_SECONDS', '900');
|
||||||
|
|
||||||
|
$policy = ThrottlePolicy::fromConfig(new Config(), 'ip');
|
||||||
|
|
||||||
|
self::assertSame(0, $policy->lockoutSeconds(19));
|
||||||
|
self::assertSame(60, $policy->lockoutSeconds(20));
|
||||||
|
self::assertSame(120, $policy->lockoutSeconds(21));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,3 +11,21 @@ declare(strict_types=1);
|
||||||
require __DIR__ . '/../src/app/Core/Autoloader.php';
|
require __DIR__ . '/../src/app/Core/Autoloader.php';
|
||||||
|
|
||||||
App\Core\Autoloader::register();
|
App\Core\Autoloader::register();
|
||||||
|
|
||||||
|
// Autoloader PSR-4 dedie aux classes de support de test (doubles, helpers) :
|
||||||
|
// App\Tests\... -> tests/... . Permet de partager un FakeDatabase entre suites
|
||||||
|
// sans le dupliquer dans chaque fichier de test.
|
||||||
|
spl_autoload_register(static function (string $class): void {
|
||||||
|
$prefix = 'App\\Tests\\';
|
||||||
|
|
||||||
|
if (!str_starts_with($class, $prefix)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relative = substr($class, strlen($prefix));
|
||||||
|
$path = __DIR__ . '/' . str_replace('\\', '/', $relative) . '.php';
|
||||||
|
|
||||||
|
if (is_file($path)) {
|
||||||
|
require $path;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue