feat(pin): primitif de verification du PIN d'action sensible (RG-T13)
Some checks failed
CI / secret-scan (push) Successful in 9s
CI / php-lint (push) Successful in 17s
CI / static-tests (pull_request) Successful in 29s
CI / auto-merge (push) Has been skipped
CI / static-tests (push) Successful in 28s
CI / secret-scan (pull_request) Successful in 7s
CI / php-lint (pull_request) Successful in 16s
CI / auto-merge (pull_request) Failing after 7s

PinVerifier verifie un PIN soumis contre user.pin_hash (argon2id, default-deny, filtre
is_active = 1) et porte la politique de longueur (chiffres ASCII, bornes min/max STAFF_PIN_*, RG-T18).
Primitif reutilise par chaque operation sensible en P3 (annulation, prix/TVA, suppressions, inventaire,
gestion user/RBAC, effacement PII) ; le flux PIN + audit_log dans la meme transaction est specifie
dans docs/uml/security-sequence.md. Un decoy argon2id sur le chemin sans PIN egalise le timing
(anti-enumeration). Tests unit + integration (auto-skippee), dont la garde du filtre is_active contre
le vrai schema.
This commit is contained in:
Imugiii 2026-06-15 18:57:01 +00:00
parent f979a2339e
commit 75dd98668c
6 changed files with 311 additions and 2 deletions

View file

@ -93,8 +93,10 @@ ACCOUNT_LOCKOUT_MAX_SECONDS=900 # plafond du backoff (15 min)
IP_THROTTLE_WINDOW_SECONDS=900 # 15 min IP_THROTTLE_WINDOW_SECONDS=900 # 15 min
IP_THROTTLE_MAX_ATTEMPTS=20 # par IP sur la fenetre IP_THROTTLE_MAX_ATTEMPTS=20 # par IP sur la fenetre
# PIN equipier pour actions sensibles (annulation, override). Longueur minimale. # PIN equipier pour actions sensibles (annulation, override). Chiffres uniquement,
# bornes min ET max (RG-T18 : validation serveur + longueur bornee).
STAFF_PIN_MIN_LENGTH=4 STAFF_PIN_MIN_LENGTH=4
STAFF_PIN_MAX_LENGTH=12
# Expiration du token de reinitialisation de mot de passe (secondes). # Expiration du token de reinitialisation de mot de passe (secondes).
PASSWORD_RESET_TTL=3600 # 1h PASSWORD_RESET_TTL=3600 # 1h

View file

@ -147,8 +147,9 @@ services:
ACCOUNT_LOCKOUT_MAX_SECONDS: ${ACCOUNT_LOCKOUT_MAX_SECONDS} ACCOUNT_LOCKOUT_MAX_SECONDS: ${ACCOUNT_LOCKOUT_MAX_SECONDS}
IP_THROTTLE_WINDOW_SECONDS: ${IP_THROTTLE_WINDOW_SECONDS} IP_THROTTLE_WINDOW_SECONDS: ${IP_THROTTLE_WINDOW_SECONDS}
IP_THROTTLE_MAX_ATTEMPTS: ${IP_THROTTLE_MAX_ATTEMPTS} IP_THROTTLE_MAX_ATTEMPTS: ${IP_THROTTLE_MAX_ATTEMPTS}
# Longueur minimale du PIN equipier (actions sensibles, P3). # Bornes du PIN equipier (actions sensibles, P3) : longueur min ET max.
STAFF_PIN_MIN_LENGTH: ${STAFF_PIN_MIN_LENGTH} STAFF_PIN_MIN_LENGTH: ${STAFF_PIN_MIN_LENGTH}
STAFF_PIN_MAX_LENGTH: ${STAFF_PIN_MAX_LENGTH}
# Expiration du token de reinitialisation de mot de passe (mlt.md 12.3). # Expiration du token de reinitialisation de mot de passe (mlt.md 12.3).
PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL} PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL}
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB} UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB}

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use App\Core\Config;
use App\Core\DatabaseInterface;
/**
* PIN d'action sensible (mlt.md RG-T13). Sur un poste a session partagee, le PIN
* re-authentifie l'individu avant une action sensible (annulation, prix/TVA,
* suppression, correction d'inventaire, gestion utilisateur, RBAC, effacement PII)
* et fournit l'actor_user_id ecrit dans audit_log (RG-T14).
*
* Ce service est le PRIMITIF de verification, reutilise par chaque operation
* sensible en P3 : il verifie le PIN soumis contre user.pin_hash (argon2id, meme
* hacheur que le mot de passe). Le flux complet (PIN + audit dans la meme
* transaction que l'effet) est decrit dans docs/uml/security-sequence.md.
*
* NB P2 : aucune operation sensible n'existe encore (elles arrivent en P3), donc
* ce primitif n'est pas encore cable a une route ; il est ecrit et teste ici pour
* que P3 s'y branche. La definition d'un PIN (set/change) releve de la gestion
* utilisateur (P3, 10.1/10.2).
*/
final class PinVerifier
{
public function __construct(
private readonly DatabaseInterface $db,
private readonly Config $config,
private readonly PasswordHasher $hasher,
) {
}
/**
* Vrai si $pin correspond au pin_hash de l'utilisateur actif $userId. Un PIN
* vide, un compte inactif/absent ou un pin_hash non defini renvoient false,
* sans distinction (ne revele pas la raison de l'echec).
*/
public function verify(int $userId, string $pin): bool
{
if ($pin === '') {
return false;
}
$row = $this->db->fetch(
'SELECT pin_hash FROM user WHERE id = :id AND is_active = 1',
['id' => $userId],
);
$hash = is_string($row['pin_hash'] ?? null) ? (string) $row['pin_hash'] : '';
if ($hash === '') {
// Egalise le timing avec le chemin mauvais-PIN (verify argon2id) : sans
// ce leurre, un compte sans PIN (ou inactif/absent) repondrait plus vite,
// revelant par la latence quels comptes ont un PIN defini (anti-enumeration,
// meme posture que AuthService RG-2). Le leurre est mis en cache process.
$this->hasher->verifyDecoy($pin);
return false;
}
return $this->hasher->verify($pin, $hash);
}
/**
* Politique de PIN a verifier cote serveur avant de hacher un nouveau PIN
* (P3, definition du PIN) : chiffres ASCII uniquement, bornes min ET max
* (RG-T18). ctype_digit garantit le charset numerique, ce qui rend strlen
* fiable comme nombre de caracteres.
*/
public function meetsLengthPolicy(string $pin): bool
{
$min = $this->config->int('STAFF_PIN_MIN_LENGTH', 4);
$max = $this->config->int('STAFF_PIN_MAX_LENGTH', 12);
return $pin !== '' && ctype_digit($pin) && strlen($pin) >= $min && strlen($pin) <= $max;
}
}

