Version initiale — carte des établissements scolaires

Carte Leaflet (annuaire Éducation nationale) + API PHP/MySQL :
fiche établissement (effectifs, IPS, population INSEE, DPE ADEME),
GED documents/photos, commentaires par groupe, gestion utilisateurs
et politique de mots de passe. Imports opendata (effectifs, IPS,
population, DPE, IRIS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 21:35:41 +02:00
commit 5633969d98
40 changed files with 7242 additions and 0 deletions
+153
View File
@@ -0,0 +1,153 @@
<?php
/**
* Authentification et gestion du mot de passe.
* GET ?action=me → utilisateur courant (ou null)
* POST ?action=login → {username, password}
* POST ?action=logout
* POST ?action=change_password → {current, new} (utilisateur connecté)
* POST ?action=forgot → {login} (identifiant ou e-mail)
* POST ?action=reset → {token, new} (lien de réinitialisation)
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
const LOGIN_MAX_ATTEMPTS = 5; // échecs avant verrouillage
const LOGIN_LOCK_MINUTES = 15; // durée du verrou
const RESET_TTL_MINUTES = 60; // validité d'un lien de réinitialisation
try {
$action = $_GET['action'] ?? 'me';
if ($action === 'me') {
json_out(['user' => 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 dessais. 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 lactuel.');
}
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);
}