Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 1524 minIntermédiaire

Refresh token, idempotence et notifications : l'API Node.js d'une application mobile

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • stocker un jeton de rafraîchissement et le faire tourner à chaque usage ;
  • révoquer une session quand un jeton volé est rejoué ;
  • rendre un POST rejouable avec une clé d'idempotence ;
  • enregistrer les appareils d'un utilisateur pour les notifications push.

4 exercices corrigés

Une application mobile demande à son API plus qu'un site web : garder l'utilisateur connecté des semaines sans stocker son mot de passe, accepter qu'une même requête arrive deux fois après une coupure de réseau, et savoir à quels téléphones envoyer les notifications. Ce chapitre écrit, en Node.js avec Express 5, les routes serveur qui rendent cela possible : le jeton de rafraîchissement (refresh token) avec rotation et révocation, la clé d'idempotence et l'enregistrement des appareils.

Ce que l'application mobile attend de l'API

Le cours Node.js construit l'API d'AppliFrais : les routes des fiches, la base MySQL avec mysql2, la validation avec Zod, la connexion par JWT et le téléversement des justificatifs. Le client mobile du chapitre 6 lui demande quatre choses de plus :

  • un jeton de rafraîchissement à la connexion, et sa rotation (POST /v1/auth/rafraichissement) ;
  • une déconnexion côté serveur, qui révoque ce jeton (POST /v1/auth/deconnexion) ;
  • la clé d'idempotence sur la création d'un frais, pour la file d'attente hors ligne ;
  • l'enregistrement des appareils pour les notifications push (PUT /v1/appareils).

Voici le code à ajouter, dans la même organisation que le cours Node.js (Express 5, modules ES, dossiers routes, controllers, services, middlewares, classe ErreurApi). C'est contre cette API qu'a tourné le script du chapitre 6 qui teste le client sans téléphone.

Les tables à ajouter

Trois tables, liées à la table utilisateur du cours Node.js :

sql
-- sql/mobile.sql : les tables qu'ajoute l'application mobile (MySQL 8.4)
CREATE TABLE jeton_rafraichissement (
  id             INT AUTO_INCREMENT PRIMARY KEY,
  id_utilisateur INT      NOT NULL,
  famille        CHAR(36) NOT NULL,              -- la même pour tous les jetons nés d'une connexion
  empreinte      CHAR(64) NOT NULL UNIQUE,       -- SHA-256 du jeton, jamais le jeton lui-même
  expire_le      DATETIME NOT NULL,              -- en UTC
  utilise_le     DATETIME NULL,                  -- rempli à la rotation : un jeton ne sert qu'une fois
  revoque        BOOLEAN  NOT NULL DEFAULT FALSE,
  INDEX idx_famille (famille),
  CONSTRAINT fk_jeton_utilisateur FOREIGN KEY (id_utilisateur) REFERENCES utilisateur (id) ON DELETE CASCADE
) ENGINE = InnoDB;

CREATE TABLE cle_idempotence (
  id_utilisateur INT         NOT NULL,
  cle            VARCHAR(64) NOT NULL,
  empreinte      CHAR(64)    NOT NULL,           -- SHA-256 de la requête : même clé, même contenu
  statut         SMALLINT    NULL,               -- NULL tant que la première requête est en cours
  reponse        TEXT        NULL,               -- le corps JSON renvoyé la première fois
  cree_le        DATETIME    NOT NULL,           -- en UTC ; une clé vaut 24 heures
  PRIMARY KEY (id_utilisateur, cle),
  CONSTRAINT fk_cle_utilisateur FOREIGN KEY (id_utilisateur) REFERENCES utilisateur (id) ON DELETE CASCADE
) ENGINE = InnoDB;

CREATE TABLE appareil (
  jeton_push     VARCHAR(255) PRIMARY KEY,       -- ExponentPushToken[…]
  id_utilisateur INT          NOT NULL,
  plateforme     ENUM('android', 'ios') NOT NULL,
  maj_le         DATETIME     NOT NULL,
  CONSTRAINT fk_appareil_utilisateur FOREIGN KEY (id_utilisateur) REFERENCES utilisateur (id) ON DELETE CASCADE
) ENGINE = InnoDB;
bash
mysql -u root -p applifrais < sql/mobile.sql

