Files
admin-netmonster-db/admin/login.php
T
2026-10-21 22:00:31 +02:00

171 lines
7.0 KiB
PHP

<?php
/**
* admin/login.php
*
* Durci suite a l'audit du 20/09/2026 :
* - Rate limiting deplace en base (voir admin_auth.php / table
* login_attempts), avec deux seuils separes (compte+IP, et IP
* seule) pour eviter qu'une IP partagee (ex: sortie Tailscale
* commune) ne bloque tout le monde a cause d'un seul identifiant
* en echec.
* - CAPTCHA Cloudflare Turnstile OPTIONNEL et configurable, pour
* bloquer les scripts automatises de bruteforce. Desactive par
* defaut tant que config/turnstile.php n'existe pas, afin de ne
* jamais bloquer l'acces admin par erreur de configuration.
* - Verification explicite de la disponibilite de l'extension
* php-curl avant d'activer Turnstile : si l'extension est absente,
* Turnstile est desactive proprement (avec un log) plutot que de
* provoquer une erreur fatale sur la page de connexion.
* - "Fail mode" configurable si Cloudflare est injoignable : par
* defaut TURNSTILE_FAIL_CLOSED=false, pour ne pas te bloquer toi-
* meme si Cloudflare est indisponible ou si la VM perd
* temporairement l'acces Internet sortant. Passez-le a true
* uniquement si vous avez un acces de secours (ex: SSH+CLI pour
* reinitialiser un mot de passe) en cas de probleme reseau.
*
* Configuration (fichier NON versionne, cf config/turnstile.example.php) :
* TURNSTILE_ENABLED : true/false, active ou non le CAPTCHA
* TURNSTILE_SITE_KEY : cle publique, visible cote client
* TURNSTILE_SECRET_KEY : cle secrete, JAMAIS exposee cote client
* TURNSTILE_FAIL_CLOSED : true/false, comportement si Cloudflare
* est injoignable (defaut recommande: false)
*
* Obtenir ces cles : https://dash.cloudflare.com/ > Turnstile > Add site
* (gratuit, aucune carte bancaire requise).
*/
declare(strict_types=1);
require_once __DIR__ . '/admin_auth.php';
require_once __DIR__ . '/../lib/db.php';
// Fichier de config NON versionne (a creer manuellement sur le
// serveur, en dehors du depot Git) : voir config/turnstile.example.php
$turnstileConfigPath = __DIR__ . '/../config/turnstile.php';
$turnstileEnabled = false;
if (is_file($turnstileConfigPath)) {
require_once $turnstileConfigPath;
$turnstileEnabled = defined('TURNSTILE_ENABLED') && TURNSTILE_ENABLED
&& defined('TURNSTILE_SITE_KEY') && TURNSTILE_SITE_KEY !== ''
&& defined('TURNSTILE_SECRET_KEY') && TURNSTILE_SECRET_KEY !== '';
if ($turnstileEnabled && !function_exists('curl_init')) {
error_log('[login] Turnstile configure mais extension php-curl absente : CAPTCHA desactive automatiquement.');
$turnstileEnabled = false;
}
}
if (!defined('TURNSTILE_FAIL_CLOSED')) {
define('TURNSTILE_FAIL_CLOSED', false);
}
$db = pdo();
$error = null;
$timeout = isset($_GET['timeout']);
/**
* Verifie le jeton Turnstile aupres de l'API Cloudflare.
*
* Retourne true si le jeton est valide. Si l'appel reseau vers
* Cloudflare echoue (timeout, DNS, panne), le comportement depend de
* TURNSTILE_FAIL_CLOSED :
* - false (par defaut) : on laisse passer la tentative (le login
* reste protege par le mot de passe et le rate limiting SQL), pour
* ne jamais bloquer l'acces admin a cause d'une panne Cloudflare
* ou d'une coupure Internet sortante temporaire sur la VM.
* - true : on bloque la tentative tant que Turnstile n'a pas
* confirme, pour un site expose publiquement sans acces de secours
* alternatif.
*/
function verifyTurnstileToken(string $token, string $remoteIp): bool
{
if ($token === '') {
return false;
}
$ch = curl_init('https://challenges.cloudflare.com/turnstile/v0/siteverify');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_POSTFIELDS => http_build_query([
'secret' => TURNSTILE_SECRET_KEY,
'response' => $token,
'remoteip' => $remoteIp,
]),
]);
$response = curl_exec($ch);
$curlError = curl_errno($ch);
curl_close($ch);
if ($curlError !== 0 || $response === false) {
error_log('[login] Turnstile injoignable (curl errno ' . $curlError . '). Fail-closed=' . (TURNSTILE_FAIL_CLOSED ? 'oui' : 'non') . '.');
return !TURNSTILE_FAIL_CLOSED;
}
$decoded = json_decode($response, true);
return is_array($decoded) && !empty($decoded['success']);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_csrf_token();
$username = trim((string)($_POST['username'] ?? ''));
$password = (string)($_POST['password'] ?? '');
$turnstileVerified = true; // reste a true si Turnstile est desactive
if ($turnstileEnabled) {
$turnstileToken = (string)($_POST['cf-turnstile-response'] ?? '');
$turnstileVerified = verifyTurnstileToken($turnstileToken, client_ip_address());
}
if (!$turnstileVerified) {
$error = 'Verification anti-robot echouee. Reessayez.';
} elseif ($username === '') {
$error = 'Identifiants incorrects.';
} elseif (is_login_locked_out($db, $username)) {
$error = 'Trop de tentatives echouees. Reessayez dans quelques minutes.';
} else {
if (attempt_admin_login($db, $username, $password)) {
record_login_attempt($db, $username, true, $turnstileVerified);
header('Location: index.php');
exit;
}
record_login_attempt($db, $username, false, $turnstileVerified);
$error = 'Identifiants incorrects.';
}
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8"><title>Connexion - NetMonster Admin</title>
<link rel="stylesheet" href="assets/theme.css">
<?php if ($turnstileEnabled): ?>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<?php endif; ?>
<style>
body { display:flex; align-items:center; justify-content:center; height:100vh; }
.login-card { width: 320px; }
.login-card .brand { display:flex; align-items:center; gap:.6rem; justify-content:center; margin-bottom:1rem; font-weight:700; font-size:1.1rem; }
.cf-turnstile { margin: 1rem 0; display:flex; justify-content:center; }
</style>
</head>
<body>
<div class="card login-card">
<div class="brand"><span class="dot"></span> NetMonster Admin</div>
<?php if ($timeout): ?><p class="msg-err">Session expiree, veuillez vous reconnecter.</p><?php endif; ?>
<?php if ($error): ?><p class="msg-err"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
<form method="post">
<?= csrf_field() ?>
<label>Utilisateur</label>
<input type="text" name="username" required autocomplete="username">
<label>Mot de passe</label>
<input type="password" name="password" required autocomplete="current-password">
<?php if ($turnstileEnabled): ?>
<div class="cf-turnstile" data-sitekey="<?= htmlspecialchars(TURNSTILE_SITE_KEY, ENT_QUOTES, 'UTF-8') ?>"></div>
<?php endif; ?>
<button type="submit" class="primary" style="width:100%;margin-top:1rem;">Se connecter</button>
</form>
</div>
</body>
</html>