comptes = new CompteModel($pdo); } /** * Affiche l'écran de connexion et traite ses chemins, faute de routeur : * - GET ?action=reset : déconnexion, puis retour à cet écran ; * - GET ?action=mot_de_passe : changement de mot de passe, session ouverte ; * - GET ?token=… : première étape par lien direct ; * - POST etape=mot_de_passe : seconde étape (définition ou vérification) ; * - POST : première étape par le champ de saisie. * * Le CSRF est vérifié à la main plutôt que par Csrf::assertPayload() : les * données arrivent en formulaire classique et un échec doit produire une page * HTML, pas une réponse JSON. */ public function index(): void { $access = new Access($this->pdo); if (($_GET['action'] ?? '') === 'reset') { $this->deconnecter($access); } $error = ''; $notice = ''; $etape = 'jeton'; $methode = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); // Changement volontaire du mot de passe : le seul chemin qui exige une // session DÉJÀ ouverte. if (($_GET['action'] ?? '') === 'mot_de_passe' || ($_POST['etape'] ?? '') === 'changement') { [$etape, $error, $notice] = $this->changerMotDePasse($access, $methode); } elseif ($methode === 'POST') { if (!$this->csrfValide()) { $error = 'La session a expiré. Rechargez la page et recommencez.'; $etape = $this->attente() !== null ? $this->etapeAttendue() : 'jeton'; } elseif (($_POST['etape'] ?? '') === 'mot_de_passe') { [$etape, $error] = $this->traiterMotDePasse($access); } else { [$etape, $error] = $this->connecter($access, (string) ($_POST['token'] ?? ''), 'FORMULAIRE'); } } elseif (isset($_GET['token'])) { [$etape, $error] = $this->connecter($access, (string) $_GET['token'], 'LIEN'); } elseif ($this->attente() !== null) { // Rechargement de la page au milieu de la connexion : on reste sur la // seconde étape plutôt que de renvoyer l'utilisateur à son jeton. $etape = $this->etapeAttendue(); } $attente = $this->attente(); Security::sendHtmlHeaders(); $this->render('pages/role', [ 'pageTitle' => 'Connexion', 'cspNonce' => Security::nonce(), 'csrfToken' => Csrf::ensureToken(), 'error' => $error, 'notice' => $notice, 'etape' => $etape, 'titulaire' => (string) ($attente['libelle'] ?? ''), 'longueurMinimale' => CompteModel::MOT_DE_PASSE_MINIMUM, 'currentProfile' => $access->isConfigured() ? $access->profile() : null, 'homeUrl' => $access->isConfigured() ? $access->homeUrl() : 'role.php', ]); } /** * Première étape : vérifie le jeton. * * Ne revient QUE si la connexion n'est pas allée à son terme — une connexion * complète redirige et termine le script. * * @param string $origine « FORMULAIRE » ou « LIEN », conservé au journal : * c'est ce qui permet à la DSI de voir si les liens * directs circulent. * * @return array{0: string, 1: string} [étape à afficher, message d'erreur] */ private function connecter(Access $access, string $jetonBrut, string $origine): array { $jeton = CompteModel::normaliser($jetonBrut); if ($jeton === '') { return ['jeton', 'Saisissez votre jeton d’accès.']; } $blocage = $this->blocageEnCours(); if ($blocage !== '') { return ['jeton', $blocage]; } $compte = $this->comptes->authentifier($jeton); if ($compte === null) { // Ni le jeton présenté ni aucun fragment ne sont journalisés : une // trace du secret dans le journal en ferait un secret de moins. Journal::connexion('JETON_REFUSE', [ 'resultat' => 'REFUS', 'description' => 'Jeton d’accès inconnu, révoqué ou porté par une identité inactive.', 'contexte' => ['origine' => $origine], ]); return ['jeton', 'Ce jeton d’accès est inconnu ou n’est plus valide. Rapprochez-vous de la DSI.']; } // Agents et responsables : le jeton suffit, la session s'ouvre ici. if (!CompteModel::exigeMotDePasse($compte)) { $this->ouvrirSession($access, $compte, ['jeton'], $origine); } // Comptes privilégiés : on retient l'attente et on demande le second // facteur. Rien n'est ouvert tant qu'il n'est pas fourni. $this->poserAttente($compte, $origine); Journal::connexion('JETON_ACCEPTE_ATTENTE_MDP', [ 'id_compte' => (int) $compte['id_compte'], 'role' => (string) $compte['role'], 'acteur' => (string) $compte['identite_libelle'], 'description' => 'Jeton accepté ; mot de passe attendu pour ouvrir la session.', 'contexte' => ['origine' => $origine], ]); return [$this->etapeAttendue(), '']; } /** * Seconde étape : définition ou vérification du mot de passe. * * Le compte est RELU EN BASE plutôt que repris de la session : entre les deux * étapes, la DSI a pu le désactiver, ou l'agent qui le porte être fermé. * * @return array{0: string, 1: string} [étape à afficher, message d'erreur] */ private function traiterMotDePasse(Access $access): array { $attente = $this->attente(); if ($attente === null) { return ['jeton', 'La connexion a expiré. Saisissez de nouveau votre jeton d’accès.']; } $compte = $this->comptes->trouverPourAuthentification((int) $attente['compte_id']); if ($compte === null || !CompteModel::exigeMotDePasse($compte)) { $this->oublierAttente(); return ['jeton', 'Ce compte n’est plus accessible. Rapprochez-vous de la DSI.']; } $motDePasse = (string) ($_POST['mot_de_passe'] ?? ''); // Premier accès : le titulaire choisit son mot de passe. Personne ne le // lui a transmis, et personne ne pourra le lire ensuite. if (!CompteModel::motDePasseDefini($compte)) { try { $this->comptes->definirMotDePasse( (int) $compte['id_compte'], $motDePasse, (string) ($_POST['mot_de_passe_confirmation'] ?? '') ); } catch (DomainException $e) { return ['definition', $e->getMessage()]; } $this->ouvrirSession($access, $compte, ['jeton', 'mot_de_passe'], (string) $attente['origine'], true); } $blocage = $this->blocageEnCours(); if ($blocage !== '') { return ['verification', $blocage]; } if (!$this->comptes->verifierMotDePasse($compte, $motDePasse)) { Journal::connexion('MOT_DE_PASSE_REFUSE', [ 'resultat' => 'REFUS', 'id_compte' => (int) $compte['id_compte'], 'role' => (string) $compte['role'], 'acteur' => (string) $compte['identite_libelle'], 'description' => 'Mot de passe incorrect après un jeton valide.', ]); return ['verification', 'Mot de passe incorrect.']; } $this->ouvrirSession($access, $compte, ['jeton', 'mot_de_passe'], (string) $attente['origine']); return ['jeton', '']; // Inatteignable : ouvrirSession() redirige. } /** * Changement de mot de passe par son titulaire, session ouverte. * * L'ancien mot de passe est exigé : sans lui, un poste laissé déverrouillé * suffirait à confisquer un compte. * * @return array{0: string, 1: string, 2: string} [étape, erreur, message de succès] */ private function changerMotDePasse(Access $access, string $methode): array { if (!$access->isConfigured()) { return ['jeton', 'Connectez-vous avant de changer votre mot de passe.', '']; } $compte = $this->comptes->trouverPourAuthentification((int) $access->accountId()); if ($compte === null || !CompteModel::exigeMotDePasse($compte)) { return ['jeton', 'Votre type de compte n’utilise pas de mot de passe.', '']; } if ($methode !== 'POST') { return ['changement', '', '']; } if (!$this->csrfValide()) { return ['changement', 'La session a expiré. Rechargez la page et recommencez.', '']; } if (!$this->comptes->verifierMotDePasse($compte, (string) ($_POST['mot_de_passe_actuel'] ?? ''))) { Journal::refus('MOT_DE_PASSE_REFUSE', 'Mot de passe actuel incorrect lors d’un changement.'); return ['changement', 'Le mot de passe actuel est incorrect.', '']; } try { $this->comptes->definirMotDePasse( (int) $compte['id_compte'], (string) ($_POST['mot_de_passe'] ?? ''), (string) ($_POST['mot_de_passe_confirmation'] ?? '') ); } catch (DomainException $e) { return ['changement', $e->getMessage(), '']; } Journal::ecriture( 'MOT_DE_PASSE_MODIFIE', 'compte_acces', (int) $compte['id_compte'], 'Mot de passe changé par son titulaire.' ); return ['changement', '', 'Votre mot de passe a été modifié.']; } /** * Ouvre la session et redirige : cette méthode NE REVIENT JAMAIS. * * @param array $compte Ligne de v_acces_compte. * @param array $facteurs Facteurs réellement présentés. * @param bool $premier Vrai si le mot de passe vient d'être défini. */ private function ouvrirSession(Access $access, array $compte, array $facteurs, string $origine, bool $premier = false): never { $this->oublierAttente(); $access->openSession($compte); $this->comptes->enregistrerConnexion((int) $compte['id_compte']); if ($premier) { // Journalisé APRÈS l'ouverture de session, pour que la trace porte le // nom du titulaire plutôt que d'être anonyme. Journal::ecriture( 'MOT_DE_PASSE_DEFINI', 'compte_acces', (int) $compte['id_compte'], 'Mot de passe défini par son titulaire à sa première connexion.' ); } Journal::connexion('CONNEXION_REUSSIE', [ 'description' => sprintf( '%s s’est connecté (%s).', (string) ($compte['identite_libelle'] ?? $compte['libelle']), (string) $compte['role'] ), 'contexte' => ['origine' => $origine, 'facteurs' => $facteurs], ]); // La redirection efface le jeton de la barre d'adresse quand il est // arrivé par un lien direct. header('Location: ' . $access->homeUrl(), true, 302); exit; } /** Déconnexion : trace d'abord, vide la session ensuite, redirige enfin. */ private function deconnecter(Access $access): never { $provided = (string) ($_GET['csrf_token'] ?? ''); if ($provided !== '' && hash_equals(Csrf::ensureToken(), $provided)) { if ($access->isConfigured()) { // L'ordre compte : après clear(), la session ne dit plus qui // vient de partir et la trace serait anonyme. Journal::connexion('DECONNEXION', ['description' => 'Fin de session à la demande de l’utilisateur.']); } $this->oublierAttente(); $access->clear(); } header('Location: role.php', true, 302); exit; } /** * Frein anti-force-brute, commun aux deux étapes : au-delà du plafond, plus * aucun secret n'est même comparé. * * @return string Message à afficher, vide si la voie est libre. */ private function blocageEnCours(): string { $ip = (string) ($_SERVER['REMOTE_ADDR'] ?? ''); $echecs = (new JournalModel($this->pdo))->echecsRecents($ip, self::FENETRE_MINUTES); if ($echecs < self::ECHECS_MAX) { return ''; } // Le blocage est tracé pour que la DSI voie une attaque en cours plutôt // que d'avoir à la déduire. Journal::connexion('CONNEXION_BLOQUEE', [ 'resultat' => 'REFUS', 'description' => sprintf( 'Trop de secrets refusés depuis %s : connexion bloquée.', $ip !== '' ? $ip : 'une adresse inconnue' ), 'contexte' => ['echecs' => $echecs, 'fenetre_minutes' => self::FENETRE_MINUTES], ]); return sprintf( 'Trop de tentatives infructueuses. Réessayez dans %d minutes ou contactez la DSI.', self::FENETRE_MINUTES ); } /** * Mémorise le compte reconnu par son jeton, en attendant son mot de passe. * * @param array $compte */ private function poserAttente(array $compte, string $origine): void { Session::start(); // Regénération de l'identifiant AVANT de poser l'attente : un identifiant // de session imposé par un tiers ne doit pas porter cette étape. session_regenerate_id(true); $_SESSION[self::ATTENTE_KEY] = [ 'compte_id' => (int) $compte['id_compte'], 'libelle' => (string) $compte['identite_libelle'], 'definition' => !CompteModel::motDePasseDefini($compte), 'origine' => $origine, 'expire' => time() + self::ATTENTE_SECONDES, ]; } /** * Attente en cours, ou null si elle est absente ou périmée. * * @return array|null */ private function attente(): ?array { Session::start(); $attente = $_SESSION[self::ATTENTE_KEY] ?? null; if (!is_array($attente) || (int) ($attente['expire'] ?? 0) < time()) { $this->oublierAttente(); return null; } return $attente; } private function oublierAttente(): void { Session::start(); unset($_SESSION[self::ATTENTE_KEY]); } /** « definition » au premier accès, « verification » ensuite. */ private function etapeAttendue(): string { return ($this->attente()['definition'] ?? false) ? 'definition' : 'verification'; } /** Jeton CSRF du formulaire, vérifié à la main (données non JSON). */ private function csrfValide(): bool { $provided = (string) ($_POST['csrf_token'] ?? ''); return $provided !== '' && hash_equals(Csrf::ensureToken(), $provided); } }