Les clés étrangères en ON DELETE CASCADE permettent au script sql/donnees.sql du cours Node.js de continuer à vider la table utilisateur : les jetons, clés et appareils partent avec elle.

Ces tables grossissent avec le temps : les jetons expirés et les clés de plus de 24 heures ne servent plus. Une tâche planifiée les purge, par exemple chaque nuit, avec DELETE FROM jeton_rafraichissement WHERE expire_le < ? et DELETE FROM cle_idempotence WHERE cree_le < ?, la date limite étant calculée par le code.

Le refresh token : rotation et révocation

Le jeton de rafraîchissement n'est pas un JWT : c'est une suite de 32 octets aléatoires, que l'API retrouve en base. C'est ce qui permet de le révoquer, ce qu'un JWT autoporteur ne permet pas. La base n'en garde que l'empreinte SHA-256, comme pour un mot de passe : si la table fuit, les empreintes ne permettent pas de se connecter. Un simple SHA-256 suffit ici, sans argon2, parce que le jeton est aléatoire sur 256 bits : il n'y a rien à deviner dans un dictionnaire.

js
// src/services/jetons.service.js
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import jwt from 'jsonwebtoken';
import { config } from '../config.js';
import { pool } from '../db.js';
import { ErreurApi } from '../erreurs.js';

export const DUREE_ACCES = 15 * 60; // secondes
const DUREE_RAFRAICHISSEMENT_MS = 30 * 24 * 60 * 60 * 1000; // 30 jours

const empreinte = (jeton) => createHash('sha256').update(jeton).digest('hex');
// Date au format DATETIME de MySQL, en UTC : '2026-11-02 08:15:00'
export const dateSql = (ms = Date.now()) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
const sessionExpiree = () => new ErreurApi(401, 'session-expiree', 'Session expirée', 'Reconnecte-toi.');

export function signerAcces(utilisateur) {
  return jwt.sign({ role: utilisateur.role }, config.jwtSecret, {
    subject: String(utilisateur.id),
    expiresIn: DUREE_ACCES,
    algorithm: 'HS256',
  });
}

// Un jeton aléatoire de 256 bits ; la base n'en garde que l'empreinte SHA-256.
export async function creerRafraichissement(idUtilisateur, famille = randomUUID()) {
  const jeton = randomBytes(32).toString('base64url');
  await pool.execute(
    'INSERT INTO jeton_rafraichissement (id_utilisateur, famille, empreinte, expire_le) VALUES (?, ?, ?, ?)',
    [idUtilisateur, famille, empreinte(jeton), dateSql(Date.now() + DUREE_RAFRAICHISSEMENT_MS)],
  );
  return jeton;
}

// Rotation : le jeton présenté est consommé, une nouvelle paire est émise dans la même famille.
export async function tourner(jeton) {
  const [[ligne]] = await pool.execute(
    'SELECT id, id_utilisateur, famille, expire_le, revoque FROM jeton_rafraichissement WHERE empreinte = ?',
    [empreinte(jeton)],
  );
  if (!ligne || ligne.revoque || ligne.expire_le < dateSql()) throw sessionExpiree();

  // « AND utilise_le IS NULL » : un jeton ne sert qu'une fois, même si deux requêtes arrivent ensemble.
  const [resultat] = await pool.execute(
    'UPDATE jeton_rafraichissement SET utilise_le = ? WHERE id = ? AND utilise_le IS NULL',
    [dateSql(), ligne.id],
  );
  if (resultat.affectedRows === 0) {
    // Jeton déjà utilisé : il a peut-être été volé. Toute la session (la famille) est révoquée.
    await pool.execute('UPDATE jeton_rafraichissement SET revoque = TRUE WHERE famille = ?', [ligne.famille]);
    throw new ErreurApi(401, 'session-revoquee', 'Session révoquée', 'Jeton déjà utilisé : reconnecte-toi.');
  }

  const [[utilisateur]] = await pool.execute('SELECT id, role FROM utilisateur WHERE id = ?', [ligne.id_utilisateur]);
  return {
    accessToken: signerAcces(utilisateur),
    refreshToken: await creerRafraichissement(utilisateur.id, ligne.famille),
  };
}

