266 lines
9.5 KiB
PHP
266 lines
9.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Models;
|
|
|
|
use PDO;
|
|
|
|
/**
|
|
* Lecture du journal d'activité pour la page DSI (V7.11).
|
|
*
|
|
* Ce modèle ne fait que LIRE — l'écriture appartient à App\Core\Journal, qui
|
|
* n'est jamais loin d'une action métier. La seule exception est la purge, une
|
|
* opération d'exploitation déclenchée à la main par la DSI.
|
|
*
|
|
* Le volume est la contrainte de conception : un journal se consulte par
|
|
* tranches. Toutes les recherches sont donc paginées et bornées, et le compte
|
|
* total est demandé séparément plutôt que ramené ligne par ligne.
|
|
*/
|
|
final class JournalModel extends BaseModel
|
|
{
|
|
/** Taille de page par défaut, alignée sur ce qu'un écran affiche sans lassitude. */
|
|
public const TAILLE_PAGE = 50;
|
|
|
|
/** Plafond dur : aucune requête ne peut ramener davantage de lignes. */
|
|
private const TAILLE_PAGE_MAX = 200;
|
|
|
|
/**
|
|
* Recherche paginée dans le journal.
|
|
*
|
|
* @param array<string, mixed> $filtres categorie, resultat, role, id_compte,
|
|
* q (texte libre), du, au (AAAA-MM-JJ),
|
|
* page, taille.
|
|
*
|
|
* @return array{entrees: array<int, array<string, mixed>>, 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<string, mixed>
|
|
*/
|
|
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<string, mixed> $filtres
|
|
*
|
|
* @return array{0: string, 1: array<string, mixed>}
|
|
*/
|
|
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<string, mixed> $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<string, mixed> $ligne
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
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'],
|
|
];
|
|
}
|
|
}
|