Introduction

Objectifs d’apprentissage

L’objectif de cette séquence qui s’inscrit dans le cadre du développement d’une application MVC est de :

  • réviser le mécanisme du routage et des URL ReST ;
  • étudier le rôle d’un contrôleur concernant la vérification de la validité des données reçues / en entrée ;
  • s’initier aux expressions rationelles ;
  • réviser les formulaires HTML ;
  • consolider la maîtrise d’une architure MVC, notamment pour le partage des “responsabilités” entre vues et contrôleurs.

Travail à faire

L’objectif de la mission est de finaliser l’application de gestion des îles et archipels en implémentant le reste des opérations CRUD.

Préparation :

  • récupérer le code de départ ;
  • renommer le dossier vue en ctrl ;
  • adapter les fichiers .htaccess et config.yaml ;
  • ajouter la classe IO du cadriciel TeachFrame ;
  • vérifier le bon fonctionnement de l’application.

Il est recommandé de désactiver le cache pendant la mise au point de l’application ; pour cela, ajouter dans index.php l’instruction Cache::$disabled = true; (avant dispatch).

Réalisation : adapter les routes, les vues et les contrôleurs pour prendre en compte les contraintes. Les indications sont volontairement restreintes afin de susciter la réflexion.

Analyse : une analyse détaillée du code est nécessaire pour développer les compétences. Les questions de réflexion abordent les points essentiels.

Contraintes

Écrans et contrôles à ajouter

  • écran d’édition (du nom) d’un archipel ;
  • bouton pour créer un archipel depuis la liste des archipels ;
  • bouton pour éditer un archipel depuis le détail d’un archipel ;
  • si l’archipel n’a pas d’île, bouton de suppression d’un archipel depuis le détail d’un archipel ;
  • écran d’édition d’une île — le choix de l’archipel doit s’effectuer à partir d’une liste déroulante ;
  • boutons pour créer une île :
    • depuis la liste des îles ;
    • depuis le détail d’un archipel — à pré-selectionner sur l’écran d’édition ;
  • bouton pour éditer une île depuis le détail d’une île ;
  • bouton de suppression d’une île depuis le détail d’une île,

Rappel d’ergonomie : lors de l’édition d’un objet (ici un archipel ou une île), les contrôles (champs de saisie, boutons radios, cases à cocher, listes déroulantes…) doivent être initialisés avec les données de l’objet.

Routes

Dans le respect du style d’architecture ReST, quatre routes doivent être ajoutées

  • pour afficher l’écran d’édition d’un archipel : GET /archipelago/id/edit — id = 0 dans le cas d’un nouvel archipel ;
  • pour créer, modifier ou supprimer un archipel : POST /archipelago/id : - création lorsque id = 0,
    • modification lorsque id != 0,
    • suppression lorsque id != 0 et qu’aucune données n’est envoyée ;
  • pour afficher l’écran d’édition d’une île : GET /island/id/edit ;
  • pour créer, modifier ou supprimer une île : POST /island/id.

Rappel de sécurité : les opérations qui changent l’état du système (typiquement l’ajout, la modification ou la suppression de données) doivent impérativement se faire avec la méthode HTTP POST.

Important : dans le cas d’une application réelle, il faut toujours demander confirmation avant d’effectuer la suppression d’un objet. Pour cela, un écran supplémentaire de confirmation (avec une route associés) est nécessaire.

Indications

Persistance d’une île

Lors d’une opération impactant l’état du système, le contrôleur doit effectuer certaines vérifications en considérant que l’utilisateur est malveillant et détourne l’utilisation de l’application. En effet, lors d’une utilisation normale, aucune instructions die ne devrait s’exécuter.