// Déconnexion : toute la famille du jeton est révoquée ; un jeton inconnu ne change rien.
export async function revoquer(jeton) {
  const [[ligne]] = await pool.execute(
    'SELECT famille FROM jeton_rafraichissement WHERE empreinte = ?',
    [empreinte(jeton)],
  );
  if (ligne) await pool.execute('UPDATE jeton_rafraichissement SET revoque = TRUE WHERE famille = ?', [ligne.famille]);
}

Les idées à retenir :

  • La famille. Chaque connexion crée une famille (un UUID) ; chaque rotation crée un nouveau jeton dans la même famille et marque l'ancien comme utilisé. Si un jeton déjà utilisé revient, deux personnes ont le même jeton : Lucas et peut-être un voleur. L'API ne sait pas lequel est légitime, alors elle révoque toute la famille, et seul celui qui connaît le mot de passe pourra se reconnecter. C'est la détection de réutilisation décrite dans le cours API REST.
  • La condition dans le UPDATE. Lire utilise_le puis l'écrire en deux temps laisserait passer deux requêtes simultanées. WHERE … AND utilise_le IS NULL fait le test et l'écriture en une seule instruction : la seconde requête modifie 0 ligne, et affectedRows le dit.
  • Les dates en UTC, calculées par JavaScript au format AAAA-MM-JJ hh:mm:ss. Dans ce format, l'ordre alphabétique des chaînes est l'ordre chronologique : expire_le < dateSql() compare donc bien deux dates.
  • Le prix de la rotation. Si la réponse d'un rafraîchissement se perd (le train entre dans un tunnel), le téléphone garde l'ancien jeton, déjà utilisé : au rafraîchissement suivant, la famille est révoquée et Lucas doit se reconnecter. C'est rare et sans perte de données (les frais attendent dans la file) ; certaines API tolèrent l'ancien jeton quelques secondes pour l'éviter.

Les routes de connexion, de rafraîchissement et de déconnexion

La connexion du cours Node.js change sur deux lignes : elle signe le jeton d'accès avec signerAcces et crée un jeton de rafraîchissement. Les deux nouvelles routes sont courtes :

js
// src/controllers/auth.controller.js (extraits)
import { creerRafraichissement, DUREE_ACCES, revoquer, signerAcces, tourner } from '../services/jetons.service.js';

export async function connexion(req, res) {
  // … vérification du login et du mot de passe, inchangée …
  const accessToken = signerAcces(utilisateur);
  const refreshToken = await creerRafraichissement(utilisateur.id); // une nouvelle famille
  req.log.info({ utilisateurId: utilisateur.id }, 'Connexion réussie');
  res.set('Cache-Control', 'no-store').json({ accessToken, tokenType: 'Bearer', expiresIn: DUREE_ACCES, refreshToken });
}

export async function rafraichissement(req, res) {
  const { accessToken, refreshToken } = await tourner(req.valide.body.refreshToken);
  res.set('Cache-Control', 'no-store').json({ accessToken, tokenType: 'Bearer', expiresIn: DUREE_ACCES, refreshToken });
}

export async function deconnexion(req, res) {
  await revoquer(req.valide.body.refreshToken);
  res.sendStatus(204); // même réponse pour un jeton inconnu : il n'y a rien à apprendre ici
}
js
// src/schemas/fiches.schemas.js (ajout)
export const schemaJetonRafraichissement = z.strictObject({
  refreshToken: z.string().min(20).max(100),
});

// src/routes/auth.routes.js (ajout)
routeurAuth.post('/rafraichissement', valider({ body: schemaJetonRafraichissement }), rafraichissement);
routeurAuth.post('/deconnexion', valider({ body: schemaJetonRafraichissement }), deconnexion);

Ces deux routes ne passent pas par authentifier : le jeton d'accès est justement expiré quand on rafraîchit, et c'est le jeton de rafraîchissement qui prouve la session. Elles n'ont pas besoin de la limitation de débit de la connexion : un jeton aléatoire de 256 bits ne se devine pas.

La clé d'idempotence côté serveur

