171 lines
7.0 KiB
PHP
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>
|