$filtres categorie, resultat, role, id_compte, * q (texte libre), du, au (AAAA-MM-JJ), * page, taille. * * @return array{entrees: array>, total: int, page: int, taille: int, pages: int} */ public function rechercher(array $filtres = []): array { [$where, $params] = $this->conditions($filtres); $taille = (int) ($filtres['taille'] ?? self::TAILLE_PAGE); $taille = max(10, min(self::TAILLE_PAGE_MAX, $taille)); $page = max(1, (int) ($filtres['page'] ?? 1)); $total = $this->compter($where, $params); $pages = $total === 0 ? 1 : (int) ceil($total / $taille); // Une page vidée par un filtre plus restrictif ne doit pas renvoyer un // écran blanc : on se replie sur la dernière page réellement peuplée. $page = min($page, $pages); $stmt = $this->pdo->prepare( 'SELECT id_journal, date_evenement, categorie, action, resultat, id_compte, role, acteur, cible_table, cible_id, description, contexte, adresse_ip, agent_utilisateur, jeton_prefixe FROM v_journal_activite' . $where . ' ORDER BY date_evenement DESC, id_journal DESC LIMIT :limite OFFSET :decalage' ); foreach ($params as $nom => $valeur) { $stmt->bindValue(':' . $nom, $valeur); } // LIMIT et OFFSET doivent être liés en ENTIER : sans émulation de // requêtes préparées, une valeur liée en chaîne produirait un LIMIT '50' // que MariaDB refuse. $stmt->bindValue(':limite', $taille, PDO::PARAM_INT); $stmt->bindValue(':decalage', ($page - 1) * $taille, PDO::PARAM_INT); $stmt->execute(); return [ 'entrees' => array_map([$this, 'presenter'], $stmt->fetchAll()), 'total' => $total, 'page' => $page, 'taille' => $taille, 'pages' => $pages, ]; } /** * Quelques chiffres de tête pour le bandeau de la page DSI. * * @return array */ public function statistiques(): array { $total = (int) $this->pdo->query('SELECT COUNT(*) FROM journal_activite')->fetchColumn(); $stmt = $this->pdo->query( 'SELECT categorie, COUNT(*) AS nombre FROM journal_activite WHERE date_evenement >= NOW() - INTERVAL 7 DAY GROUP BY categorie' ); $parCategorie = []; foreach ($stmt->fetchAll() as $ligne) { $parCategorie[(string) $ligne['categorie']] = (int) $ligne['nombre']; } $refus24h = (int) $this->pdo->query( 'SELECT COUNT(*) FROM journal_activite WHERE resultat = \'REFUS\' AND date_evenement >= NOW() - INTERVAL 1 DAY' )->fetchColumn(); $connexions24h = (int) $this->pdo->query( 'SELECT COUNT(*) FROM journal_activite WHERE categorie = \'CONNEXION\' AND resultat = \'SUCCES\' AND date_evenement >= NOW() - INTERVAL 1 DAY' )->fetchColumn(); $plusAncienne = $this->pdo->query( 'SELECT MIN(date_evenement) FROM journal_activite' )->fetchColumn(); return [ 'total' => $total, 'sept_jours' => $parCategorie, 'refus_24h' => $refus24h, 'connexions_24h' => $connexions24h, 'plus_ancienne' => $plusAncienne ?: null, ]; } /** * Nombre de secrets refusés pour une adresse IP sur les dernières minutes. * * C'est la mesure qui alimente le frein anti-force-brute de la page de * connexion : le journal sert ici de compteur, sans table supplémentaire. * Les deux facteurs comptent dans le même compteur — jeton refusé (V7.11) et * mot de passe refusé (V7.12) : essayer mille mots de passe derrière un jeton * volé doit buter sur le même mur qu'essayer mille jetons. */ public function echecsRecents(string $adresseIp, int $minutes): int { if ($adresseIp === '') { return 0; } $stmt = $this->pdo->prepare( 'SELECT COUNT(*) FROM journal_activite WHERE categorie = \'CONNEXION\' AND action IN (\'JETON_REFUSE\', \'MOT_DE_PASSE_REFUSE\') AND adresse_ip = :ip AND date_evenement >= NOW() - INTERVAL :minutes MINUTE' ); $stmt->bindValue(':ip', $adresseIp); $stmt->bindValue(':minutes', $minutes, PDO::PARAM_INT); $stmt->execute(); return (int) $stmt->fetchColumn(); } /** * Purge les entrées antérieures à $jours jours. * * Le plancher de 30 jours est volontaire : une purge trop courte reviendrait * à effacer les traces d'un incident avant même qu'il soit constaté. * * @return int Nombre de lignes supprimées. */ public function purger(int $jours): int { $jours = max(30, $jours); $stmt = $this->pdo->prepare( 'DELETE FROM journal_activite WHERE date_evenement < NOW() - INTERVAL :jours DAY' ); $stmt->bindValue(':jours', $jours, PDO::PARAM_INT); $stmt->execute(); return $stmt->rowCount(); } /** * Construit la clause WHERE commune à la recherche et au comptage. * * @param array $filtres * * @return array{0: string, 1: array} */ private function conditions(array $filtres): array { $conditions = []; $params = []; foreach (['categorie' => 'categorie', 'resultat' => 'resultat', 'role' => 'role'] as $filtre => $colonne) { $valeur = trim((string) ($filtres[$filtre] ?? '')); if ($valeur !== '') { $conditions[] = $colonne . ' = :' . $filtre; $params[$filtre] = $valeur; } } $compteId = (int) ($filtres['id_compte'] ?? 0); if ($compteId > 0) { $conditions[] = 'id_compte = :id_compte'; $params['id_compte'] = $compteId; } $du = trim((string) ($filtres['du'] ?? '')); if ($du !== '') { $conditions[] = 'date_evenement >= :du'; $params['du'] = $du . ' 00:00:00'; } $au = trim((string) ($filtres['au'] ?? '')); if ($au !== '') { $conditions[] = 'date_evenement <= :au'; $params['au'] = $au . ' 23:59:59'; } $recherche = trim((string) ($filtres['q'] ?? '')); if ($recherche !== '') { $conditions[] = '(acteur LIKE :q OR action LIKE :q OR description LIKE :q OR adresse_ip LIKE :q)'; $params['q'] = '%' . $recherche . '%'; } return [$conditions === [] ? '' : ' WHERE ' . implode(' AND ', $conditions), $params]; } /** * @param array $params */ private function compter(string $where, array $params): int { $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM v_journal_activite' . $where); $stmt->execute($params); return (int) $stmt->fetchColumn(); } /** * Met en forme une entrée pour l'API. `contexte` est décodé ici : le client * reçoit un objet, pas une chaîne JSON à réanalyser. * * @param array $ligne * * @return array */ private function presenter(array $ligne): array { $contexte = null; if (!empty($ligne['contexte'])) { $decode = json_decode((string) $ligne['contexte'], true); $contexte = is_array($decode) ? $decode : null; } return [ 'id_journal' => (int) $ligne['id_journal'], 'date_evenement' => (string) $ligne['date_evenement'], 'categorie' => (string) $ligne['categorie'], 'action' => (string) $ligne['action'], 'resultat' => (string) $ligne['resultat'], 'id_compte' => $ligne['id_compte'] !== null ? (int) $ligne['id_compte'] : null, 'role' => $ligne['role'], 'acteur' => $ligne['acteur'], 'cible_table' => $ligne['cible_table'], 'cible_id' => $ligne['cible_id'] !== null ? (int) $ligne['cible_id'] : null, 'description' => $ligne['description'], 'contexte' => $contexte, 'adresse_ip' => $ligne['adresse_ip'], 'agent_utilisateur' => $ligne['agent_utilisateur'], 'jeton_prefixe' => $ligne['jeton_prefixe'], ]; } }