Une requête est idempotente quand la rejouer ne change rien de plus que la première fois. PUT et DELETE le sont par nature ; POST, qui crée, ne l'est pas : deux envois créent deux frais. La clé d'idempotence corrige cela. Le client envoie un en-tête Idempotency-Key avec chaque création de frais. Le middleware idempotence retient, pour chaque visiteur et chaque clé, l'empreinte de la requête et la réponse envoyée :

js
// src/middlewares/idempotence.js
import { createHash } from 'node:crypto';
import { pool } from '../db.js';
import { ErreurApi } from '../erreurs.js';
import { dateSql } from '../services/jetons.service.js';

const DUREE_CLE_MS = 24 * 60 * 60 * 1000; // une clé vaut 24 heures

// Même clé et même requête : la réponse d'origine est rejouée, rien n'est créé une deuxième fois.
// À placer après authentifier et valider.
export async function idempotence(req, res, next) {
  const cle = req.get('Idempotency-Key');
  if (cle === undefined) return next(); // l'en-tête est facultatif
  if (!/^[\w-]{8,64}$/.test(cle)) {
    throw new ErreurApi(400, 'cle-idempotence-invalide', "Clé d'idempotence invalide",
      'De 8 à 64 caractères : lettres, chiffres, tirets.');
  }
  const idUtilisateur = req.utilisateur.id;
  const empreinte = createHash('sha256')
    .update(`${req.method} ${req.originalUrl} ${JSON.stringify(req.valide.body)}`)
    .digest('hex');

  await pool.execute(
    'DELETE FROM cle_idempotence WHERE id_utilisateur = ? AND cle = ? AND cree_le < ?',
    [idUtilisateur, cle, dateSql(Date.now() - DUREE_CLE_MS)],
  );
  try {
    await pool.execute(
      'INSERT INTO cle_idempotence (id_utilisateur, cle, empreinte, cree_le) VALUES (?, ?, ?, ?)',
      [idUtilisateur, cle, empreinte, dateSql()],
    );
  } catch (erreur) {
    if (erreur.code !== 'ER_DUP_ENTRY') throw erreur;
    // La clé est connue : c'est un renvoi.
    const [[connue]] = await pool.execute(
      'SELECT empreinte, statut, reponse FROM cle_idempotence WHERE id_utilisateur = ? AND cle = ?',
      [idUtilisateur, cle],
    );
    if (connue.empreinte !== empreinte) {
      throw new ErreurApi(422, 'cle-deja-utilisee', 'Clé déjà utilisée', 'Cette clé a servi pour une autre requête.');
    }
    if (connue.statut === null) {
      throw new ErreurApi(409, 'requete-en-cours', 'Requête en cours', 'La même requête est encore en traitement.');
    }
    return res.status(connue.statut).json(JSON.parse(connue.reponse));
  }

  // Première fois : la route travaille ; sa réponse est enregistrée avant de partir.
  const envoyerJson = res.json.bind(res);
  res.json = async (corps) => {
    try {
      if (res.statusCode < 300) {
        await pool.execute(
          'UPDATE cle_idempotence SET statut = ?, reponse = ? WHERE id_utilisateur = ? AND cle = ?',
          [res.statusCode, JSON.stringify(corps), idUtilisateur, cle],
        );
      } else {
        // Refus (404, 409, 422…) : la clé est libérée, le client pourra renvoyer une requête corrigée.
        await pool.execute('DELETE FROM cle_idempotence WHERE id_utilisateur = ? AND cle = ?', [idUtilisateur, cle]);
      }
    } catch (erreur) {
      req.log.error(erreur, "Clé d'idempotence non enregistrée");
    }
    return envoyerJson(corps);
  };
  next();
}
js
// src/routes/fiches.routes.js : le middleware se place après la validation
routeurFiches.post('/:id/frais-hors-forfait', exigerRole('visiteur'),
  valider({ params: schemaId, body: schemaFraisHorsForfait }), idempotence, fiches.ajouterFraisHorsForfait);

Quatre cas se présentent :

