feat(admin): definition self-service du PIN d'action sensible (P3)
Some checks failed
CI / secret-scan (push) Successful in 10s
CI / static-tests (push) Successful in 30s
CI / php-lint (push) Successful in 20s
CI / secret-scan (pull_request) Successful in 9s
CI / php-lint (pull_request) Successful in 19s
CI / static-tests (pull_request) Successful in 30s
CI / auto-merge (push) Has been skipped
CI / auto-merge (pull_request) Failing after 5s

ProfileController -> GET/POST /admin/profile/pin : l'utilisateur connecte definit/change SON
propre PIN (cible = guard.userId issu de la session, jamais un champ de formulaire -> pas d'IDOR).
CSRF (RG-T01) + validation serveur (PinVerifier::meetsLengthPolicy : numerique + bornes min/max,
RG-T18 ; confirmation). PIN stocke en hash argon2id. Ecriture gardee sur 1 ligne affectee (pas de faux
succes silencieux). UserRepository : ecritures user hors auth (setPinHash retourne le compte de lignes,
pinIsSet). Prerequis du modele 'identifiant equipier + PIN' des actions sensibles (CRUD produits).
152 tests (unit + integration), PHPStan L6. Revue adversariale passee, 3 findings corriges.
This commit is contained in:
Imugiii 2026-06-15 20:00:48 +00:00
parent 8290ceabc4
commit f60bc484f7
7 changed files with 515 additions and 1 deletions

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use App\Core\DatabaseInterface;
/**
* Ecritures sur l'entite user necessaires hors du flux d'authentification
* (definition du PIN en self-service ici ; la gestion complete des comptes
* arrive avec le CRUD Users). Lecture seule d'affichage = UserDirectory.
*/
final class UserRepository
{
public function __construct(private readonly DatabaseInterface $db)
{
}
/**
* Retourne le nombre de lignes affectees (1 attendu). Le hash argon2id
* change a chaque appel (sel aleatoire), donc une cible existante donne
* toujours 1 ; 0 revele une cible inexistante (defense en profondeur).
*/
public function setPinHash(int $userId, string $hash): int
{
return $this->db->execute('UPDATE user SET pin_hash = :hash WHERE id = :id', ['hash' => $hash, 'id' => $userId]);
}
public function pinIsSet(int $userId): bool
{
return $this->db->fetch(
'SELECT id FROM user WHERE id = :id AND pin_hash IS NOT NULL',
['id' => $userId],
) !== null;
}
}

View file

@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Auth\Csrf;
use App\Auth\GuardResult;
use App\Auth\PasswordHasher;
use App\Auth\PinVerifier;
use App\Auth\UserRepository;
use App\Core\Response;
/**
* Profil self-service : definition / changement du PIN d'action sensible de
* l'utilisateur connecte (prerequis au modele "identifiant equipier + PIN" des
* actions sensibles, RG-T13). Accessible a tout utilisateur authentifie ; aucune
* permission specifique (on n'agit que sur son propre compte = session userId).
*
* Non `final` : les tests sous-classent pour injecter des doubles.
*/
class ProfileController extends AdminController
{
/**
* @param array<string, string> $params
*/
public function showPin(array $params = []): Response
{
$guard = $this->guard();
if ($guard instanceof Response) {
return $guard;
}
$userId = $guard->userId;
if ($userId === null) {
return Response::make('', 302, ['Location' => '/login']);
}
return $this->adminView('admin/profile/pin', [
'title' => 'Mon PIN - Wakdo Admin',
'activeNav' => '',
'pinIsSet' => $this->userRepository()->pinIsSet($userId),
'error' => null,
], $guard);
}
/**
* @param array<string, string> $params
*/
public function updatePin(array $params = []): Response
{
$guard = $this->guard();
if ($guard instanceof Response) {
return $guard;
}
$userId = $guard->userId;
if ($userId === null) {
return Response::make('', 302, ['Location' => '/login']);
}
$form = $this->request->formBody();
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']);
}
$pin = $form['pin'] ?? '';
$confirm = $form['pin_confirm'] ?? '';
$error = null;
if (!$this->pinVerifier()->meetsLengthPolicy($pin)) {
$error = 'Le PIN doit etre uniquement numerique et respecter la longueur requise.';
} elseif ($pin !== $confirm) {
$error = 'Les PIN ne correspondent pas.';
}
if ($error !== null) {
return $this->renderPinForm($guard, $userId, $error, 422);
}
// Gate sur 1 ligne affectee : une cible inexistante (0 ligne) ne doit pas
// produire un faux "PIN enregistre" (defense en profondeur).
if ($this->userRepository()->setPinHash($userId, $this->passwordHasher()->hash($pin)) !== 1) {
return $this->renderPinForm($guard, $userId, 'Echec de l enregistrement du PIN.', 500);
}
$this->setFlash('PIN enregistre.');
return Response::make('', 302, ['Location' => '/admin/profile/pin']);
}
private function renderPinForm(GuardResult $guard, int $userId, ?string $error, int $status): Response
{
return $this->adminView('admin/profile/pin', [
'title' => 'Mon PIN - Wakdo Admin',
'activeNav' => '',
'pinIsSet' => $this->userRepository()->pinIsSet($userId),
'error' => $error,
], $guard, $status);
}
protected function userRepository(): UserRepository
{
return new UserRepository($this->database);
}
protected function pinVerifier(): PinVerifier
{
return new PinVerifier($this->database, $this->config, $this->passwordHasher());
}
protected function passwordHasher(): PasswordHasher
{
return new PasswordHasher($this->config);
}
}

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/**
* Formulaire de definition / changement du PIN de l'utilisateur connecte.
* Injecte dans admin/layout.php. Le PIN sert a re-autoriser les actions sensibles.
*
* @var string $csrfToken
* @var bool $pinIsSet
* @var string|null $error
*/
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
$alreadySet = isset($pinIsSet) && $pinIsSet === true;
$errorMessage = isset($error) && is_string($error) ? $error : null;
?>
<div class="page-header">
<div>
<h1 class="page-title">Mon PIN</h1>
<p class="page-subtitle">PIN de confirmation des actions sensibles (annulation, prix, suppressions...)</p>
</div>
</div>
<section>
<p><small>Statut : <?= $alreadySet ? 'un PIN est defini.' : 'aucun PIN defini pour l instant.' ?></small></p>
<?php if ($errorMessage !== null): ?>
<p role="alert"><?= htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<form method="post" action="/admin/profile/pin" class="form-card">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<div class="form-group">
<label class="form-label" for="pin">Nouveau PIN</label>
<input class="form-input" type="password" id="pin" name="pin" inputmode="numeric" autocomplete="off" required>
</div>
<div class="form-group">
<label class="form-label" for="pin_confirm">Confirmer le PIN</label>
<input class="form-input" type="password" id="pin_confirm" name="pin_confirm" inputmode="numeric" autocomplete="off" required>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Enregistrer</button>
</div>
</form>
</section>

