diff --git a/app/public/admin/login.php b/app/public/admin/login.php index c71b7a8..6a29cb7 100644 --- a/app/public/admin/login.php +++ b/app/public/admin/login.php @@ -21,6 +21,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $error = 'Mot de passe incorrect.'; } +// Ensure a CSRF token exists for the reset-password form on this page. +require_once APP_ROOT . '/src/App.php'; +App::boot(); + $pageTitle = 'Connexion'; $isAdmin = true; $isLogin = true; $bodyClass = 'admin-body'; require_once APP_ROOT . '/templates/head.php'; diff --git a/app/public/admin/password-reset.php b/app/public/admin/password-reset.php new file mode 100644 index 0000000..8c0be71 --- /dev/null +++ b/app/public/admin/password-reset.php @@ -0,0 +1,53 @@ +\n"; diff --git a/app/public/admin/request-reset.php b/app/public/admin/request-reset.php new file mode 100644 index 0000000..b0aa51e --- /dev/null +++ b/app/public/admin/request-reset.php @@ -0,0 +1,50 @@ +checkKey($key)) { + App::flash('error', 'Trop de demandes. Réessayez dans quelques minutes.'); + header('Location: /admin/login.php'); + exit; +} + +$sent = AdminAuth::requestPasswordReset(); + +// Neutral message regardless of outcome (no information leak). +App::flash('success', 'Si une adresse de notification est configurée, un lien de réinitialisation a été envoyé.'); +header('Location: /admin/login.php'); +exit; diff --git a/app/src/AdminAuth.php b/app/src/AdminAuth.php index 7443995..398f8fc 100644 --- a/app/src/AdminAuth.php +++ b/app/src/AdminAuth.php @@ -182,27 +182,80 @@ class AdminAuth } /** - * Redeem a password-reset token and return the new password hash. + * Initiate a password reset: issue a one-time token and email the link + * to the admin notification address. * - * On success, the token is consumed (one-time) and a fresh bcrypt hash - * is generated for the given new password — but NOT yet persisted; the - * caller decides when to store it (so the reset flow can validate before - * committing). - * - * @return string|null bcrypt hash, or null when the token is invalid. + * Uses the shared OneTimeToken model + SmtpRelay. Returns true when the + * email was accepted for delivery (false if no address is configured or + * sending fails). */ - public static function redeemPasswordResetToken(string $token, string $newPassword): ?string + public static function requestPasswordReset(): bool { + require_once APP_ROOT . '/src/SmtpRelay.php'; + $db = new Database(); + + $to = SmtpRelay::getNotifyEmail($db); + if ($to === '') { + error_log('[AdminAuth] password reset requested but no notify email configured'); + return false; + } + + $token = self::issuePasswordResetToken(); + $host = $_SERVER['HTTP_HOST'] ?? 'xamxam.erg.be'; + $url = "https://{$host}/admin/password-reset.php?token={$token}"; + + $subject = 'Réinitialisation du mot de passe — XAMXAM'; + $body = << +

Réinitialisation du mot de passe

+

Une demande de réinitialisation du mot de passe administrateur a été effectuée.

+

Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe. Ce lien est à usage unique et expire dans 30 minutes.

+

Définir un nouveau mot de passe

+

Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail.

+ +HTML; + + try { + return SmtpRelay::send($db, $to, $subject, $body); + } catch (\Throwable $e) { + error_log('[AdminAuth] password-reset email failed: ' . $e->getMessage()); + return false; + } + } + + /** + * Redeem a password-reset token and install a new password. + * + * Validates the new password length, consumes the one-time token, stores + * a fresh bcrypt hash (cost 12), and invalidates the session so the admin + * must log in with the new password. + * + * @return bool True on success; false when the token is invalid or the + * password is too short (< 12 chars). + */ + public static function redeemPasswordResetToken(string $token, string $newPassword): bool + { + if (strlen($newPassword) < 12) { + return false; + } + require_once APP_ROOT . '/src/OneTimeToken.php'; $db = new Database(); $ot = new OneTimeToken($db->getPDO()); $context = $ot->redeem('password_reset', $token); if ($context === null) { - return null; + return false; } - return password_hash($newPassword, PASSWORD_BCRYPT, ['cost' => 12]); + $hash = password_hash($newPassword, PASSWORD_BCRYPT, ['cost' => 12]); + if ($hash === false) { + return false; + } + + self::setPasswordHash($hash); + self::logout(); // invalidate any existing admin session + return true; } /** diff --git a/app/templates/admin/login.php b/app/templates/admin/login.php index 65b040a..dc5975e 100644 --- a/app/templates/admin/login.php +++ b/app/templates/admin/login.php @@ -14,6 +14,11 @@ + +
+ + +
diff --git a/app/templates/admin/password-reset.php b/app/templates/admin/password-reset.php new file mode 100644 index 0000000..022e240 --- /dev/null +++ b/app/templates/admin/password-reset.php @@ -0,0 +1,29 @@ +
+
+ +
+
diff --git a/tests/phpunit/OneTimeTokenTest.php b/tests/phpunit/OneTimeTokenTest.php new file mode 100644 index 0000000..d450eab --- /dev/null +++ b/tests/phpunit/OneTimeTokenTest.php @@ -0,0 +1,65 @@ +pdo = TestDatabase::getPDO(); + $this->ot = new OneTimeToken($this->pdo); + } + + public function testIssueReturns256BitHex(): void + { + $token = $this->ot->issue('test', 3600); + $this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $token); + } + + public function testIssueStoresOnlyHashAndContext(): void + { + $token = $this->ot->issue('test', 3600, ['a' => 1]); + $row = $this->pdo->query("SELECT * FROM one_time_tokens WHERE purpose = 'test'")->fetch(); + + $this->assertNotFalse($row); + $this->assertSame(hash('sha256', $token), $row['token_hash']); + $this->assertNotSame($token, $row['token_hash']); + $this->assertSame(['a' => 1], json_decode($row['context'], true)); + } + + public function testPurposeIsolation(): void + { + $token = $this->ot->issue('alpha', 3600); + $this->assertFalse($this->ot->isValid('beta', $token)); + } + + public function testRedeemReturnsContextAndConsumesToken(): void + { + $token = $this->ot->issue('test', 3600, ['k' => 'v']); + $this->assertSame(['k' => 'v'], $this->ot->redeem('test', $token)); + $this->assertNull($this->ot->redeem('test', $token)); + } + + public function testExpiredTokenIsInvalidAndLookupStillFindsIt(): void + { + $token = $this->ot->issue('test', -10, ['k' => 'v']); + $this->assertFalse($this->ot->isValid('test', $token)); + $this->assertNull($this->ot->redeem('test', $token)); + + $row = $this->ot->lookup('test', $token); + $this->assertNotNull($row); + $this->assertSame(['k' => 'v'], $row['context']); + } + + public function testLookupUnknownTokenReturnsNull(): void + { + $this->assertNull($this->ot->lookup('test', str_repeat('ab', 32))); + } +}