+ Mot de passe oublie
+
+
+ = htmlspecialchars($noticeMessage, ENT_QUOTES, 'UTF-8') ?>
+
+
+
+
+ Retour a la connexion
+
diff --git a/src/app/Views/auth/login.php b/src/app/Views/auth/login.php
new file mode 100644
index 0000000..187b1d3
--- /dev/null
+++ b/src/app/Views/auth/login.php
@@ -0,0 +1,47 @@
+
+
+ Wakdo Admin
+ Back-office de gestion
+
+
+ = htmlspecialchars($noticeMessage, ENT_QUOTES, 'UTF-8') ?>
+
+
+
+ = htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?>
+
+
+
+
+ Mot de passe oublie ?
+
diff --git a/src/app/Views/auth/reset.php b/src/app/Views/auth/reset.php
new file mode 100644
index 0000000..e8b8b41
--- /dev/null
+++ b/src/app/Views/auth/reset.php
@@ -0,0 +1,43 @@
+
+
+ Nouveau mot de passe
+
+
+ = htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?>
+
+
+
+
+ Retour a la connexion
+
diff --git a/src/public/admin/index.php b/src/public/admin/index.php
index 50279f4..dd44d61 100644
--- a/src/public/admin/index.php
+++ b/src/public/admin/index.php
@@ -10,8 +10,11 @@ declare(strict_types=1);
* "/", "/api/health", etc.
*/
+use App\Auth\SessionManager;
+use App\Controllers\AuthController;
use App\Controllers\HealthController;
use App\Controllers\HomeController;
+use App\Controllers\PasswordResetController;
use App\Core\Autoloader;
use App\Core\Config;
use App\Core\Database;
@@ -36,10 +39,24 @@ try {
// donc la home back-office reste servie meme base indisponible.
$database = new Database($config);
+ // Demarre la session du vhost admin avant le dispatch (effet de bord global,
+ // hors du Core stateless). Les controleurs y rattachent leur SessionManager.
+ (new SessionManager($config))->start();
+
$router = new Router($config, $database);
$router->add('GET', '/', [HomeController::class, 'index']);
$router->add('GET', '/api/health', [HealthController::class, 'index']);
+ // Authentification back-office (mlt.md section 12). Le docroot du vhost admin
+ // etant src/public/admin, le Router voit "/login" (pas de prefixe "/admin").
+ $router->add('GET', '/login', [AuthController::class, 'showLogin']);
+ $router->add('POST', '/login', [AuthController::class, 'login']);
+ $router->add('POST', '/logout', [AuthController::class, 'logout']);
+ $router->add('GET', '/forgot_password', [PasswordResetController::class, 'showRequest']);
+ $router->add('POST', '/forgot_password', [PasswordResetController::class, 'submitRequest']);
+ $router->add('GET', '/reset_password', [PasswordResetController::class, 'showConfirm']);
+ $router->add('POST', '/reset_password', [PasswordResetController::class, 'submitConfirm']);
+
$response = $router->dispatch(Request::fromGlobals());
$response->send();
} catch (Throwable $exception) {
diff --git a/tests/Integration/AuthServiceDbTest.php b/tests/Integration/AuthServiceDbTest.php
new file mode 100644
index 0000000..e726ea3
--- /dev/null
+++ b/tests/Integration/AuthServiceDbTest.php
@@ -0,0 +1,189 @@
+config = new Config();
+ $this->db = new Database($this->config);
+
+ try {
+ $this->db->fetch('SELECT 1');
+ } catch (Throwable $exception) {
+ self::markTestSkipped('Base de donnees injoignable: ' . $exception->getMessage());
+ }
+
+ $this->cleanupThrottle();
+ $this->userId = $this->createDisposableUser();
+ }
+
+ protected function tearDown(): void
+ {
+ if ($this->userId === 0) {
+ return;
+ }
+
+ // Ordre compatible FK : audit (actor SET NULL mais on retire nos lignes),
+ // throttle (par IP), puis l'utilisateur jetable.
+ $this->db->execute('DELETE FROM audit_log WHERE actor_user_id = :id', ['id' => $this->userId]);
+ $this->cleanupThrottle();
+ $this->db->execute('DELETE FROM user WHERE id = :id', ['id' => $this->userId]);
+ $this->userId = 0;
+ }
+
+ private function service(): AuthService
+ {
+ return new AuthService(
+ $this->db,
+ $this->config,
+ new SessionManager($this->config, true),
+ new PasswordHasher($this->config),
+ );
+ }
+
+ public function testSuccessfulLoginPersistsResetCountersAndAuditSuccess(): void
+ {
+ $result = $this->service()->authenticate($this->email(), self::PASSWORD, self::TEST_IP);
+
+ self::assertTrue($result->success);
+
+ $user = $this->db->fetch(
+ 'SELECT failed_login_attempts, lockout_until, last_login_at FROM user WHERE id = :id',
+ ['id' => $this->userId],
+ );
+ self::assertNotNull($user);
+ self::assertSame(0, (int) ($user['failed_login_attempts'] ?? -1));
+ self::assertNull($user['lockout_until']);
+ self::assertNotNull($user['last_login_at']);
+
+ self::assertSame('auth.login_success', $this->lastAuditAction());
+ }
+
+ public function testFailedLoginIncrementsAccountAndCreatesThrottleAndAuditFailure(): void
+ {
+ $result = $this->service()->authenticate($this->email(), 'WRONG-PASSWORD', self::TEST_IP);
+
+ self::assertFalse($result->success);
+
+ $user = $this->db->fetch(
+ 'SELECT failed_login_attempts FROM user WHERE id = :id',
+ ['id' => $this->userId],
+ );
+ self::assertNotNull($user);
+ self::assertSame(1, (int) ($user['failed_login_attempts'] ?? -1));
+
+ $throttle = $this->db->fetch(
+ 'SELECT failed_attempts FROM login_throttle WHERE ip_address = :ip',
+ ['ip' => self::TEST_IP],
+ );
+ self::assertNotNull($throttle);
+ self::assertSame(1, (int) ($throttle['failed_attempts'] ?? -1));
+
+ self::assertSame('auth.login_failed', $this->lastAuditAction());
+ }
+
+ public function testThrottleGateRejectsWhenAccountLocked(): void
+ {
+ // Pose un verrou compte dans le futur, puis tente avec le BON mot de passe :
+ // la porte PRE-3 doit refuser avant toute verification.
+ $future = date('Y-m-d H:i:s', time() + 600);
+ $this->db->execute(
+ 'UPDATE user SET lockout_until = :lock WHERE id = :id',
+ ['lock' => $future, 'id' => $this->userId],
+ );
+
+ $result = $this->service()->authenticate($this->email(), self::PASSWORD, self::TEST_IP);
+
+ self::assertFalse($result->success);
+ // last_login_at reste nul : aucune authentification n'a abouti.
+ $user = $this->db->fetch('SELECT last_login_at FROM user WHERE id = :id', ['id' => $this->userId]);
+ self::assertNotNull($user);
+ self::assertNull($user['last_login_at']);
+ }
+
+ private function email(): string
+ {
+ return 'it-auth-' . $this->userId . '@wakdo.invalid';
+ }
+
+ private function createDisposableUser(): int
+ {
+ $roleRow = $this->db->fetch('SELECT id FROM role ORDER BY id LIMIT 1');
+ $roleId = (int) ($roleRow['id'] ?? 0);
+ self::assertGreaterThan(0, $roleId, 'aucun role seede: migration/seed requis');
+
+ $hash = (new PasswordHasher($this->config))->hash(self::PASSWORD);
+ // Email provisoire pour obtenir l'id, puis on le rend unique par id.
+ $this->db->execute(
+ 'INSERT INTO user (email, password_hash, first_name, last_name, role_id, is_active) '
+ . 'VALUES (:email, :hash, :fn, :ln, :role, 1)',
+ [
+ 'email' => 'it-auth-pending-' . bin2hex(random_bytes(6)) . '@wakdo.invalid',
+ 'hash' => $hash,
+ 'fn' => 'Integration',
+ 'ln' => 'Test',
+ 'role' => $roleId,
+ ],
+ );
+
+ $row = $this->db->fetch('SELECT LAST_INSERT_ID() AS id');
+ $id = (int) ($row['id'] ?? 0);
+
+ $this->db->execute(
+ 'UPDATE user SET email = :email WHERE id = :id',
+ ['email' => 'it-auth-' . $id . '@wakdo.invalid', 'id' => $id],
+ );
+
+ return $id;
+ }
+
+ private function cleanupThrottle(): void
+ {
+ $this->db->execute('DELETE FROM login_throttle WHERE ip_address = :ip', ['ip' => self::TEST_IP]);
+ }
+
+ private function lastAuditAction(): ?string
+ {
+ $row = $this->db->fetch(
+ 'SELECT action_code FROM audit_log WHERE actor_user_id = :id ORDER BY id DESC LIMIT 1',
+ ['id' => $this->userId],
+ );
+
+ return $row === null ? null : (string) ($row['action_code'] ?? '');
+ }
+}
diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php
new file mode 100644
index 0000000..3009c38
--- /dev/null
+++ b/tests/Support/FakeDatabase.php
@@ -0,0 +1,155 @@
+|null
+ */
+ public ?array $userRow = null;
+
+ /** lockout_until renvoye pour la porte de throttling IP ; null = pas de verrou. */
+ public ?string $ipLockoutUntil = null;
+
+ /**
+ * Compteur login_throttle relu apres l'upsert atomique (sert au calcul du
+ * backoff IP en PHP) ; null => 1 par defaut cote service.
+ *
+ * @var array