View file

@ -18,6 +18,7 @@ use App\Controllers\HealthController;
use App\Controllers\HomeController;
use App\Controllers\MeController;
use App\Controllers\PasswordResetController;
use App\Controllers\ProfileController;
use App\Core\Autoloader;
use App\Core\Config;
use App\Core\Database;
@ -74,6 +75,10 @@ try {
$router->add('POST', '/admin/categories/{id}', [CategoryController::class, 'update']);
$router->add('POST', '/admin/categories/{id}/toggle', [CategoryController::class, 'toggle']);
// Profil self-service : definition du PIN d'action sensible (RG-T13).
$router->add('GET', '/admin/profile/pin', [ProfileController::class, 'showPin']);
$router->add('POST', '/admin/profile/pin', [ProfileController::class, 'updatePin']);
$response = $router->dispatch(Request::fromGlobals());
$response->send();
} catch (Throwable $exception) {

View file

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration;
use PHPUnit\Framework\TestCase;
use Throwable;
use App\Auth\PasswordHasher;
use App\Auth\UserRepository;
use App\Core\Config;
use App\Core\Database;
/**
* Ecriture du PIN (UserRepository) contre une vraie MariaDB. Auto-skip si
* WAKDO_DB_TESTS != 1. User jetable (.invalid), supprime en tearDown.
*/
final class UserRepositoryDbTest extends TestCase
{
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());
}
$roleId = (int) ($this->db->fetch('SELECT id FROM role ORDER BY id LIMIT 1')['id'] ?? 0);
$hasher = new PasswordHasher($this->config);
$this->db->execute(
'INSERT INTO user (email, password_hash, first_name, last_name, role_id, is_active) '
. 'VALUES (:email, :pwd, :fn, :ln, :role, 1)',
[
'email' => 'it-userrepo-' . bin2hex(random_bytes(6)) . '@wakdo.invalid',
'pwd' => $hasher->hash('IntegrationPass1'),
'fn' => 'Integration',
'ln' => 'UserRepo',
'role' => $roleId,
],
);
$this->userId = (int) ($this->db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0);
}
protected function tearDown(): void
{
if ($this->userId !== 0) {
$this->db->execute('DELETE FROM user WHERE id = :id', ['id' => $this->userId]);
$this->userId = 0;
}
}
public function testSetPinHashAndPinIsSet(): void
{
$repo = new UserRepository($this->db);
$hasher = new PasswordHasher($this->config);
// Aucun PIN au depart.
self::assertFalse($repo->pinIsSet($this->userId));
$repo->setPinHash($this->userId, $hasher->hash('4729'));
self::assertTrue($repo->pinIsSet($this->userId));
// Le hash stocke est verifiable et n'est pas le PIN en clair.
$stored = (string) ($this->db->fetch('SELECT pin_hash FROM user WHERE id = :id', ['id' => $this->userId])['pin_hash'] ?? '');
self::assertNotSame('4729', $stored);
self::assertTrue($hasher->verify('4729', $stored));
}
}

