185 lines
6.8 KiB
PHP
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;
|
|
} |