mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
add password-reset flow: request endpoint + reset page + login link (shared OneTimeToken)
This commit is contained in:
@@ -21,6 +21,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$error = 'Mot de passe incorrect.';
|
$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';
|
$pageTitle = 'Connexion';
|
||||||
$isAdmin = true; $isLogin = true; $bodyClass = 'admin-body';
|
$isAdmin = true; $isLogin = true; $bodyClass = 'admin-body';
|
||||||
require_once APP_ROOT . '/templates/head.php';
|
require_once APP_ROOT . '/templates/head.php';
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Reset password page (unauthenticated).
|
||||||
|
*
|
||||||
|
* GET → show a "set new password" form if the token is valid (token in a
|
||||||
|
* hidden field, never in a POST-redirect URL).
|
||||||
|
* POST → redeem the one-time token, install the new password, redirect to login.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../../bootstrap.php';
|
||||||
|
require_once APP_ROOT . '/src/AdminAuth.php';
|
||||||
|
require_once APP_ROOT . '/src/App.php';
|
||||||
|
|
||||||
|
// Reset only makes sense with a password configured.
|
||||||
|
if (!AdminAuth::hasPassword()) {
|
||||||
|
header('Location: /admin/');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
App::boot();
|
||||||
|
|
||||||
|
$token = trim($_GET['token'] ?? $_POST['token'] ?? '');
|
||||||
|
$error = null;
|
||||||
|
|
||||||
|
// ── POST: redeem token + set new password ─────────────────────────────────
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (empty($_POST['csrf_token']) || empty($_SESSION['csrf_token'])
|
||||||
|
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
|
||||||
|
$error = 'Erreur de sécurité : token invalide.';
|
||||||
|
} else {
|
||||||
|
$new = $_POST['new_password'] ?? '';
|
||||||
|
$conf = $_POST['confirm_password'] ?? '';
|
||||||
|
|
||||||
|
if (strlen($new) < 12) {
|
||||||
|
$error = 'Le mot de passe doit contenir au moins 12 caractères.';
|
||||||
|
} elseif ($new !== $conf) {
|
||||||
|
$error = 'Les mots de passe ne correspondent pas.';
|
||||||
|
} elseif (!AdminAuth::redeemPasswordResetToken($token, $new)) {
|
||||||
|
$error = 'Ce lien est invalide, a déjà été utilisé, ou a expiré.';
|
||||||
|
} else {
|
||||||
|
App::flash('success', 'Mot de passe mis à jour. Connectez-vous.');
|
||||||
|
header('Location: /admin/login.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render (GET, or POST with a validation error) ─────────────────────────
|
||||||
|
$pageTitle = 'Réinitialiser le mot de passe';
|
||||||
|
$isAdmin = true; $isLogin = true; $bodyClass = 'admin-body';
|
||||||
|
require_once APP_ROOT . '/templates/head.php';
|
||||||
|
include APP_ROOT . '/templates/header.php';
|
||||||
|
include APP_ROOT . '/templates/admin/password-reset.php';
|
||||||
|
echo "\n</body>\n</html>";
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Request a password reset (unauthenticated).
|
||||||
|
*
|
||||||
|
* Issues a single-use reset token (shared OneTimeToken model, purpose
|
||||||
|
* 'password_reset') and emails the link to the admin notification address.
|
||||||
|
* Always returns a neutral message so an attacker cannot learn whether a
|
||||||
|
* password hash is configured.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../../bootstrap.php';
|
||||||
|
require_once APP_ROOT . '/src/AdminAuth.php';
|
||||||
|
require_once APP_ROOT . '/src/App.php';
|
||||||
|
require_once APP_ROOT . '/src/RateLimit.php';
|
||||||
|
|
||||||
|
// Only meaningful when a password actually exists (dev mode has none).
|
||||||
|
if (!AdminAuth::hasPassword()) {
|
||||||
|
header('Location: /admin/');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
App::boot();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
header('Allow: POST');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF
|
||||||
|
if (empty($_POST['csrf_token']) || empty($_SESSION['csrf_token'])
|
||||||
|
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
|
||||||
|
App::flash('error', 'Erreur de sécurité : token invalide.');
|
||||||
|
header('Location: /admin/login.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate-limit reset requests (3 per 10 min per IP) to prevent inbox flooding.
|
||||||
|
$key = 'password_reset_' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||||||
|
if (!(new RateLimit(3, 600))->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;
|
||||||
+63
-10
@@ -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
|
* Uses the shared OneTimeToken model + SmtpRelay. Returns true when the
|
||||||
* is generated for the given new password — but NOT yet persisted; the
|
* email was accepted for delivery (false if no address is configured or
|
||||||
* caller decides when to store it (so the reset flow can validate before
|
* sending fails).
|
||||||
* committing).
|
|
||||||
*
|
|
||||||
* @return string|null bcrypt hash, or null when the token is invalid.
|
|
||||||
*/
|
*/
|
||||||
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 = <<<HTML
|
||||||
|
<html><body style="font-family:sans-serif;color:#222;max-width:600px;margin:0 auto;padding:24px">
|
||||||
|
<h1 style="font-size:1.4rem;border-bottom:2px solid #c00;padding-bottom:8px">Réinitialisation du mot de passe</h1>
|
||||||
|
<p>Une demande de réinitialisation du mot de passe administrateur a été effectuée.</p>
|
||||||
|
<p>Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe. Ce lien est à usage unique et expire dans 30 minutes.</p>
|
||||||
|
<p><a href="{$url}" style="display:inline-block;margin:16px 0;padding:12px 24px;background:#c00;color:#fff;text-decoration:none;border-radius:4px">Définir un nouveau mot de passe</a></p>
|
||||||
|
<p style="font-size:.85rem;color:#666">Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail.</p>
|
||||||
|
</body></html>
|
||||||
|
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';
|
require_once APP_ROOT . '/src/OneTimeToken.php';
|
||||||
$db = new Database();
|
$db = new Database();
|
||||||
$ot = new OneTimeToken($db->getPDO());
|
$ot = new OneTimeToken($db->getPDO());
|
||||||
|
|
||||||
$context = $ot->redeem('password_reset', $token);
|
$context = $ot->redeem('password_reset', $token);
|
||||||
if ($context === null) {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,6 +14,11 @@
|
|||||||
<button type="submit" class="btn btn--primary">Se connecter</button>
|
<button type="submit" class="btn btn--primary">Se connecter</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/request-reset.php" class="admin-form" style="margin-top:var(--space-s)">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '') ?>">
|
||||||
|
<button type="submit" class="btn btn--muted">Mot de passe oublié ?</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<main id="main-content">
|
||||||
|
<div class="admin-login-wrap">
|
||||||
|
<div class="admin-login-box">
|
||||||
|
<h2>Nouveau mot de passe</h2>
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<p class="toast" role="alert" data-type="error">⚠ <?= htmlspecialchars($error) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<form method="post" action="/admin/password-reset.php" class="admin-form" autocomplete="off">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '') ?>">
|
||||||
|
<input type="hidden" name="token" value="<?= htmlspecialchars($token) ?>">
|
||||||
|
<div>
|
||||||
|
<label for="new_password">Nouveau mot de passe</label>
|
||||||
|
<input type="password" id="new_password" name="new_password" required minlength="12" autocomplete="new-password" autofocus>
|
||||||
|
<small>Minimum 12 caractères.</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="confirm_password">Confirmer le mot de passe</label>
|
||||||
|
<input type="password" id="confirm_password" name="confirm_password" required minlength="12" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="admin-form-footer">
|
||||||
|
<button type="submit" class="btn btn--primary">Enregistrer le mot de passe</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p class="admin-cancel-link" style="margin-top:var(--space-s);text-align:center">
|
||||||
|
<a href="/admin/login.php">Retour à la connexion</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OneTimeTokenTest — unit tests for the shared single-use token model.
|
||||||
|
*/
|
||||||
|
class OneTimeTokenTest extends TestCase
|
||||||
|
{
|
||||||
|
private OneTimeToken $ot;
|
||||||
|
private PDO $pdo;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
TestDatabase::resetData();
|
||||||
|
$this->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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user