Files
2026-10-21 22:00:06 +02:00

185 lines
6.8 KiB
PHP

<?php
/**
* admin/admin_auth.php
*
* Barriere d'authentification pour tout le dossier /admin, durcie pour
* la production :
* - Cookies de session Secure/HttpOnly/SameSite=Strict.
* - Expiration automatique par inactivite (30 minutes).
* - Verrouillage temporaire apres echecs de connexion repetes, stocke
* en BASE DE DONNEES (table login_attempts) et non plus en session
* PHP : un attaquant qui vide ses cookies ou ouvre une navigation
* privee ne peut plus remettre son compteur d'essais a zero.
* - Deux seuils separes : un par couple (identifiant, IP) pour cibler
* le bruteforce sur un compte precis, un autre par IP seule (plus
* permissif) pour eviter qu'un tiers sur la meme IP Tailscale ne
* bloque tout le monde en se trompant sur un identifiant.
* - IP lue exclusivement depuis REMOTE_ADDR : X-Forwarded-For n'est
* PAS utilise ici car aucun reverse proxy de confiance n'est
* declare devant Apache dans cette installation. Si un jour un
* reverse proxy est mis en place, il faudra le configurer
* explicitement (mod_remoteip + RemoteIPTrustedProxy) avant de
* faire confiance a cet en-tete.
* - Charge automatiquement lib/csrf.php pour que csrf_field() et
* require_csrf_token() soient disponibles partout ou admin_auth
* est inclus.
*
* Necessite la migration sql/migration_security_hardening.sql (table
* login_attempts).
*
* Note : la separation par roles (admin/reviewer) est volontairement
* absente de cette version. Tant qu'un seul compte administrateur est
* utilise, l'ajouter partiellement donnerait une fausse impression de
* securite. A traiter dans une migration dediee le jour ou un second
* compte avec des droits restreints sera necessaire.
*/
declare(strict_types=1);
const SESSION_IDLE_TIMEOUT = 1800; // 30 minutes
// Seuil cible sur un identifiant precis, depuis une IP precise :
// protege un compte contre un bruteforce dirige.
const MAX_LOGIN_ATTEMPTS_PER_ACCOUNT = 5;
const LOCKOUT_SECONDS_PER_ACCOUNT = 300; // 5 minutes
// Seuil plus large sur l'IP seule, tous identifiants confondus :
// protege contre un scan large depuis une meme source, sans bloquer
// tout le monde des le premier compte en echec sur une IP partagee
// (ex: plusieurs appareils derriere la meme sortie Tailscale).
const MAX_LOGIN_ATTEMPTS_PER_IP = 30;
const LOCKOUT_SECONDS_PER_IP = 300; // 5 minutes
const LOGIN_ATTEMPTS_WINDOW_SECONDS = 900; // fenetre glissante de 15 minutes
if (session_status() === PHP_SESSION_NONE) {
// IMPORTANT : 'secure' => true exige HTTPS. Si vous testez en HTTP pur
// sur une VM sans certificat, passez temporairement cette valeur a
// false, sinon le cookie de session ne sera jamais transmis et la
// connexion semblera "ne pas fonctionner" (boucle de redirection vers
// login.php). Remettez-le a true avant la mise en production reelle.
session_set_cookie_params([
'lifetime' => 0,
'path' => '/admin',
'secure' => true,
'httponly' => true,
'samesite' => 'Strict',
]);
session_start();
}
require_once __DIR__ . '/../lib/csrf.php';
function require_admin_auth(): void
{
if (empty($_SESSION['admin_user_id'])) {
header('Location: /admin/login.php');
exit;
}
if (!empty($_SESSION['last_activity']) && (time() - $_SESSION['last_activity']) > SESSION_IDLE_TIMEOUT) {
session_unset();
session_destroy();
header('Location: /admin/login.php?timeout=1');
exit;
}
$_SESSION['last_activity'] = time();
}
function current_admin_username(): string
{
return $_SESSION['admin_username'] ?? 'unknown';
}
/**
* IP du visiteur, lue exclusivement depuis REMOTE_ADDR. Ne fait
* volontairement pas confiance a X-Forwarded-For : sur cette
* installation, Apache recoit les connexions directement (ou via
* Tailscale), sans reverse proxy declare qui nettoierait cet en-tete.
* Un client pourrait sinon forger cet en-tete pour usurper une IP et
* contourner le rate limiting.
*/
function client_ip_address(): string
{
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}
function normalize_login_identifier(string $identifier): string
{
return mb_strtolower(trim($identifier));
}
/**
* Verifie si l'identifiant+IP OU l'IP seule ont depasse leur seuil
* respectif d'echecs sur la fenetre glissante. Stocke en base, donc
* non contournable en changeant de session/cookie cote client.
*/
function is_login_locked_out(PDO $db, string $identifier): bool
{
$normalized = normalize_login_identifier($identifier);
$ip = client_ip_address();
$windowStart = date('Y-m-d H:i:s', time() - LOGIN_ATTEMPTS_WINDOW_SECONDS);
// Seuil cible : ce compte precis, depuis cette IP precise.
$accountStmt = $db->prepare(
'SELECT COUNT(*), MAX(attempted_at) FROM login_attempts
WHERE username_normalized = ? AND ip_address = ?
AND success = 0 AND attempted_at >= ?'
);
$accountStmt->execute([$normalized, $ip, $windowStart]);
[$accountFailures, $accountLastAttempt] = $accountStmt->fetch(PDO::FETCH_NUM);
if ((int)$accountFailures >= MAX_LOGIN_ATTEMPTS_PER_ACCOUNT
&& $accountLastAttempt !== null
&& (time() - strtotime($accountLastAttempt)) < LOCKOUT_SECONDS_PER_ACCOUNT
) {
return true;
}
// Seuil large : toute cette IP, tous identifiants confondus.
$ipStmt = $db->prepare(
'SELECT COUNT(*), MAX(attempted_at) FROM login_attempts
WHERE ip_address = ? AND success = 0 AND attempted_at >= ?'
);
$ipStmt->execute([$ip, $windowStart]);
[$ipFailures, $ipLastAttempt] = $ipStmt->fetch(PDO::FETCH_NUM);
if ((int)$ipFailures >= MAX_LOGIN_ATTEMPTS_PER_IP
&& $ipLastAttempt !== null
&& (time() - strtotime($ipLastAttempt)) < LOCKOUT_SECONDS_PER_IP
) {
return true;
}
return false;
}
function record_login_attempt(PDO $db, string $identifier, bool $success, bool $turnstileVerified = false): void
{
$stmt = $db->prepare(
'INSERT INTO login_attempts (username_normalized, ip_address, success, turnstile_verified)
VALUES (?, ?, ?, ?)'
);
$stmt->execute([
normalize_login_identifier($identifier),
client_ip_address(),
$success ? 1 : 0,
$turnstileVerified ? 1 : 0,
]);
}
function attempt_admin_login(PDO $db, string $username, string $password): bool
{
$stmt = $db->prepare('SELECT id, username, password_hash FROM admin_users WHERE username = ?');
$stmt->execute([$username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user && password_verify($password, $user['password_hash'])) {
$_SESSION['admin_user_id'] = $user['id'];
$_SESSION['admin_username'] = $user['username'];
$_SESSION['last_activity'] = time();
session_regenerate_id(true);
return true;
}
return false;
}