current_user()]); } // ---- Connexion -------------------------------------------------------- if ($action === 'login') { $b = body_json(); $username = trim((string) ($b['username'] ?? '')); $password = (string) ($b['password'] ?? ''); if ($username === '' || $password === '') { json_error('Identifiant et mot de passe requis.'); } $st = db()->prepare('SELECT id, username, password_hash, role, must_change, failed_attempts, locked_until FROM users WHERE username = ?'); $st->execute([$username]); $u = $st->fetch(); // Verrou anti-bruteforce. if ($u && $u['locked_until'] !== null && strtotime($u['locked_until']) > time()) { json_error('Compte temporairement verrouillé suite à trop d’essais. Réessayez plus tard.', 429); } if (!$u || !password_verify($password, $u['password_hash'])) { if ($u) { $attempts = (int) $u['failed_attempts'] + 1; if ($attempts >= LOGIN_MAX_ATTEMPTS) { db()->prepare('UPDATE users SET failed_attempts = 0, locked_until = DATE_ADD(NOW(), INTERVAL ? MINUTE) WHERE id = ?') ->execute([LOGIN_LOCK_MINUTES, (int) $u['id']]); } else { db()->prepare('UPDATE users SET failed_attempts = ? WHERE id = ?') ->execute([$attempts, (int) $u['id']]); } } json_error('Identifiants invalides.', 401); } // Succès : réinitialise les compteurs et régénère la session. db()->prepare('UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = ?') ->execute([(int) $u['id']]); session_boot(); session_regenerate_id(true); $_SESSION['uid'] = (int) $u['id']; json_out(['user' => [ 'id' => (int) $u['id'], 'username' => $u['username'], 'role' => $u['role'], 'must_change' => (int) $u['must_change'], ]]); } if ($action === 'logout') { session_boot(); $_SESSION = []; session_destroy(); json_out(['ok' => true]); } // ---- Changement de mot de passe (connecté) ---------------------------- if ($action === 'change_password') { $me = require_login(); $b = body_json(); $current = (string) ($b['current'] ?? ''); $new = (string) ($b['new'] ?? ''); $st = db()->prepare('SELECT password_hash FROM users WHERE id = ?'); $st->execute([(int) $me['id']]); $hash = $st->fetchColumn(); if (!$hash || !password_verify($current, $hash)) { json_error('Mot de passe actuel incorrect.', 403); } if ($new === $current) { json_error('Le nouveau mot de passe doit être différent de l’actuel.'); } if ($err = password_policy_check($new)) { json_error($err); } db()->prepare('UPDATE users SET password_hash = ?, must_change = 0 WHERE id = ?') ->execute([password_hash($new, PASSWORD_DEFAULT), (int) $me['id']]); json_out(['ok' => true]); } // ---- Mot de passe oublié : demande de lien ---------------------------- if ($action === 'forgot') { $login = trim((string) (body_json()['login'] ?? '')); // Réponse générique systématique (ne révèle pas l'existence d'un compte). $generic = ['ok' => true, 'message' => 'Si un compte correspond, un e-mail de réinitialisation a été envoyé.']; if ($login === '') { json_out($generic); } $st = db()->prepare('SELECT id, username, email FROM users WHERE username = ? OR (email IS NOT NULL AND email = ?) LIMIT 1'); $st->execute([$login, $login]); $u = $st->fetch(); if ($u && !empty($u['email'])) { // Invalide les anciens jetons non utilisés puis en crée un nouveau. db()->prepare('UPDATE password_resets SET used = 1 WHERE user_id = ? AND used = 0')->execute([(int) $u['id']]); $token = reset_token_new(); db()->prepare('INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? MINUTE))') ->execute([(int) $u['id'], hash('sha256', $token), RESET_TTL_MINUTES]); $link = APP_BASE_URL . '/reset.html?token=' . $token; $body = "Bonjour,\n\nUne réinitialisation du mot de passe du compte « {$u['username']} » a été demandée.\n" . "Cliquez sur ce lien (valable " . RESET_TTL_MINUTES . " minutes) pour choisir un nouveau mot de passe :\n\n$link\n\n" . "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail.\n"; send_mail($u['email'], 'Réinitialisation de votre mot de passe', $body); } json_out($generic); } // ---- Mot de passe oublié : application du nouveau mot de passe --------- if ($action === 'reset') { $b = body_json(); $token = (string) ($b['token'] ?? ''); $new = (string) ($b['new'] ?? ''); if ($token === '') { json_error('Lien invalide.'); } $st = db()->prepare('SELECT id, user_id FROM password_resets WHERE token_hash = ? AND used = 0 AND expires_at > NOW() LIMIT 1'); $st->execute([hash('sha256', $token)]); $row = $st->fetch(); if (!$row) { json_error('Lien de réinitialisation invalide ou expiré.', 400); } if ($err = password_policy_check($new)) { json_error($err); } db()->prepare('UPDATE users SET password_hash = ?, must_change = 0, failed_attempts = 0, locked_until = NULL WHERE id = ?') ->execute([password_hash($new, PASSWORD_DEFAULT), (int) $row['user_id']]); db()->prepare('UPDATE password_resets SET used = 1 WHERE id = ?')->execute([(int) $row['id']]); json_out(['ok' => true]); } json_error('Action inconnue.', 404); } catch (Throwable $e) { json_error('Erreur : ' . $e->getMessage(), 500); }