function save(string $id) {
  if (0 == $id) {
    $island = new Island(0, '', -1, -1, new Archipelago(0, ''));
  } else {
    $island = ORM::getOne(Island::class, $id);
    if (null == $island) {
      http_response_code(404);
      die('error: \'/island/' . $id . '\' not found');
    }
  }
  if (IO::hasInput()) {
    $inputs = IO::getInput();
    IO::validateInput(['name:*s', 'population:*i', 'area:*i', 'idArchipelago:*i'], $inputs);
    $archipelago = ORM::getOne(Archipelago::class, $inputs->idArchipelago, false);
    if (null == $archipelago) {
      http_response_code(400);
      die('error: integrity constraint failed (idArchipelago = ' . $inputs->idArchipelago . ')');
    }
    $island->setName($inputs->name);
    $island->setPopulation($inputs->population);
    $island->setArea($inputs->area);
    $island->setArchipelago($archipelago);
    ORM::persist($island);
  } else if (0 != $id) { //sans données, c'est une suppression
    ORM::delete($island);
  } else {
    http_response_code(400);
    die('error: cannot delete \'/island/0\'');
  }
  Cache::resetAll();
  header('Location: ' . Router::getURL('/island'));
}

Suppression d’un archipel

Les boutons de suppressions doivent déclencher la soumission d’un formulaire sans données, avec la méthode POST ; une solution consiste :

  • à utiliser un formulaire vide (donc sans affichage), mais avec un identifiant ;
  • à utiliser un bouton, associé au formulaire.

En raison des contraintes d’intégrité référentielle, la suppression d’un archipel n’est possible que si ce dernier n’a pas d’île ; plusieurs approches sont possibles :

  • lors de la suppression d’un archipel, toutes ses îles sont également supprimées ;
  • la suppression d’un archipel avec des îles est interdite, approche retenue ici.

L’ajout du bouton de suppression est conditionné à l’absence d’îles pour cet archipel (cf count($archipelago->getIslands()) == 0) :

<form id="delete" method="POST" action="<?=Router::getURL('/archipelago/' . $archipelago->getId())?>"></form>
<p class="w3-bar">
  <a class="w3-bar-item w3-button w3-indigo w3-right" href="<?=Router::getURL('/archipelago/' . $archipelago->getId() . '/edit')?>">&nbsp;Éditer&nbsp;</a>
  <?php if (count($archipelago->getIslands()) == 0): ?>
  <button form="delete" class="w3-bar-item w3-button w3-red">Effacer</button>
  <?php endif; ?>
</p>

Du côté du contrôleur, il faut prendre en considération l’utilisation malveillante de l’application : un utilisateur pourrait forger une requête HTTP avec curl ou une extension comme Rester, même en l’absence de bouton sur l’interface de l’application. Lors d’une opération de suppression, le contrôleur doit donc vérifier l’absence d’îles :

if (0 != count($archipelago->getIslands())) {
  http_response_code(403);
  die('error: \'/archipelago/' . $id . '\' still has islands');
}

Édition d’un archipel

L’édition d’un archipel ne présente pas de difficulté, hé hé hé ;-)

public function renderEdit(Archipelago $archipelago): string {
  ob_start();
  ?>

  <div class="w3-margin-top w3-container">
    <form method="POST" action="<?=Router::getURL('/archipelago/' . $archipelago->getId())?>" class="w3-card w3-padding">
      <p>
        <label class="w3-bold" for="name">Archipel :</label>
        <input required="required" id="name" name="name" type="text"
               value="<?=$archipelago->getName()?>" class="w3-input w3-border"/>
      </p>
      <p class="w3-bar"><button class="w3-bar-item w3-right w3-button w3-green">Enregistrer</button></p>
    </form>
  </div>

  <?php $content = ob_get_clean();
  $name = '' == $archipelago->getName() ? 'Nouveau' : $archipelago->getName();
  $path = 0 == $archipelago->getId() ? '/archipelago' : '/archipelago/' . $archipelago->getId();
  return $this->render('/Archipels/' . $name,  $path, $content);
}

Édition d’une île

Pour éditer une île, il faut sélectionner un archipel :

  • c’est le contrôleur qui récupère la liste des archipels avec l’ORM ;
  • le contrôleur transmet à la vue l’île et la liste des archipels ;
  • la vue pré-sélectionne l’archipel dans la liste.

Code du contrôleur :