View file

@ -117,9 +117,15 @@ final class FakeDatabase implements DatabaseInterface
/** Resultat de CategoryRepository::slugExists(). */
public bool $categorySlugTaken = false;
/** Resultat de UserRepository::pinIsSet() (true = un PIN est defini). */
public bool $userPinSet = false;
/** Si non nul, execute() leve cette exception (simulation panne DB / violation de contrainte). */
public ?Throwable $failOnExecute = null;
/** Nombre de lignes affectees renvoye par execute() (1 par defaut). */
public int $executeRowCount = 1;
/** @var list<array{sql: string, params: array<string|int, mixed>}> */
public array $writes = [];
@ -166,6 +172,10 @@ final class FakeDatabase implements DatabaseInterface
return $this->pinUserRow;
}
if (str_contains($sql, 'FROM user WHERE id = :id AND pin_hash IS NOT NULL')) {
return $this->userPinSet ? ['id' => 1] : null;
}
if (str_contains($sql, 'FROM category WHERE id = :id')) {
return $this->categoryRow;
}
@ -216,7 +226,7 @@ final class FakeDatabase implements DatabaseInterface
$this->writes[] = ['sql' => $sql, 'params' => $params];
return 1;
return $this->executeRowCount;
}
public function transaction(callable $fn): void

View file

