|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|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 et pour la page « Jetons d'accès » du * Service Enfance. * * `roles` BORNE la liste à un ensemble de rôles. C'est ce qui permet à la * délégation V7.27 de ne jamais laisser voir un compte privilégié : le * contrôleur passe sa liste blanche, et aucun filtre d'interface ne peut * l'élargir — `role` ne fait que restreindre à l'intérieur de `roles`. * * @param array $filtres role, actif (0|1), q (texte libre), * roles (array facultatif). * * @return array> */ public function lister(array $filtres = []): array { $conditions = []; $params = []; $rolesAutorises = array_values(array_filter(array_map( static fn (mixed $role): string => strtoupper(trim((string) $role)), (array) ($filtres['roles'] ?? []) ))); if ($rolesAutorises !== []) { $marqueurs = []; foreach ($rolesAutorises as $index => $roleAutorise) { $marqueurs[] = ':role_autorise_' . $index; $params['role_autorise_' . $index] = $roleAutorise; } $conditions[] = 'role IN (' . implode(', ', $marqueurs) . ')'; } $role = (string) ($filtres['role'] ?? ''); // Le filtre d'interface ne peut que restreindre : un rôle hors de la // liste blanche ne rend rien, il ne la contourne pas. if ($role !== '' && ($rolesAutorises === [] || in_array(strtoupper($role), $rolesAutorises, true))) { $conditions[] = 'role = :role'; $params['role'] = $role; } elseif ($role !== '') { $conditions[] = '1 = 0'; } if (isset($filtres['actif']) && $filtres['actif'] !== '') { $conditions[] = 'actif = :actif'; $params['actif'] = (int) $filtres['actif']; } $recherche = trim((string) ($filtres['q'] ?? '')); if ($recherche !== '') { // UN MARQUEUR PAR COLONNE, et non un seul répété quatre fois : PDO // tourne ici sans émulation de requêtes préparées (ATTR_EMULATE_ // PREPARES = 0), et MariaDB refuse alors un même paramètre nommé // employé plusieurs fois — « Invalid parameter number ». Répéter // « :q » faisait échouer toute recherche en 500. $colonnes = ['identite_libelle', 'libelle', 'matricule', 'jeton_prefixe']; $comparaisons = []; foreach ($colonnes as $index => $colonne) { $comparaisons[] = $colonne . ' LIKE :q_' . $index; $params['q_' . $index] = '%' . $recherche . '%'; } $conditions[] = '(' . implode(' OR ', $comparaisons) . ')'; } $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|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> */ 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, 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 * 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 $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 $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 $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 $ligne * * @return array */ 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 $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; } }