update
This commit is contained in:
265
app/Models/JournalModel.php
Normal file
265
app/Models/JournalModel.php
Normal file
@@ -0,0 +1,265 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user