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
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:
parent
f979a2339e
commit
75dd98668c
6 changed files with 311 additions and 2 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
79
src/app/Auth/PinVerifier.php
Normal file
79
src/app/Auth/PinVerifier.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
102
tests/Integration/PinVerifierDbTest.php
Normal file
102
tests/Integration/PinVerifierDbTest.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
112
tests/Unit/Auth/PinVerifierTest.php
Normal file
112
tests/Unit/Auth/PinVerifierTest.php
Normal 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(''));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue