This commit is contained in:
PTA
2026-08-20 17:00:06 +02:00
parent 5d8c0993a6
commit 94e7f09eb4
58 changed files with 5605 additions and 615 deletions

View File

@@ -46,6 +46,149 @@ final class AgentModel extends BaseModel
return self::hasTrainingRight($agent) ? self::ENVELOPPE_FORMATION_MINUTES : 0;
}
/**
* Bornes du contrat, telles qu'elles servent aux contrôles d'affectation.
*
* Les deux bornes sont FACULTATIVES et n'ont pas le même sens quand elles
* manquent :
* - pas de date de début : contrat d'origine inconnue (fiche ancienne), on
* n'oppose alors aucune borne basse plutôt que d'inventer une date ;
* - pas de date de fin : contrat à durée indéterminée, la borne haute
* n'existe pas.
* Dans les deux cas, l'absence ouvre — elle ne ferme jamais.
*
* @param array<string, mixed> $agent Fiche agent, telle que renvoyée par
* findActive() ou allActive().
*
* @return array{0: string|null, 1: string|null} [début, fin] au format AAAA-MM-JJ.
*/
public static function contractRange(array $agent): array
{
$debut = trim((string) ($agent['date_debut_contrat'] ?? ''));
$fin = trim((string) ($agent['date_fin_contrat'] ?? ''));
return [$debut !== '' ? $debut : null, $fin !== '' ? $fin : null];
}
/**
* Vrai si la date tombe dans les bornes du contrat.
*
* Comparaison de chaînes « AAAA-MM-JJ » : ce format se compare
* lexicographiquement dans le même ordre que chronologiquement, ce qui évite
* de construire deux objets DateTime par créneau contrôlé.
*/
public static function contractCoversDate(array $agent, string $date): bool
{
[$debut, $fin] = self::contractRange($agent);
if ($debut !== null && $date < $debut) {
return false;
}
return !($fin !== null && $date > $fin);
}
/**
* Filtre les dates qui tombent hors contrat, dédoublonnées et triées.
*
* @param array<int, string> $dates
*
* @return array<int, string>
*/
public static function datesOutsideContract(array $agent, array $dates): array
{
$hors = [];
foreach ($dates as $date) {
$date = trim((string) $date);
if ($date !== '' && !self::contractCoversDate($agent, $date)) {
$hors[$date] = true;
}
}
$hors = array_keys($hors);
sort($hors);
return $hors;
}
/** « du 01/09/2026 au 31/08/2027 », « à partir du 01/09/2026 », « jusqu'au … ». */
public static function contractPeriodLabel(array $agent): string
{
[$debut, $fin] = self::contractRange($agent);
$lisible = static fn(string $date): string => implode('/', array_reverse(explode('-', $date)));
if ($debut !== null && $fin !== null) {
return sprintf('du %s au %s', $lisible($debut), $lisible($fin));
}
if ($debut !== null) {
return sprintf('à partir du %s', $lisible($debut));
}
if ($fin !== null) {
return sprintf('jusqu’au %s', $lisible($fin));
}
return 'sans date de contrat renseignée';
}
/**
* Message de refus commun à tous les points d'écriture, pour que
* l'utilisateur lise la même phrase quel que soit l'écran qui l'a produit.
*
* @param array<int, string> $datesHorsContrat
*/
public static function contractViolationMessage(array $agent, array $datesHorsContrat): string
{
$lisible = static fn(string $date): string => implode('/', array_reverse(explode('-', $date)));
$exemples = array_map($lisible, array_slice($datesHorsContrat, 0, 3));
if (count($datesHorsContrat) > 3) {
$exemples[] = sprintf('et %d autre(s)', count($datesHorsContrat) - 3);
}
return sprintf(
'%s %s n’est pas sous contrat à cette période : son contrat court %s. Date(s) refusée(s) : %s.',
(string) ($agent['prenom'] ?? ''),
(string) ($agent['nom'] ?? ''),
self::contractPeriodLabel($agent),
implode(', ', $exemples)
);
}
/**
* Créneaux déjà planifiés en dehors d'une fenêtre de contrat.
*
* Sert au contrôle MIROIR de l'affectation : raccourcir un contrat ne doit
* pas laisser derrière lui des affectations orphelines. Interroge donc les
* créneaux, pas les plannings — un planning peut être à cheval sur la borne.
*
* @return array{nombre: int, premiere: string|null, derniere: string|null}
*/
public function entriesOutsideContract(int $agentId, ?string $debut, ?string $fin): array
{
$stmt = $this->pdo->prepare(
'SELECT COUNT(*) AS nombre, MIN(c.date_jour) AS premiere, MAX(c.date_jour) AS derniere
FROM creneau_horaire c
JOIN planning p ON p.id_planning = c.id_planning
WHERE p.id_agent = :agent_id
AND ((:debut IS NOT NULL AND c.date_jour < :debut_compare)
OR (:fin IS NOT NULL AND c.date_jour > :fin_compare))'
);
// Chaque paramètre nommé est répété sous deux noms : PDO tourne sans
// émulation, un même nom ne peut pas apparaître deux fois.
$stmt->execute([
'agent_id' => $agentId,
'debut' => $debut,
'debut_compare' => $debut,
'fin' => $fin,
'fin_compare' => $fin,
]);
$ligne = $stmt->fetch() ?: [];
return [
'nombre' => (int) ($ligne['nombre'] ?? 0),
'premiere' => $ligne['premiere'] ?? null,
'derniere' => $ligne['derniere'] ?? null,
];
}
/**
* Projection commune à toutes les lectures d'agent, complétée par la clause
* WHERE de chaque appelant.
@@ -375,6 +518,10 @@ final class AgentModel extends BaseModel
* bindValue() explicite ici (et non un tableau passé à execute()) car
* `structure_id` peut être NULL et exige PDO::PARAM_NULL.
*
* Le nom et le prénom font partie des champs modifiables depuis la V7.13 ;
* le MATRICULE, lui, ne l'est jamais : c'est la clé de rapprochement avec
* les outils RH, et les plannings comme les PDF déjà produits s'y réfèrent.
*
* @param array<string, mixed> $data Champs déjà validés par le contrôleur.
*
* @throws \RuntimeException Si l'agent n'existe pas.
@@ -395,7 +542,9 @@ final class AgentModel extends BaseModel
$update = $this->pdo->prepare(
'UPDATE agent
SET email = :email,
SET nom = :nom,
prenom = :prenom,
email = :email,
telephone = :telephone,
adresse = :adresse,
est_diplome = :est_diplome,
@@ -410,6 +559,8 @@ final class AgentModel extends BaseModel
WHERE id_agent = :agent_id'
);
$update->bindValue(':agent_id', $agentId, PDO::PARAM_INT);
$update->bindValue(':nom', $data['nom']);
$update->bindValue(':prenom', $data['prenom']);
$update->bindValue(':email', $data['email']);
$update->bindValue(':telephone', $data['telephone']);
$update->bindValue(':adresse', $data['adresse']);

672
app/Models/CompteModel.php Normal file
View File

@@ -0,0 +1,672 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Core\Access;
use DomainException;
/**
* Comptes d'accès et jetons (V7.11).
*
* PRINCIPE : chaque utilisateur — agent, responsable de lieu, Service Enfance,
* DSI — reçoit un jeton unique tiré au sort par le serveur. Il n'y a AUCUN mot
* de passe dans PTA : rien à choisir, rien à renouveler, rien à ressaisir sur
* un post-it.
*
* Le jeton n'est stocké NULLE PART en clair. La base garde son empreinte
* SHA-256 et ses onze premiers caractères, qui servent uniquement à reconnaître
* une ligne dans la liste de la page DSI. Conséquence directe, à assumer dans
* l'interface : un jeton n'est affiché QU'UNE FOIS, au moment où il est produit.
* Perdu, il ne se retrouve pas — il se régénère, ce qui invalide le précédent.
*
* Pourquoi SHA-256 sans sel, alors qu'on hache d'ordinaire les secrets avec
* password_hash() : un jeton PTA est tiré sur 120 bits d'entropie par le
* serveur, il n'est pas devinable par dictionnaire comme le serait un mot de
* passe choisi par un humain. Le durcissement lent de password_hash() n'apporte
* donc rien ici, alors qu'il interdirait de retrouver le compte en une seule
* lecture d'index — il faudrait comparer le jeton présenté à CHAQUE compte.
*/
final class CompteModel extends BaseModel
{
/**
* Alphabet des jetons : chiffres et majuscules, MOINS I, O, 0 et 1.
* Les jetons sont recopiés à la main depuis un courriel ou un papier : les
* caractères qu'on confond à la lecture en sont exclus.
*/
private const ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
/** 4 groupes de 6 caractères = 24 tirages sur 32 valeurs, soit 120 bits. */
private const GROUPES = 4;
private const TAILLE_GROUPE = 6;
/**
* Longueur minimale d'un mot de passe. La longueur prime sur la complexité :
* imposer une majuscule et un chiffre produit « Motdepasse1 », pas un secret.
*/
public const MOT_DE_PASSE_MINIMUM = 12;
/**
* Longueur maximale, imposée par bcrypt : password_hash() ne considère que
* les 72 PREMIERS OCTETS et ignore le reste EN SILENCE. Refuser au-delà vaut
* mieux que laisser croire qu'une phrase de 90 caractères protège mieux
* qu'un préfixe de 72.
*/
public const MOT_DE_PASSE_MAXIMUM = 72;
/**
* Empreinte du jeton d'installation posé par la migration V7.11.
* Sert uniquement à afficher l'alerte « ce jeton est public, régénérez-le »
* tant que le compte DSI initial n'a pas été renouvelé.
*/
public const EMPREINTE_INSTALLATION = '7f61ffc8efed4ed46d581a98286115ad03bb0122ffc70db3398584348aca431d';
/**
* Vérifie un jeton présenté à la connexion.
*
* La vue ne renvoie le compte que s'il est actif ET que l'identité derrière
* lui l'est aussi : un agent désactivé ne peut plus entrer, même avec un
* jeton techniquement valide.
*
* @return array<string, mixed>|null Le compte, ou null si le jeton est
* inconnu, révoqué ou porté par une
* identité inactive.
*/
public function authentifier(string $jeton): ?array
{
$jeton = self::normaliser($jeton);
if ($jeton === '') {
return null;
}
$stmt = $this->pdo->prepare(
'SELECT * FROM v_acces_compte
WHERE jeton_empreinte = :empreinte AND actif = 1 AND identite_active = 1
LIMIT 1'
);
$stmt->execute(['empreinte' => self::empreinte($jeton)]);
return $stmt->fetch() ?: null;
}
/**
* Relit un compte par son identifiant, pour la SECONDE étape de connexion.
*
* Les mêmes conditions qu'à l'authentification par jeton s'appliquent — actif
* et identité active : entre la saisie du jeton et celle du mot de passe, la
* DSI a pu révoquer le compte. La ligne est renvoyée brute, avec son hachage,
* là où trouver() la met en forme pour l'API et le retire.
*
* @return array<string, mixed>|null
*/
public function trouverPourAuthentification(int $compteId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM v_acces_compte
WHERE id_compte = :id AND actif = 1 AND identite_active = 1
LIMIT 1'
);
$stmt->execute(['id' => $compteId]);
return $stmt->fetch() ?: null;
}
/** Horodate une connexion réussie et incrémente son compteur. */
public function enregistrerConnexion(int $compteId): void
{
$stmt = $this->pdo->prepare(
'UPDATE compte_acces
SET date_derniere_connexion = NOW(),
nombre_connexions = nombre_connexions + 1
WHERE id_compte = :id'
);
$stmt->execute(['id' => $compteId]);
}
/**
* Liste des comptes pour la page DSI.
*
* @param array<string, mixed> $filtres role, actif (0|1), q (texte libre).
*
* @return array<int, array<string, mixed>>
*/
public function lister(array $filtres = []): array
{
$conditions = [];
$params = [];
$role = (string) ($filtres['role'] ?? '');
if ($role !== '') {
$conditions[] = 'role = :role';
$params['role'] = $role;
}
if (isset($filtres['actif']) && $filtres['actif'] !== '') {
$conditions[] = 'actif = :actif';
$params['actif'] = (int) $filtres['actif'];
}
$recherche = trim((string) ($filtres['q'] ?? ''));
if ($recherche !== '') {
$conditions[] = '(identite_libelle LIKE :q OR libelle LIKE :q OR matricule LIKE :q OR jeton_prefixe LIKE :q)';
$params['q'] = '%' . $recherche . '%';
}
$where = $conditions === [] ? '' : ' WHERE ' . implode(' AND ', $conditions);
$stmt = $this->pdo->prepare(
'SELECT id_compte, role, libelle, identite_libelle, identite_active, id_agent, id_structure,
matricule, structure_nom, jeton_prefixe, jeton_empreinte, actif, commentaire,
date_creation, date_rotation, date_derniere_connexion, nombre_connexions,
mot_de_passe_requis, mot_de_passe_defini, date_mot_de_passe
FROM v_acces_compte' . $where . '
ORDER BY FIELD(role, \'DSI\', \'SERVICE_ENFANCE\', \'RESPONSABLE_STRUCTURE\', \'AGENT\'), identite_libelle'
);
$stmt->execute($params);
return array_map(
fn (array $ligne): array => $this->presenter($ligne),
$stmt->fetchAll()
);
}
/**
* Un compte par son identifiant.
*
* @return array<string, mixed>|null
*/
public function trouver(int $compteId): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM v_acces_compte WHERE id_compte = :id LIMIT 1');
$stmt->execute(['id' => $compteId]);
$compte = $stmt->fetch();
return $compte === false ? null : $this->presenter($compte);
}
/**
* Identités actives encore dépourvues de compte.
*
* @return array<int, array<string, mixed>>
*/
public function manquants(): array
{
$stmt = $this->pdo->query(
'SELECT role, id_agent, id_structure, libelle FROM v_acces_compte_manquant ORDER BY role, libelle'
);
return $stmt->fetchAll();
}
/**
* Crée un compte et retourne le jeton EN CLAIR — unique occasion de le lire.
*
* @return array{compte: array<string, mixed>, jeton: string}
*
* @throws DomainException Si le rôle, l'identité ou l'unicité ne conviennent pas.
*/
public function creer(
string $role,
?int $agentId,
?int $structureId,
string $libelle,
?string $commentaire = null
): array {
$role = strtoupper(trim($role));
$libelle = trim($libelle);
if (!in_array($role, [Access::ROLE_AGENT, Access::ROLE_RESPONSABLE, Access::ROLE_SERVICE, Access::ROLE_DSI], true)) {
throw new DomainException('Rôle inconnu.');
}
// Un compte AGENT ou RESPONSABLE_STRUCTURE tire son périmètre de son
// identité : sans identité, la session n'aurait aucun lieu ni aucun
// agent à borner. Les deux autres rôles n'ont pas de périmètre à borner.
if ($role === Access::ROLE_AGENT) {
$structureId = null;
if ($agentId === null) {
throw new DomainException('Choisissez l’agent titulaire du jeton.');
}
$libelle = $this->libelleAgent($agentId);
} elseif ($role === Access::ROLE_RESPONSABLE) {
$agentId = null;
if ($structureId === null) {
throw new DomainException('Choisissez le lieu d’affectation titulaire du jeton.');
}
$libelle = $this->libelleStructure($structureId);
} else {
$agentId = null;
$structureId = null;
if ($libelle === '') {
throw new DomainException('Indiquez le nom du titulaire du jeton.');
}
}
$jeton = self::genererJeton();
$stmt = $this->pdo->prepare(
'INSERT INTO compte_acces (role, libelle, id_agent, id_structure, jeton_empreinte, jeton_prefixe, commentaire)
VALUES (:role, :libelle, :id_agent, :id_structure, :empreinte, :prefixe, :commentaire)'
);
try {
$stmt->execute([
'role' => $role,
'libelle' => mb_substr($libelle, 0, 180),
'id_agent' => $agentId,
'id_structure' => $structureId,
'empreinte' => self::empreinte($jeton),
'prefixe' => self::prefixe($jeton),
'commentaire' => $commentaire !== null && trim($commentaire) !== ''
? mb_substr(trim($commentaire), 0, 255)
: null,
]);
} catch (\PDOException $e) {
// 23000 = violation de contrainte : les index uniques sur id_agent et
// id_structure garantissent un seul compte par identité.
if ($e->getCode() === '23000') {
throw new DomainException('Cette identité possède déjà un compte d’accès.');
}
throw $e;
}
$compte = $this->trouver((int) $this->pdo->lastInsertId());
return ['compte' => $compte ?? [], 'jeton' => $jeton];
}
/**
* Régénère le jeton d'un compte : l'ancien cesse immédiatement de
* fonctionner, y compris pour les sessions ouvertes — Access relit le compte
* à chaque requête, mais c'est l'empreinte qui a changé, pas le compte.
*
* @return string Le nouveau jeton, en clair, à transmettre au titulaire.
*
* @throws DomainException Si le compte n'existe pas.
*/
public function regenererJeton(int $compteId): string
{
if ($this->trouver($compteId) === null) {
throw new DomainException('Compte introuvable.');
}
$jeton = self::genererJeton();
$stmt = $this->pdo->prepare(
'UPDATE compte_acces
SET jeton_empreinte = :empreinte,
jeton_prefixe = :prefixe,
date_rotation = NOW()
WHERE id_compte = :id'
);
$stmt->execute([
'empreinte' => self::empreinte($jeton),
'prefixe' => self::prefixe($jeton),
'id' => $compteId,
]);
return $jeton;
}
/**
* Active ou désactive un compte.
*
* @throws DomainException Si l'opération laisserait l'application sans
* aucune DSI en état de se connecter.
*/
public function definirActif(int $compteId, bool $actif): void
{
$compte = $this->trouver($compteId);
if ($compte === null) {
throw new DomainException('Compte introuvable.');
}
if (!$actif) {
$this->assurerDerniereDsi($compte);
}
$stmt = $this->pdo->prepare('UPDATE compte_acces SET actif = :actif WHERE id_compte = :id');
$stmt->execute(['actif' => $actif ? 1 : 0, 'id' => $compteId]);
}
/**
* Supprime définitivement un compte. Les traces qu'il a laissées dans le
* journal SURVIVENT : la clé étrangère est en ON DELETE SET NULL et le
* libellé de l'acteur y est figé.
*
* @throws DomainException Si c'est le dernier accès DSI actif.
*/
public function supprimer(int $compteId): void
{
$compte = $this->trouver($compteId);
if ($compte === null) {
throw new DomainException('Compte introuvable.');
}
$this->assurerDerniereDsi($compte);
$stmt = $this->pdo->prepare('DELETE FROM compte_acces WHERE id_compte = :id');
$stmt->execute(['id' => $compteId]);
}
/**
* Génère en une passe un jeton pour chaque agent et chaque lieu actif qui
* n'en a pas. C'est l'outil de MISE EN SERVICE : sans lui, il faudrait créer
* les comptes un par un avant que quiconque puisse se connecter.
*
* @return array<int, array{role: string, libelle: string, jeton: string, id_compte: int}>
* Les jetons produits, en clair. Cette liste est la seule occasion
* de les lire : elle doit être distribuée puis refermée.
*/
public function genererManquants(): array
{
$produits = [];
foreach ($this->manquants() as $identite) {
$role = (string) $identite['role'];
$agentId = $identite['id_agent'] !== null ? (int) $identite['id_agent'] : null;
$structureId = $identite['id_structure'] !== null ? (int) $identite['id_structure'] : null;
$resultat = $this->creer($role, $agentId, $structureId, (string) $identite['libelle']);
$produits[] = [
'id_compte' => (int) ($resultat['compte']['id_compte'] ?? 0),
'role' => $role,
'libelle' => (string) ($resultat['compte']['identite_libelle'] ?? $identite['libelle']),
'jeton' => $resultat['jeton'],
];
}
return $produits;
}
/** Nombre de comptes DSI actifs, hors compte $exclu. */
public function compterDsiActives(?int $exclu = null): int
{
// Zéro plutôt que NULL : PDO tourne sans émulation de requêtes préparées,
// un même paramètre nommé ne peut donc pas être répété dans la requête,
// et aucun compte ne porte l'identifiant 0.
$stmt = $this->pdo->prepare(
'SELECT COUNT(*) FROM v_acces_compte
WHERE role = :role AND actif = 1 AND identite_active = 1
AND id_compte <> :exclu'
);
$stmt->execute(['role' => Access::ROLE_DSI, 'exclu' => $exclu ?? 0]);
return (int) $stmt->fetchColumn();
}
/**
* Vrai tant qu'un compte actif porte encore le jeton d'installation, dont
* l'empreinte est écrite en clair dans la migration versionnée — donc
* publique. L'interface DSI s'en sert pour alerter en rouge.
*
* La question est posée à la base plutôt qu'à la liste affichée : un filtre
* de recherche ne doit pas faire disparaître l'alerte.
*/
public function installationEnCours(): bool
{
$stmt = $this->pdo->prepare(
'SELECT 1 FROM compte_acces
WHERE jeton_empreinte = :empreinte AND actif = 1
LIMIT 1'
);
$stmt->execute(['empreinte' => self::EMPREINTE_INSTALLATION]);
return $stmt->fetchColumn() !== false;
}
/**
* Ce compte réclame-t-il un mot de passe en plus de son jeton ?
*
* La règle vit dans la vue (`mot_de_passe_requis`), pas ici : la liste des
* rôles concernés se modifie en SQL, comme le reste des droits depuis la
* V7.8.
*
* @param array<string, mixed> $compte Ligne de v_acces_compte.
*/
public static function exigeMotDePasse(array $compte): bool
{
return (int) ($compte['mot_de_passe_requis'] ?? 0) === 1;
}
/**
* Le titulaire a-t-il déjà choisi son mot de passe ?
* Faux à la création du compte comme après une réinitialisation par la DSI :
* la connexion suivante impose alors d'en définir un.
*
* @param array<string, mixed> $compte Ligne de v_acces_compte.
*/
public static function motDePasseDefini(array $compte): bool
{
return (int) ($compte['mot_de_passe_defini'] ?? 0) === 1;
}
/**
* Vérifie le mot de passe présenté, et remet l'empreinte à niveau si PHP a
* changé d'algorithme par défaut depuis sa création.
*
* @param array<string, mixed> $compte Ligne de v_acces_compte, avec son hachage.
*/
public function verifierMotDePasse(array $compte, string $motDePasse): bool
{
$hash = (string) ($compte['mot_de_passe_hash'] ?? '');
if ($hash === '' || !password_verify($motDePasse, $hash)) {
return false;
}
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$this->enregistrerHash((int) $compte['id_compte'], password_hash($motDePasse, PASSWORD_DEFAULT));
}
return true;
}
/**
* Enregistre le mot de passe choisi par le titulaire.
*
* @throws DomainException Si la politique n'est pas respectée.
*/
public function definirMotDePasse(int $compteId, string $motDePasse, string $confirmation): void
{
if ($motDePasse !== $confirmation) {
throw new DomainException('Les deux mots de passe saisis ne sont pas identiques.');
}
$this->assurerPolitique($motDePasse);
$this->enregistrerHash($compteId, password_hash($motDePasse, PASSWORD_DEFAULT));
}
/**
* Efface le mot de passe d'un compte : la DSI ne le remplace pas, elle le
* RETIRE. Le titulaire en choisira un nouveau à sa prochaine connexion, avec
* son jeton — la DSI ne connaît donc jamais le mot de passe de personne.
*
* @throws DomainException Si le compte n'existe pas ou n'a pas de mot de passe.
*/
public function reinitialiserMotDePasse(int $compteId): array
{
$compte = $this->trouver($compteId);
if ($compte === null) {
throw new DomainException('Compte introuvable.');
}
if (!$compte['mot_de_passe_requis']) {
throw new DomainException('Ce type de compte n’utilise pas de mot de passe.');
}
$stmt = $this->pdo->prepare(
'UPDATE compte_acces SET mot_de_passe_hash = NULL, date_mot_de_passe = NULL WHERE id_compte = :id'
);
$stmt->execute(['id' => $compteId]);
return $compte;
}
/**
* Politique de mot de passe : la longueur, et rien d'autre.
*
* @throws DomainException Si le mot de passe est trop court, trop long ou
* trop proche du jeton.
*/
private function assurerPolitique(string $motDePasse): void
{
// Mesure en OCTETS et non en caractères : c'est la limite de bcrypt, et
// un accent compte pour deux.
$longueur = strlen($motDePasse);
if ($longueur < self::MOT_DE_PASSE_MINIMUM) {
throw new DomainException(sprintf(
'Le mot de passe doit contenir au moins %d caractères.',
self::MOT_DE_PASSE_MINIMUM
));
}
if ($longueur > self::MOT_DE_PASSE_MAXIMUM) {
throw new DomainException(sprintf(
'Le mot de passe ne peut pas dépasser %d caractères.',
self::MOT_DE_PASSE_MAXIMUM
));
}
if (str_starts_with(self::normaliser($motDePasse), 'PTA-')) {
// Recopier son jeton dans le champ mot de passe ramènerait les deux
// facteurs à un seul secret.
throw new DomainException('Le mot de passe ne peut pas être votre jeton d’accès.');
}
}
/** Écrit une empreinte de mot de passe et horodate le changement. */
private function enregistrerHash(int $compteId, string $hash): void
{
$stmt = $this->pdo->prepare(
'UPDATE compte_acces
SET mot_de_passe_hash = :hash, date_mot_de_passe = NOW()
WHERE id_compte = :id'
);
$stmt->execute(['hash' => $hash, 'id' => $compteId]);
}
/** Un jeton en clair, prêt à être transmis : « PTA-XXXXXX-XXXXXX-… ». */
public static function genererJeton(): string
{
$groupes = [];
for ($groupe = 0; $groupe < self::GROUPES; $groupe++) {
$caracteres = '';
for ($index = 0; $index < self::TAILLE_GROUPE; $index++) {
// random_int() et non rand() : le tirage doit être imprévisible.
$caracteres .= self::ALPHABET[random_int(0, strlen(self::ALPHABET) - 1)];
}
$groupes[] = $caracteres;
}
return 'PTA-' . implode('-', $groupes);
}
/** Empreinte stockée en base. */
public static function empreinte(string $jeton): string
{
return hash('sha256', self::normaliser($jeton));
}
/**
* Nettoie un jeton saisi à la main : espaces, casse et espaces insécables
* collés par un copier-coller depuis un courriel.
*/
public static function normaliser(string $jeton): string
{
$jeton = str_replace(["\u{00A0}", ' ', "\t", "\r", "\n"], '', $jeton);
return strtoupper(trim($jeton));
}
/** Début du jeton, conservé en base pour l'identifier sans le révéler. */
private static function prefixe(string $jeton): string
{
return mb_substr(self::normaliser($jeton), 0, 11);
}
/**
* Met en forme une ligne de v_acces_compte pour l'API : types corrects,
* empreinte remplacée par le seul drapeau qui intéresse l'interface.
*
* @param array<string, mixed> $ligne
*
* @return array<string, mixed>
*/
private function presenter(array $ligne): array
{
return [
'id_compte' => (int) $ligne['id_compte'],
'role' => (string) $ligne['role'],
'libelle' => (string) $ligne['libelle'],
'identite_libelle' => (string) ($ligne['identite_libelle'] ?? $ligne['libelle']),
'identite_active' => (int) ($ligne['identite_active'] ?? 1) === 1,
'id_agent' => $ligne['id_agent'] !== null ? (int) $ligne['id_agent'] : null,
'id_structure' => $ligne['id_structure'] !== null ? (int) $ligne['id_structure'] : null,
'matricule' => $ligne['matricule'] ?? null,
'structure_nom' => $ligne['structure_nom'] ?? null,
'jeton_prefixe' => (string) $ligne['jeton_prefixe'],
// Le jeton d'installation est public : l'interface doit pouvoir
// crier tant qu'il n'a pas été remplacé.
'jeton_installation' => (string) $ligne['jeton_empreinte'] === self::EMPREINTE_INSTALLATION,
'actif' => (int) $ligne['actif'] === 1,
// Le hachage ne sort jamais du serveur : l'interface n'a besoin que
// de savoir si un mot de passe est exigé et s'il a été choisi.
'mot_de_passe_requis' => (int) ($ligne['mot_de_passe_requis'] ?? 0) === 1,
'mot_de_passe_defini' => (int) ($ligne['mot_de_passe_defini'] ?? 0) === 1,
'date_mot_de_passe' => $ligne['date_mot_de_passe'] ?? null,
'commentaire' => $ligne['commentaire'] ?? null,
'date_creation' => $ligne['date_creation'],
'date_rotation' => $ligne['date_rotation'],
'date_derniere_connexion' => $ligne['date_derniere_connexion'],
'nombre_connexions' => (int) $ligne['nombre_connexions'],
];
}
/**
* Interdit de se couper soi-même la branche : le dernier accès DSI actif ne
* peut être ni désactivé ni supprimé. Sans ce garde-fou, il faudrait rejouer
* la migration pour récupérer la main.
*
* @param array<string, mixed> $compte
*/
private function assurerDerniereDsi(array $compte): void
{
if ((string) $compte['role'] !== Access::ROLE_DSI || !$compte['actif']) {
return;
}
if ($this->compterDsiActives((int) $compte['id_compte']) === 0) {
throw new DomainException(
'Ce compte est le dernier accès DSI actif : créez-en un autre avant de le retirer.'
);
}
}
/** Libellé d'un agent actif, ou refus si l'agent n'existe pas ou est inactif. */
private function libelleAgent(int $agentId): string
{
$stmt = $this->pdo->prepare(
'SELECT CONCAT(prenom, \' \', nom) FROM v_acces_identite_agent WHERE id_agent = :id LIMIT 1'
);
$stmt->execute(['id' => $agentId]);
$libelle = $stmt->fetchColumn();
if ($libelle === false) {
throw new DomainException('Agent introuvable ou inactif.');
}
return (string) $libelle;
}
/** Libellé d'un lieu actif, ou refus si le lieu n'existe pas ou est fermé. */
private function libelleStructure(int $structureId): string
{
$stmt = $this->pdo->prepare(
'SELECT nom FROM v_acces_identite_structure WHERE id_structure = :id LIMIT 1'
);
$stmt->execute(['id' => $structureId]);
$libelle = $stmt->fetchColumn();
if ($libelle === false) {
throw new DomainException('Lieu d’affectation introuvable ou inactif.');
}
return (string) $libelle;
}
}

265
app/Models/JournalModel.php Normal file
View 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'],
];
}
}

View File

@@ -437,6 +437,34 @@ final class PlanningModel extends BaseModel
*
* @return array<int, array{c1: int, c2: int, date_jour: string}>
*/
/**
* Créneaux du planning tombant hors des dates de contrat de leur agent.
*
* Contrôle de VALIDATION, complémentaire des gardes posées à l'écriture
* (V7.15) : celles-ci empêchent d'en créer de nouveaux, celui-ci révèle ceux
* qui existaient déjà — saisis avant la règle, ou rendus illégitimes par un
* contrat raccourci depuis. La comparaison est faite en SQL, sur la fiche
* agent jointe : c'est elle qui fait foi au moment du contrôle.
*
* @return array<int, array{id_creneau: int, date_jour: string}>
*/
public function entriesOutsideContract(int $planningId): array
{
$stmt = $this->pdo->prepare(
'SELECT c.id_creneau, c.date_jour
FROM creneau_horaire c
JOIN planning p ON p.id_planning = c.id_planning
JOIN agent a ON a.id_agent = p.id_agent
WHERE c.id_planning = :id_planning
AND ((a.date_debut_contrat IS NOT NULL AND c.date_jour < a.date_debut_contrat)
OR (a.date_fin_contrat IS NOT NULL AND c.date_jour > a.date_fin_contrat))
ORDER BY c.date_jour'
);
$stmt->execute(['id_planning' => $planningId]);
return $stmt->fetchAll();
}
public function internalOverlaps(int $planningId): array
{
$stmt = $this->pdo->prepare(

View File

@@ -56,6 +56,8 @@ final class SchemaModel extends BaseModel
'equipe_pta',
'equipe_pta_membre',
'structure_creneau_reference',
'compte_acces',
'journal_activite',
];
$placeholders = implode(',', array_fill(0, count($requiredTables), '?'));
@@ -82,6 +84,7 @@ final class SchemaModel extends BaseModel
'planning' => ['date_validation'],
'agent' => ['email', 'telephone', 'adresse', 'est_diplome', 'diplome_libelle', 'id_structure', 'id_poste', 'type_contrat', 'date_debut_contrat', 'date_fin_contrat', 'droit_enveloppe_formation', 'commentaire_pta'],
'structure' => ['type_affectation'],
'compte_acces' => ['mot_de_passe_hash'],
];
foreach ($requiredColumns as $table => $columns) {
@@ -108,6 +111,7 @@ final class SchemaModel extends BaseModel
}
$this->assertAccessViews();
$this->assertJetonViews();
}
/**
@@ -145,4 +149,39 @@ final class SchemaModel extends BaseModel
);
}
}
/**
* Vues des comptes à jeton et du journal (V7.11).
*
* Même logique fail-closed que pour les vues de droits : sans elles,
* personne ne peut plus s'authentifier et aucune action ne serait tracée.
* Mieux vaut un message explicite au démarrage qu'une application ouverte
* sans journal.
*/
private function assertJetonViews(): void
{
$requiredViews = [
'v_acces_compte',
'v_acces_compte_manquant',
'v_journal_activite',
];
$placeholders = implode(',', array_fill(0, count($requiredViews), '?'));
$stmt = $this->pdo->prepare(
"SELECT TABLE_NAME
FROM information_schema.VIEWS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ($placeholders)"
);
$stmt->execute($requiredViews);
$missing = array_values(array_diff($requiredViews, $stmt->fetchAll(\PDO::FETCH_COLUMN)));
if ($missing) {
throw new \RuntimeException(
'Comptes d’accès indisponibles : vue(s) manquante(s) : '
. implode(', ', $missing)
. '. Exécutez database/migration_v7_9e_dsi_comptes_journal.sql.'
);
}
}
}