SituationRéponse de l'API
Clé inconnue (ou de plus de 24 heures)La route crée le frais ; la réponse 201 est enregistrée avec la clé
Même clé, même requête, déjà traitéeLa réponse d'origine est rejouée (201 et le même frais), sans nouvelle insertion
Même clé, requête différente422 : une clé identifie une requête ; la réutiliser pour un autre frais est une erreur du client
Même clé, première requête pas encore finie409 de type requete-en-cours : la file d'attente du chapitre 7 garde le frais et réessaiera
  • La clé appartient à un visiteur (clé primaire id_utilisateur, cle) : un autre visiteur qui enverrait la même clé ne recevrait jamais le frais de Lucas.
  • L'empreinte est calculée sur les données validées (req.valide.body), dans l'ordre des champs du schéma Zod : le même frais donne toujours la même empreinte, que l'envoi vienne du formulaire ou de la file d'attente.
  • Une requête refusée libère sa clé. Après un 422, Lucas corrige le formulaire et renvoie avec la même clé : c'est une nouvelle requête légitime.
  • L'insertion de la clé avant le travail règle le cas de deux envois simultanés : la clé primaire n'accepte qu'une ligne, le second envoi reçoit ER_DUP_ENTRY et voit la requête en cours.

Les appareils pour les notifications

L'application envoie le jeton push de l'appareil avec PUT /v1/appareils (chapitre 9). L'API le range dans la table appareil, lié au visiteur connecté.

js
// src/schemas/fiches.schemas.js (ajout)
export const schemaAppareil = z.strictObject({
  jetonPush: z.string().max(255).regex(/^Expo(nent)?PushToken\[.+\]$/, 'Jeton Expo attendu'),
  plateforme: z.enum(['android', 'ios']),
});
js
// src/routes/appareils.routes.js
import { Router } from 'express';
import { pool } from '../db.js';
import { authentifier } from '../middlewares/authentifier.js';
import { exigerRole } from '../middlewares/autoriser.js';
import { valider } from '../middlewares/valider.js';
import { schemaAppareil } from '../schemas/fiches.schemas.js';
import { dateSql } from '../services/jetons.service.js';

export const routeurAppareils = Router();

routeurAppareils.use(authentifier, exigerRole('visiteur'));

// PUT : enregistrer deux fois le même jeton ne crée pas deux lignes.
routeurAppareils.put('/', valider({ body: schemaAppareil }), async (req, res) => {
  const { jetonPush, plateforme } = req.valide.body;
  // Le visiteur vient du jeton d'accès, jamais du corps. REPLACE : un téléphone prêté change de propriétaire.
  await pool.execute(
    'REPLACE INTO appareil (jeton_push, id_utilisateur, plateforme, maj_le) VALUES (?, ?, ?, ?)',
    [jetonPush, req.utilisateur.id, plateforme, dateSql()],
  );
  res.sendStatus(204);
});
js
// src/app.js (ajout, avec les autres routeurs)
app.use('/v1/appareils', routeurAppareils);

REPLACE INTO remplace la ligne qui a la même clé primaire, ou l'insère si elle n'existe pas : si Sarah se connecte sur le téléphone que Lucas utilisait, les notifications iront à Sarah. Pour rester court, la requête SQL est écrite dans la route ; dans ton projet, range-la dans un dépôt appareils.depot.js, comme les autres. L'envoi des notifications par l'API est présenté au chapitre 9.

Exercice 1 sur 4 Le jeton rejoué Réussi À revoir

Lucas se connecte : l'API lui remet le jeton de rafraîchissement R1, dans la famille F. Plus tard, l'application échange R1 contre R2. Un attaquant qui avait copié R1 le présente alors à POST /v1/auth/rafraichissement.

  1. Que répond l'API à l'attaquant, et que devient R2 ?
  2. Que voit Lucas à son prochain rafraîchissement ?
  3. La veille, Lucas s'était aussi connecté sur sa tablette (famille G). Est-elle touchée ?
Voir la réponseMasquer la réponse de l'exercice 1
  1. R1 existe, n'est ni expiré ni révoqué, mais son utilise_le est déjà rempli : le UPDATE … AND utilise_le IS NULL modifie 0 ligne. L'API révoque toute la famille F, donc R2 aussi, et répond 401 de type session-revoquee.
  2. Son rafraîchissement avec R2 reçoit 401 de type session-expiree (R2 est révoqué). Le client appelle surDeconnexion et affiche l'écran de connexion : Lucas tape son mot de passe, ce que l'attaquant ne peut pas faire.
  3. Non : la révocation porte sur la famille F seulement. La session de la tablette, dans la famille G, continue de fonctionner.

Tu avais trouvé ?

Exercice 2 sur 4 Quelle réponse pour quelle clé ? Réussi À revoir

Lucas est connecté. Donne le code de statut de chaque requête, envoyée dans cet ordre, et dis si un frais est créé. Le corps B vaut { "date": "2026-10-20", "libelle": "Parking gare", "montant": 12 }.

  1. POST /v1/fiches/12/frais-hors-forfait, clé cle-aaaa-1, corps B.
  2. La même requête, rejouée par la file d'attente.
  3. Clé cle-aaaa-1, corps B avec "montant": 13.
  4. POST /v1/fiches/11/frais-hors-forfait (fiche validée), clé cle-bbbb-2, corps B.
  5. POST /v1/fiches/12/frais-hors-forfait, clé cle-bbbb-2, corps B.
  6. Clé abc, corps B.
Voir la réponseMasquer la réponse de l'exercice 2
  1. 201 : le frais est créé (le n° 60 avec le jeu de données du cours Node.js), et la réponse est enregistrée avec la clé.
  2. 201 avec le même frais n° 60 : la réponse d'origine est rejouée, rien n'est inséré.
  3. 422 de type cle-deja-utilisee : même clé, mais l'empreinte du corps a changé.
  4. 409 de type fiche-cloturee (la fiche n'est pas en cours). Comme la réponse est un refus, la clé cle-bbbb-2 est supprimée.
  5. 201 : la clé cle-bbbb-2 est de nouveau libre, un nouveau frais (n° 61) est créé.
  6. 400 de type cle-idempotence-invalide : la clé fait moins de 8 caractères. Rien n'est créé.

La fiche 12 compte alors trois frais hors forfait : le n° 58 du jeu de données, et les n° 60 et 61.

Tu avais trouvé ?

Exercice 3 sur 4 Une empreinte plutôt que le jeton Réussi À revoir

  1. Un attaquant obtient une copie de la table jeton_rafraichissement. Que pourrait-il faire si la colonne contenait les jetons eux-mêmes ? Et avec les empreintes SHA-256 ?
  2. Pour les mots de passe, le cours Node.js utilise argon2, lent et salé. Pourquoi ne pas l'utiliser ici aussi ?
Voir la réponseMasquer la réponse de l'exercice 3
  1. Avec les jetons en clair, il pourrait appeler POST /v1/auth/rafraichissement avec chacun d'eux et obtenir un jeton d'accès au nom de chaque utilisateur, pendant 30 jours. Avec les empreintes, il ne peut rien faire : retrouver un jeton aléatoire de 256 bits à partir de son SHA-256 est hors de portée.
  2. argon2 sert à ralentir l'attaque par dictionnaire sur un mot de passe choisi par un humain, donc devinable. Un jeton de 256 bits aléatoires ne se devine pas : la lenteur n'apporte rien. Et le sel d'argon2 empêcherait de retrouver le jeton avec WHERE empreinte = ? : il faudrait tester chaque ligne de la table. SHA-256, sans sel, donne toujours la même empreinte pour le même jeton, ce qui permet la recherche par l'index UNIQUE.

Tu avais trouvé ?

Exercice 4 sur 4 Notifier le visiteur Réussi À revoir

Quand le comptable valide la fiche 11, l'API doit envoyer une notification à tous les appareils du visiteur de cette fiche. Écris la requête SQL qui récupère leurs jetons push, puis celle qui supprime un jeton quand le service d'Expo répond DeviceNotRegistered.

Voir la réponseMasquer la réponse de l'exercice 4
sql
-- Les jetons des appareils du visiteur de la fiche
SELECT a.jeton_push
  FROM appareil a
  JOIN fiche_frais f ON f.id_visiteur = a.id_utilisateur
 WHERE f.id = ?;

-- Un appareil désinstallé ou qui a coupé les notifications
DELETE FROM appareil WHERE jeton_push = ?;

Les deux requêtes sont préparées : l'identifiant de la fiche et le jeton passent en paramètres, par exemple pool.execute(sql, [11]). La première renvoie la liste à passer à notifierValidation (chapitre 9).

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Cours suivant : RustRevoir le sommaire du cours