View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration;
use PHPUnit\Framework\TestCase;
use Throwable;
use App\Auth\PasswordHasher;
use App\Auth\PinVerifier;
use App\Core\Config;
use App\Core\Database;
/**
* Verification du PIN (RG-T13) contre une vraie MariaDB : prouve la lecture reelle
* de user.pin_hash et le filtre is_active = 1.
*
* Auto-skip si WAKDO_DB_TESTS != 1 ou base injoignable. Cree un user jetable
* (email .invalid) avec un PIN connu, supprime en tearDown.
*/
final class PinVerifierDbTest extends TestCase
{
private const PIN = '4729';
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_*).');
}
$this->config = new Config();
$this->db = new Database($this->config);
try {
$this->db->fetch('SELECT 1');
} catch (Throwable $exception) {
self::markTestSkipped('Base injoignable: ' . $exception->getMessage());
}
$roleRow = $this->db->fetch('SELECT id FROM role ORDER BY id LIMIT 1');
$roleId = (int) ($roleRow['id'] ?? 0);
self::assertGreaterThan(0, $roleId, 'role seede attendu');
$hasher = new PasswordHasher($this->config);
$this->db->execute(
'INSERT INTO user (email, password_hash, pin_hash, first_name, last_name, role_id, is_active) '
. 'VALUES (:email, :pwd, :pin, :fn, :ln, :role, 1)',
[
'email' => 'it-pin-' . bin2hex(random_bytes(6)) . '@wakdo.invalid',
'pwd' => $hasher->hash('IntegrationPass1'),
'pin' => $hasher->hash(self::PIN),
'fn' => 'Integration',
'ln' => 'Pin',
'role' => $roleId,
],
);
$this->userId = (int) ($this->db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0);
}
protected function tearDown(): void
{
if ($this->userId === 0) {
return;
}
$this->db->execute('DELETE FROM user WHERE id = :id', ['id' => $this->userId]);
$this->userId = 0;
}
private function verifier(): PinVerifier
{
return new PinVerifier($this->db, $this->config, new PasswordHasher($this->config));
}
public function testVerifyAgainstRealPinHash(): void
{
$verifier = $this->verifier();
self::assertTrue($verifier->verify($this->userId, self::PIN));
self::assertFalse($verifier->verify($this->userId, '0000'));
}
public function testVerifyFalseWhenPinHashNull(): void
{
$this->db->execute('UPDATE user SET pin_hash = NULL WHERE id = :id', ['id' => $this->userId]);
self::assertFalse($this->verifier()->verify($this->userId, self::PIN));
}
public function testVerifyFalseWhenUserInactive(): void
{
// Compte desactive mais pin_hash encore valide : le filtre is_active = 1
// doit refuser (un equipier desactive ne re-autorise plus d'action sensible).
$this->db->execute('UPDATE user SET is_active = 0 WHERE id = :id', ['id' => $this->userId]);
self::assertFalse($this->verifier()->verify($this->userId, self::PIN));
}
}

