707 lines
27 KiB
PHP
707 lines
27 KiB
PHP
<?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 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<string, mixed> $filtres role, actif (0|1), q (texte libre),
|
||
* roles (array<int, string> facultatif).
|
||
*
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
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<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;
|
||
}
|
||
}
|