corentin_wakdo/tests/Unit/Auth/PasswordResetServiceTest.php
Imugiii 35ead030b1
All checks were successful
CI / php-lint (push) Successful in 22s
CI / php-lint (pull_request) Successful in 22s
CI / static-tests (pull_request) Successful in 36s
CI / auto-merge (push) Has been skipped
CI / auto-merge (pull_request) Successful in 6s
CI / secret-scan (push) Successful in 11s
CI / static-tests (push) Successful in 35s
CI / secret-scan (pull_request) Successful in 8s
fix(auth): leurre anti-enumeration sur la demande de reset (parite timing/ecritures)
PasswordResetService::requestReset repondait instantanement et sans ecriture sur
un email inconnu, alors que le chemin email-connu genere un token (random_bytes +
SHA-256), fait un UPDATE et envoie un mail : oracle de timing + de profil
d'ecritures revelant l'existence d'un compte. Le commit P2 #11 annoncait pourtant
une parite anti-enumeration PR-wide (vraie sur le login, pas sur le reset).

payEnumerationDecoy() reproduit le cout CPU et UNE ecriture du chemin connu, mais
sur id = 0 (aucune ligne, rien persiste) ; aucun mail. Meme pattern que le leurre
du login (UPDATE no-op sur id = 0). Tests unknown-email (service + controleur)
mis a jour : 1 ecriture leurre sur id=0, pas de mail. Suite 188 verte, PHPStan L6.
2026-06-16 12:02:19 +00:00

159 lines
5.5 KiB
PHP

<?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 testRequestUnknownEmailPaysDecoyWriteAndSendsNoMail(): void
{
$this->db->emailLookupRow = null;
$this->service()->requestReset('ghost@wakdo.local', 'https://admin.wakdo.test', self::NOW);
// Anti-enumeration : meme profil que le chemin email-connu (UNE ecriture
// de la meme forme), mais ciblant id = 0 -> rien persiste ; et aucun mail.
self::assertCount(1, $this->db->writes);
$decoy = $this->db->writes[0];
self::assertStringContainsString('password_reset_token_hash = :hash', $decoy['sql']);
self::assertStringContainsString('WHERE id = 0', $decoy['sql']);
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);
}
}