@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Admin;
use PHPUnit\Framework\TestCase;
use App\Auth\Authorizer;
use App\Auth\Csrf;
use App\Auth\SessionGuard;
use App\Auth\SessionManager;
use App\Auth\UserDirectory;
use App\Auth\UserRepository;
use App\Controllers\ProfileController;
use App\Core\Config;
use App\Core\Database;
use App\Core\Request;
use App\Tests\Support\FakeDatabase;
final class TestProfileController extends ProfileController
{
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 sessionGuard(): SessionGuard
{
return new SessionGuard($this->testSession, $this->fakeDb, $this->config);
}
protected function authorizer(): Authorizer
{
return new Authorizer($this->fakeDb);
}
protected function userDirectory(): UserDirectory
{
return new UserDirectory($this->fakeDb);
}
protected function userRepository(): UserRepository
{
return new UserRepository($this->fakeDb);
}
}
final class ProfileControllerTest extends TestCase
{
/** @var list<string> */
private array $touchedKeys = [];
private SessionManager $session;
private string $csrf = '';
protected function setUp(): void
{
$this->setEnv('SESSION_LIFETIME_IDLE', '14400');
$this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000');
$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->session = new SessionManager(new Config(), true);
$now = time();
$this->session->set('user_id', 1);
$this->session->set('role_id', 1);
$this->session->set('logged_in_at', $now - 100);
$this->session->set('last_activity', $now - 50);
$this->csrf = Csrf::token($this->session);
}
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 permittedDb(): FakeDatabase
{
$db = new FakeDatabase();
$db->guardUserRow = ['is_active' => 1];
$db->userDisplayRow = ['first_name' => 'Corentin', 'last_name' => 'J', 'role_label' => 'Administrateur'];
$db->canResult = true;
$db->permissionCodes = ['category.manage'];
return $db;
}
/**
* @param array<string, string> $form
*/
private function post(array $form): Request
{
return new Request(
'POST',
'/admin/profile/pin',
[],
['content-type' => 'application/x-www-form-urlencoded'],
http_build_query($form),
'203.0.113.5',
);
}
private function controller(Request $request, FakeDatabase $db): TestProfileController
{
return new TestProfileController($request, new Config(), new Database(new Config()), $this->session, $db);
}
public function testRedirectsToLoginWithoutSession(): void
{
$request = new Request('GET', '/admin/profile/pin', [], [], '', '203.0.113.5');
$response = $this->controller($request, new FakeDatabase())->showPin();
self::assertSame(302, $response->status());
self::assertSame('/login', $response->header('Location'));
}
public function testShowPinReflectsStatus(): void
{
$request = new Request('GET', '/admin/profile/pin', [], [], '', '203.0.113.5');
$db = $this->permittedDb();
$db->userPinSet = false;
$response = $this->controller($request, $db)->showPin();
self::assertSame(200, $response->status());
self::assertStringContainsString('name="pin"', $response->body());
self::assertStringContainsString('aucun PIN defini', $response->body());
$db2 = $this->permittedDb();
$db2->userPinSet = true;
self::assertStringContainsString('un PIN est defini', $this->controller($request, $db2)->showPin()->body());
}
public function testUpdatePinValidStoresHashAndRedirects(): void
{
$db = $this->permittedDb();
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin' => '4729', 'pin_confirm' => '4729']), $db)->updatePin();
self::assertSame(302, $response->status());
self::assertSame('/admin/profile/pin', $response->header('Location'));
self::assertSame('PIN enregistre.', $this->session->get('_flash'));
// Invariant central : la cible est l'utilisateur de la SESSION (1, pose en
// setUp), jamais un champ de formulaire ; et c'est un hash, pas le PIN clair.
$write = null;
foreach ($db->writes as $w) {
if (str_contains($w['sql'], 'UPDATE user SET pin_hash')) {
$write = $w;
break;
}
}
self::assertNotNull($write);
self::assertSame(1, $write['params']['id'] ?? null);
self::assertNotSame('4729', $write['params']['hash'] ?? null);
}
public function testUpdatePinFailsWhenNoRowAffected(): void
{
// Cible inexistante (0 ligne affectee) : pas de faux succes, pas de flash.
$db = $this->permittedDb();
$db->executeRowCount = 0;
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin' => '4729', 'pin_confirm' => '4729']), $db)->updatePin();
self::assertSame(500, $response->status());
self::assertNull($this->session->get('_flash'));
}
public function testUpdatePinMismatchRerenders422(): void
{
$db = $this->permittedDb();
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin' => '4729', 'pin_confirm' => '0000']), $db)->updatePin();
self::assertSame(422, $response->status());
self::assertStringContainsString('ne correspondent pas', $response->body());
self::assertFalse($db->wrote('UPDATE user SET pin_hash'));
}
public function testUpdatePinTooShortRerenders422(): void
{
$db = $this->permittedDb();
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin' => '12', 'pin_confirm' => '12']), $db)->updatePin();
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('UPDATE user SET pin_hash'));
}
public function testUpdatePinRejectsInvalidCsrf(): void
{
$db = $this->permittedDb();
$response = $this->controller($this->post(['_csrf' => 'wrong', 'pin' => '4729', 'pin_confirm' => '4729']), $db)->updatePin();
self::assertSame(403, $response->status());
self::assertFalse($db->wrote('UPDATE user SET pin_hash'));
}
}