fix(auth): leurre anti-enumeration sur la demande de reset (#26)
This commit is contained in:
parent
9ddb4ccb27
commit
6557dd9c6c
3 changed files with 38 additions and 3 deletions
|
|
@ -38,6 +38,13 @@ final class PasswordResetService
|
|||
);
|
||||
|
||||
if ($user === null) {
|
||||
// Anti-enumeration (RG-2) : egaliser le profil du chemin email-inconnu
|
||||
// avec celui du chemin email-connu (meme cout de generation de token +
|
||||
// UNE ecriture), sans rien persister d'exploitable. Sans ce leurre, une
|
||||
// reponse instantanee et zero ecriture trahissent qu'aucun compte ne
|
||||
// correspond a l'email.
|
||||
$this->payEnumerationDecoy($now);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -60,6 +67,26 @@ final class PasswordResetService
|
|||
$this->mailer->sendPasswordReset($email, $resetUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leurre anti-enumeration du chemin email-inconnu : reproduit le cout CPU
|
||||
* (generation d'un token CSPRNG + SHA-256) et UNE ecriture du chemin
|
||||
* email-connu, mais cible id = 0 (aucune ligne affectee, rien persiste). Le
|
||||
* temps de reponse et le nombre d'ecritures ne revelent plus l'existence
|
||||
* d'un compte (parite avec requestReset cote email connu).
|
||||
*/
|
||||
private function payEnumerationDecoy(int $now): void
|
||||
{
|
||||
$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 = 0',
|
||||
['hash' => $tokenHash, 'exp' => $expiresAt],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
|
|
|||
|
|
@ -134,7 +134,10 @@ final class PasswordResetControllerTest extends TestCase
|
|||
self::assertSame(200, $response->status());
|
||||
self::assertStringContainsString('Si un compte', $response->body());
|
||||
self::assertSame([], $mailer->sent);
|
||||
self::assertSame([], $db->writes);
|
||||
// Anti-enumeration : un leurre (UPDATE no-op sur id = 0) aligne le profil
|
||||
// d'ecritures sur le chemin email-connu ; rien n'est persiste.
|
||||
self::assertCount(1, $db->writes);
|
||||
self::assertStringContainsString('WHERE id = 0', $db->writes[0]['sql']);
|
||||
}
|
||||
|
||||
public function testSubmitConfirmPasswordMismatchRendersError(): void
|
||||
|
|
|
|||
|
|
@ -57,13 +57,18 @@ final class PasswordResetServiceTest extends TestCase
|
|||
return new PasswordResetService($this->db, new Config(), $this->hasher, $this->mailer);
|
||||
}
|
||||
|
||||
public function testRequestUnknownEmailWritesNothingAndSendsNoMail(): void
|
||||
public function testRequestUnknownEmailPaysDecoyWriteAndSendsNoMail(): void
|
||||
{
|
||||
$this->db->emailLookupRow = null;
|
||||
|
||||
$this->service()->requestReset('ghost@wakdo.local', 'https://admin.wakdo.test', self::NOW);
|
||||
|
||||
self::assertSame([], $this->db->writes);
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue