add password-reset flow: request endpoint + reset page + login link (shared OneTimeToken)

This commit is contained in:
Pontoporeia
2026-08-24 11:36:02 +02:00
parent d7184e4447
commit ddae5d8fef
7 changed files with 269 additions and 10 deletions
+4
View File
@@ -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';
+53
View File
@@ -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>";
+50
View File
@@ -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;