update
This commit is contained in:
@@ -7,90 +7,438 @@ namespace App\Controllers;
|
||||
use App\Core\Access;
|
||||
use App\Core\Controller;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\Security;
|
||||
use App\Models\AgentModel;
|
||||
use App\Models\StructureModel;
|
||||
use App\Core\Session;
|
||||
use App\Models\CompteModel;
|
||||
use App\Models\JournalModel;
|
||||
use DomainException;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Sélection du rôle et de l'identité de travail.
|
||||
* Connexion — point d'entrée de l'application (role.php).
|
||||
*
|
||||
* ATTENTION : ce n'est PAS une authentification. L'utilisateur choisit
|
||||
* librement qui il est, sans mot de passe ni vérification. C'est la faille
|
||||
* ouverte la plus importante du projet, assumée pour cette version. Un passage
|
||||
* en production doit remplacer cet écran par une authentification réelle — le
|
||||
* reste de la mécanique de droits (Access, vues SQL) n'a pas à bouger, il
|
||||
* suffit que la session soit renseignée par un compte vérifié.
|
||||
* JUSQU'À LA V7.10, CET ÉCRAN ÉTAIT UN SÉLECTEUR LIBRE : chacun choisissait son
|
||||
* rôle et son identité dans une liste, sans rien prouver. Depuis la V7.11, une
|
||||
* session ne peut naître que de la présentation d'un JETON valide, distribué par
|
||||
* la DSI. Le reste de la mécanique de droits n'a pas bougé : Access lit toujours
|
||||
* les mêmes vues SQL, simplement le rôle n'est plus déclaré par l'utilisateur.
|
||||
*
|
||||
* Contrairement aux autres pages, ce contrôleur n'est pas appelé par runPage() :
|
||||
* il s'exécute AVANT tout contrôle d'accès, puisqu'il est ce qui permet d'en
|
||||
* avoir un.
|
||||
* La V7.12 ajoute un SECOND FACTEUR pour les comptes qui engagent l'application
|
||||
* — Service Enfance (qui valide les plannings de toute la collectivité) et DSI
|
||||
* (qui distribue les accès) : après le jeton, un mot de passe. Les deux secrets
|
||||
* sont de nature différente, et c'est tout l'intérêt : le jeton est remis par la
|
||||
* DSI et peut circuler par courriel, le mot de passe est choisi par le titulaire
|
||||
* à sa première connexion et n'est connu que de lui. La DSI ne peut que
|
||||
* l'effacer, jamais le lire ni le remplacer.
|
||||
*
|
||||
* La connexion se fait donc en deux étapes, matérialisées par $etape :
|
||||
* « jeton » — saisie du jeton (ou arrivée par role.php?token=…) ;
|
||||
* « definition » — premier accès d'un compte privilégié : choisir son mot de passe ;
|
||||
* « verification » — accès suivants : le saisir ;
|
||||
* « changement » — session ouverte, le titulaire remplace son mot de passe.
|
||||
*
|
||||
* Entre les deux étapes, RIEN N'EST OUVERT : la session ne porte qu'une attente
|
||||
* horodatée (ATTENTE_KEY), qui périme au bout de cinq minutes. Un jeton validé
|
||||
* sans mot de passe ne donne accès à aucune page.
|
||||
*
|
||||
* Comme avant, ce contrôleur n'est pas appelé par runPage() : il s'exécute AVANT
|
||||
* tout contrôle d'accès, puisqu'il est ce qui permet d'en avoir un.
|
||||
*/
|
||||
final class RoleController extends Controller
|
||||
{
|
||||
/** Fenêtre et plafond du frein anti-force-brute, par adresse IP. */
|
||||
private const FENETRE_MINUTES = 15;
|
||||
private const ECHECS_MAX = 10;
|
||||
|
||||
/** Attente entre les deux étapes : le jeton est reconnu, le mot de passe manque. */
|
||||
private const ATTENTE_KEY = 'pta_auth_attente';
|
||||
private const ATTENTE_SECONDES = 300;
|
||||
|
||||
private CompteModel $comptes;
|
||||
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
$this->comptes = new CompteModel($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Affiche le sélecteur et traite sa soumission.
|
||||
*
|
||||
* Trois chemins dans une seule méthode, faute de routeur :
|
||||
* - GET ?action=reset : abandon du rôle courant, puis redirection ;
|
||||
* - POST : tentative de sélection, puis redirection vers
|
||||
* l'accueil du rôle ou réaffichage avec $error ;
|
||||
* - GET simple : affichage du formulaire.
|
||||
* 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 ici en formulaire classique ($_POST / $_GET) et non en
|
||||
* JSON, et un échec doit produire une page HTML, pas une réponse JSON.
|
||||
* Un jeton invalide sur le reset est ignoré EN SILENCE : l'utilisateur est
|
||||
* simplement renvoyé au sélecteur, sans message d'échec inutile.
|
||||
* 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);
|
||||
|
||||
// Le changement de rôle modifie l'état de la session : il est protégé
|
||||
// par le jeton CSRF au même titre que les écritures de l'API.
|
||||
if (($_GET['action'] ?? '') === 'reset') {
|
||||
$provided = (string) ($_GET['csrf_token'] ?? '');
|
||||
if ($provided !== '' && hash_equals(Csrf::ensureToken(), $provided)) {
|
||||
$access->clear();
|
||||
}
|
||||
header('Location: role.php', true, 302);
|
||||
exit;
|
||||
$this->deconnecter($access);
|
||||
}
|
||||
|
||||
$error = '';
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||
$expected = Csrf::ensureToken();
|
||||
$provided = (string) ($_POST['csrf_token'] ?? '');
|
||||
if ($provided === '' || !hash_equals($expected, $provided)) {
|
||||
$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 {
|
||||
try {
|
||||
$role = strtoupper(trim((string) ($_POST['role'] ?? '')));
|
||||
$agentId = filter_var($_POST['agent_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
|
||||
$structureId = filter_var($_POST['structure_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
|
||||
$access->select($role, $agentId ? (int) $agentId : null, $structureId ? (int) $structureId : null);
|
||||
header('Location: ' . $access->homeUrl(), true, 302);
|
||||
exit;
|
||||
} catch (\DomainException $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
[$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' => 'Choisir votre accès',
|
||||
'pageTitle' => 'Connexion',
|
||||
'cspNonce' => Security::nonce(),
|
||||
'csrfToken' => Csrf::ensureToken(),
|
||||
'agents' => (new AgentModel($this->pdo))->allActive(),
|
||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||
'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<string, mixed> $compte Ligne de v_acces_compte.
|
||||
* @param array<int, string> $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<string, mixed> $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<string, mixed>|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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user