function edit (string $id) {
  if (0 == $id) { //pour éditer une nouvelle île
    $referer = explode('/', Router::getReferer()); //présélection de l'archipel
    //var_dump($referer);
    $idArchipelago = 'archipelago' == $referer[1] && 3 <= count($referer) ? intval($referer[2]) : 0;
    $island = new Island(0, '', -1, -1, new Archipelago($idArchipelago, ''));
  } else { //pour éditer une île existante
    $island = ORM::getOne(Island::class, $id);
    if (null == $island) { //si l'île n'existe pas
      http_response_code(404);
      die('error: \'/island/' . $id . '\' not found');
    }
  }
  $html = new IslandView()->renderEdit($island, ORM::getAll(Archipelago::class)); //rendu du formulaire
  echo $html;
}

Code de la vue (extrait) :

<p>
  <label class="w3-bold" for="idArchipelago">Archipel :</label>
  <select class="w3-select" id="idArchipelago" name="idArchipelago">
    <?php foreach ($archipelagos as $archipelago): ?>
    <?php $selected = $archipelago->getId() == $island->getArchipelago()->getId() ? 'selected="selected"' : '';?>
    <option <?=$selected?> value="<?=$archipelago->getId()?>"><?=htmlspecialchars($archipelago->getName())?></option>
    <?php endforeach; ?>
  </select>
</p>

Attention, la valeur associée à la liste déroulante est l’id de l’archipel…

Réflexion - édition d’une île

Analyser le mécanisme de présélection de l’archipel :

  • décommenter le var_dump et noter ce qu’il affiche lors de l’ajout d’une île :
    • depuis la liste des îles,
    • depuis un archipel,
    • par saisie directe de l’URL dans la barre d’adresse ;
  • se documenter sur ce qu’est le “referer” dans le protocole HTTP ;
  • faire une trace du code du contrôleur et du routeur ;
  • expliquer pourquoi l’archipel associé à l’île n’a pas besoin d’être chargé par l’ORM (cf new Archipelago(…).

Sécurité

XSS

Les champs de saisie name sont vulnérables aux attaques de type XSS :

  • vérifier que c’est bien le cas en réussissant une attaque ;
  • corriger le code pour éliminer la faille de sécurité.

Détournement

  • Modifier dynamiquement le contenu du document (pour supprimer un attribut required par exemple) — cf outils de développements.

  • Utiliser l’utilitaire en ligne de commande curl et une interface graphique telle que Rester pour forger des requêtes HTTP valides et invalides ; exemple :

    curl -k -X POST -d “name=Test” https://web.sio.local/f.endr/ws/archipelago/0

L’option -k permet d’accepter les certificats SSL auto-signés.

Hameçonnage

Envoyer (à soi même) un courriel contenant un formulaire pour créer, modifier ou détruire une île…

Cela est par exemple possible avec Thunderbird en ajoutant une extension telle que HTML Source Editor.

Un bon client de messagerie, à la réception de ce message devrait afficher un avertissement de sécurité, et remplacer la méthode POST par GET.

Méthode HTTP

Rappeler pourquoi la méthode HTTP GET est “interdite” (du point de vue de la sécurité) pour tout changement d’état du système.

Approfondissement

  • Repérer le code redondant dans les contrôleurs et le factoriser.
  • Optimiser la gestion du cache pour n’effacer que le nécessaire…
  • Rendre les champs population et surface (area) de l’île facultatifs.
  • Ajouter un écran de confirmation des suppressions ; exemple : code pour remplacer le bouton de suppression d’un archipel :
<p class="w3-bar">
  <a class="w3-bar-item w3-button w3-indigo w3-right" href="<?=Router::getURL('/archipelago/' . $archipelago->getId() . '/edit')?>">&nbsp;Éditer&nbsp;</a>
  <?php if (count($archipelago->getIslands()) == 0): ?>
  <a class="w3-bar-item w3-button w3-red" href="<?=Router::getURL('/archipelago/' . $archipelago->getId() . '/delete')?>">&nbsp;Effacer&nbsp;</a>
  <?php endif; ?>
</p>