Import
This commit is contained in:
1 parent
038a01ccb8
commit
eee6418ea4
5 files changed
+1232
No files matched your search
@@ -0,0 +1,185 @@
|
||||
<?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;
|
||||
}
|
||||
Reference in new issue
Block a user