From f63ac9873c3386569ca68fe1b3f011c6fd3fbd1e Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Mon, 15 Jun 2026 22:04:14 +0200 Subject: [PATCH] feat: PIN self-service P3 (/admin/profile/pin) (#16) --- src/app/Auth/UserRepository.php | 37 ++++ src/app/Controllers/ProfileController.php | 116 +++++++++++ src/app/Views/admin/profile/pin.php | 49 +++++ src/public/admin/index.php | 5 + tests/Integration/UserRepositoryDbTest.php | 80 ++++++++ tests/Support/FakeDatabase.php | 12 +- tests/Unit/Admin/ProfileControllerTest.php | 217 +++++++++++++++++++++ 7 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 src/app/Auth/UserRepository.php create mode 100644 src/app/Controllers/ProfileController.php create mode 100644 src/app/Views/admin/profile/pin.php create mode 100644 tests/Integration/UserRepositoryDbTest.php create mode 100644 tests/Unit/Admin/ProfileControllerTest.php diff --git a/src/app/Auth/UserRepository.php b/src/app/Auth/UserRepository.php new file mode 100644 index 0000000..4648235 --- /dev/null +++ b/src/app/Auth/UserRepository.php @@ -0,0 +1,37 @@ +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; + } +} diff --git a/src/app/Controllers/ProfileController.php b/src/app/Controllers/ProfileController.php new file mode 100644 index 0000000..2b88ea8 --- /dev/null +++ b/src/app/Controllers/ProfileController.php @@ -0,0 +1,116 @@ + $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 $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); + } +} diff --git a/src/app/Views/admin/profile/pin.php b/src/app/Views/admin/profile/pin.php new file mode 100644 index 0000000..c854c18 --- /dev/null +++ b/src/app/Views/admin/profile/pin.php @@ -0,0 +1,49 @@ + + + +
+

Statut :

+ + +

+ + +
+ + +
+ + +
+ +
+ + +
+ +
+ +
+
+
diff --git a/src/public/admin/index.php b/src/public/admin/index.php index 9140055..7352aad 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -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) { diff --git a/tests/Integration/UserRepositoryDbTest.php b/tests/Integration/UserRepositoryDbTest.php new file mode 100644 index 0000000..2b9757a --- /dev/null +++ b/tests/Integration/UserRepositoryDbTest.php @@ -0,0 +1,80 @@ +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)); + } +} diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index f2e012c..e5bb09a 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -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}> */ 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 diff --git a/tests/Unit/Admin/ProfileControllerTest.php b/tests/Unit/Admin/ProfileControllerTest.php new file mode 100644 index 0000000..ca4a77f --- /dev/null +++ b/tests/Unit/Admin/ProfileControllerTest.php @@ -0,0 +1,217 @@ +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 */ + 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 $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')); + } +}