View file

@ -83,6 +83,13 @@ final class FakeDatabase implements DatabaseInterface
*/ */
public ?array $roleRow = null; public ?array $roleRow = null;
/**
* Ligne user renvoyee pour la verification du PIN (RG-T13) ; null = absent/inactif.
*
* @var array<string, mixed>|null
*/
public ?array $pinUserRow = null;
/** Si non nul, execute() leve cette exception (simulation panne DB -> fail-closed). */ /** Si non nul, execute() leve cette exception (simulation panne DB -> fail-closed). */
public ?RuntimeException $failOnExecute = null; public ?RuntimeException $failOnExecute = null;
@ -120,6 +127,12 @@ final class FakeDatabase implements DatabaseInterface
return $this->roleRow; return $this->roleRow;
} }
// Exige le predicat is_active = 1 : si la production le retirait, le double
// renverrait null et le test verify-true virerait au rouge (garde RG-T13).
if (str_contains($sql, 'SELECT pin_hash FROM user WHERE id') && str_contains($sql, 'is_active = 1')) {
return $this->pinUserRow;
}
if (str_contains($sql, 'SELECT lockout_until FROM login_throttle')) { if (str_contains($sql, 'SELECT lockout_until FROM login_throttle')) {
return ['lockout_until' => $this->ipLockoutUntil]; return ['lockout_until' => $this->ipLockoutUntil];
} }

View file

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Auth;
use PHPUnit\Framework\TestCase;
use App\Auth\PasswordHasher;
use App\Auth\PinVerifier;
use App\Core\Config;
use App\Tests\Support\FakeDatabase;
/**
* Verification du PIN d'action sensible (RG-T13) avec un FakeDatabase et un vrai
* PasswordHasher a cout reduit.
*/
final class PinVerifierTest extends TestCase
{
/** @var list<string> */
private array $touchedKeys = [];
private FakeDatabase $db;
private PasswordHasher $hasher;
protected function setUp(): void
{
$this->setEnv('STAFF_PIN_MIN_LENGTH', '4');
$this->setEnv('STAFF_PIN_MAX_LENGTH', '12');
$this->setEnv('ARGON2_MEMORY_COST', '1024');
$this->setEnv('ARGON2_TIME_COST', '1');
$this->setEnv('ARGON2_THREADS', '1');
$this->db = new FakeDatabase();
$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 verifier(): PinVerifier
{
return new PinVerifier($this->db, new Config(), $this->hasher);
}
public function testVerifyTrueWhenPinMatches(): void
{
$this->db->pinUserRow = ['pin_hash' => $this->hasher->hash('4729')];
self::assertTrue($this->verifier()->verify(7, '4729'));
// Garde RG-T13 : la lecture filtre bien is_active = 1 (retirer le predicat
// ferait echouer ce cas via le routage durci du FakeDatabase).
self::assertStringContainsString('is_active = 1', $this->db->reads[0]['sql']);
}
public function testVerifyFalseWhenPinWrong(): void
{
$this->db->pinUserRow = ['pin_hash' => $this->hasher->hash('4729')];
self::assertFalse($this->verifier()->verify(7, '0000'));
}
public function testVerifyFalseWhenPinHashNull(): void
{
// PIN non defini sur le compte.
$this->db->pinUserRow = ['pin_hash' => null];
self::assertFalse($this->verifier()->verify(7, '4729'));
}
public function testVerifyFalseWhenUserAbsentOrInactive(): void
{
// La requete filtre is_active = 1 : un compte inactif/absent ne renvoie rien.
$this->db->pinUserRow = null;
self::assertFalse($this->verifier()->verify(7, '4729'));
}
public function testVerifyFalseWhenPinEmpty(): void
{
$this->db->pinUserRow = ['pin_hash' => $this->hasher->hash('4729')];
self::assertFalse($this->verifier()->verify(7, ''));
}
public function testMeetsLengthPolicy(): void
{
$verifier = $this->verifier();
// Sous le minimum / au minimum / dans les bornes.
self::assertFalse($verifier->meetsLengthPolicy('123'));
self::assertTrue($verifier->meetsLengthPolicy('1234'));
self::assertTrue($verifier->meetsLengthPolicy('123456'));
// Au max (12) accepte, au-dela refuse (RG-T18 borne haute).
self::assertTrue($verifier->meetsLengthPolicy('123456789012'));
self::assertFalse($verifier->meetsLengthPolicy('1234567890123'));
// Charset : chiffres uniquement ; vide refuse.
self::assertFalse($verifier->meetsLengthPolicy('abcd'));
self::assertFalse($verifier->meetsLengthPolicy('12ab'));
self::assertFalse($verifier->meetsLengthPolicy(''));
}
}