Aller au contenu principal

Un score calculé par le serveur

Écrire la fonction terminer_partie : le score et la durée officiels sont calculés et enregistrés par le serveur, puis le tutoriel est fusionné par une Pull Request.

Objectifs de la séance​

  • écrire une fonction SQL idempotente qui termine une partie ;
  • laisser le serveur calculer le score et mesurer la durée ;
  • transmettre à l'écran de résultat un simple identifiant de partie (validé), et non plus un score ;
  • comprendre pourquoi recharger la page ou appeler deux fois la fonction ne pose aucun problème ;
  • connaître les limites de cette protection ;
  • fusionner la branche feature/securite dans main avec une Pull Request.

Petite vérification​

Avant de commencer, vérifiez si vous savez...

  1. rejouer dans un terminal la commande curl qui demande select=question_id,texte,est_correcte sur la table reponse, et lire le code de l'erreur renvoyée
  2. dans le Table Editor de Supabase, ouvrir la table partie_question et repérer, pour une question à laquelle vous avez répondu, les trois colonnes renseignées
  3. citer le nom du script SQL qui a retiré le droit de lire la colonne est_correcte
📌 Réponse attendue

TP pour réfléchir et résoudre des problèmes​

Il reste une faille : le score affiché à la fin est calculé par l'application et transmis à l'écran de résultat dans l'adresse (/resultat?score=5&...), comme vous l'avez falsifié à la séance 30. Votre mission : le faire calculer par le serveur. Quand la partie se termine, l'application demandera à la fonction terminer_partie « combien ai-je de bonnes réponses, et en combien de temps ? ». Restez sur la branche feature/securite.

Étape 1 — Créer la fonction terminer_partie et verrouiller la partie​

Créez sql/08_terminer_partie.sql. La fonction reçoit l'identifiant d'une partie et commence par la sélectionner en la verrouillant, mais seulement si elle appartient au joueur connecté. Voici le début du fichier :

-- =====================================================================
-- 08_terminer_partie.sql : fin de partie, score et durée calculés par le serveur
-- =====================================================================
-- Renvoie : { "score": 4, "nb_questions": 5, "duree_secondes": 73 }
-- La fonction est "idempotente" : l'appeler 2 fois (double appui, écran
-- rechargé...) ne change rien, elle renvoie simplement le même résultat.
create or replace function public.terminer_partie(p_partie_id bigint)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_partie public.partie%rowtype;
begin
-- La partie doit appartenir au joueur connecté (verrouillée pendant le calcul)

Complétez la condition de sélection :

La ligne suivante de la fonction refuse de continuer si aucune partie n'a été trouvée :

if not found then
raise exception 'Partie introuvable';
end if;

Bonne pratique - Une seule réponse pour « inexistante » et « pas la vôtre »

Le message « Partie introuvable » est le même si la partie n'existe pas ou si elle appartient à un autre joueur. On n'apprend ainsi rien à un curieux qui tenterait des numéros de partie au hasard.

Étape 2 — Calculer le score​

Si la partie n'est pas encore terminée (terminee_le is null), la fonction l'enregistre : l'heure de fin, le score (le nombre de questions dont est_correcte est vrai) et la durée. Complétez le sous-calcul du score :

-- Première fin de partie : on calcule et on enregistre le score et la durée
if v_partie.terminee_le is null then
update public.partie
set terminee_le = now(),

Bonne pratique - Le score est une conséquence, pas une donnée reçue

Ce score est un count(*) sur les réponses déjà corrigées par le serveur dans la table partie_question. Aucun nombre venant de l'application n'intervient : il n'y a plus rien à falsifier.

Étape 3 — Mesurer la durée​

La durée est la différence entre l'heure de début de la partie (debut_le, enregistrée à la création) et maintenant. Elle est plafonnée à une heure (3600 secondes) : une partie laissée ouverte toute la nuit ne produit pas une durée absurde. Complétez la ligne :

La suite du update et la fin de la fonction :

where id = p_partie_id
returning * into v_partie;
end if;

return jsonb_build_object(
'score', v_partie.score,
'nb_questions', v_partie.nb_questions,
'duree_secondes', v_partie.duree_secondes
);
end;
$$;

Bonne pratique - Utiliser l'horloge du serveur

L'horloge d'un téléphone peut être réglée n'importe comment. now() est évalué par le serveur, qui est le seul à mesurer fiablement le temps. returning * into v_partie récupère la ligne après modification.

Étape 4 — Droits d'exécution, exécution et premiers tests​

Ajoutez à la fin du script les droits d'exécution : refusés aux visiteurs, accordés aux joueurs connectés. Complétez la dernière ligne :

Exécutez le script dans le SQL Editor, puis ajoutez la ligne suivante au tableau de sql/README.md :

| `08_terminer_partie.sql` | fonction `terminer_partie` : score et durée calculés par le serveur |

Premier test : dans le SQL Editor (aucune session de joueur), select public.terminer_partie(1); doit répondre « Partie introuvable ». Second test, en visiteur :

curl -X POST "https://VOTRE-PROJET.supabase.co/rest/v1/rpc/terminer_partie" \
-H "apikey: VOTRE_CLE_PUBLISHABLE" \
-H "Content-Type: application/json" \
-d '{"p_partie_id": 1}'

Réponse attendue : un refus « permission denied for function terminer_partie ».


Bonne pratique - Même règle pour toutes les fonctions

Chaque fonction security definer reçoit la même recette : security definer, set search_path = '', vérification de auth.uid(), validation des paramètres, puis revoke execute ... from public, anon et grant execute ... to authenticated.

Étape 5 — Décrire et vérifier le résultat côté application​

Dans src/types/quiz.ts, ajoutez à la fin du fichier le type de ce que renvoie la fonction :

// Ce que renvoie la fonction SQL terminer_partie (score calculé par le serveur)
export type ResultatPartie = {
score: number;
nbQuestions: number;
dureeSecondes: number;
};

Dans src/logic/conversions.ts, ajoutez ResultatPartie à l'import des types et la fonction de conversion (comme pour convertirCorrection, elle refuse un JSON mal formé). Complétez la dernière condition :

Mettez aussi à jour tests/conversions.test.ts (corrigé), puis lancez npm test.


Bonne pratique - Chaque réponse du serveur a sa conversion vérifiée

Le patron est toujours le même : unknown en entrée, Type | null en sortie, un test qui vérifie un cas valide et un cas invalide.

Étape 6 — L'appel dans le service​

Dans src/services/partieService.ts, ajoutez terminerPartie : même structure que demarrerPartie (session assurée, appel rpc, erreur traduite, conversion vérifiée). Complétez l'appel :

(N'oubliez pas d'ajouter convertirResultat à l'import depuis @/logic/conversions et ResultatPartie à l'import des types.)


Bonne pratique - Un service, une responsabilité

L'écran ne connaît ni supabase, ni les noms des paramètres SQL. Si la fonction change un jour, un seul fichier est à modifier.

Étape 7 — Le hook useTerminerPartie​

Créez src/hooks/useTerminerPartie.ts. C'est le même patron que useDemarrerPartie : état EtatChargement, AbortController, Réessayer. Complétez l'appel :


Bonne pratique - Un appel multiple est sans danger

En mode développement, React peut exécuter l'effet deux fois. Comme terminer_partie est idempotente (elle renvoie le même résultat), les deux appels donnent la même réponse et la partie n'est enregistrée qu'une fois.

Étape 8 — Valider l'identifiant de partie reçu dans l'adresse​

L'écran de résultat reçoit désormais /resultat?partieId=42&.... Cette valeur vient de l'adresse : elle peut avoir été modifiée à la main. Dans src/logic/parametresResultat.ts, remplacez le contenu du fichier par la fonction lireIdentifiantPartie (voir le corrigé). Complétez la ligne qui refuse une valeur invalide :

Remplacez tests/parametresResultat.test.ts par la version du corrigé et lancez npm test.


Bonne pratique - Liste blanche et limite de taille

L'expression régulière n'accepte que des chiffres, sans zéro initial, et 15 chiffres au maximum : cela évite les valeurs géantes (qui ne sont plus des entiers sûrs en JavaScript) et toute tentative d'injection. Même si la valeur est valide, c'est le serveur qui vérifie que la partie est la vôtre.

Étape 9 — Brancher l'écran de résultat​

Trois fichiers à modifier (voir le corrigé) :

  1. src/components/PartieEnCours.tsx : afficherLeResultat ne transmet plus score, total et secondes mais seulement partieId ;
  2. src/components/ResultatServeur.tsx (nouveau) : affiche « Calcul du score... » puis le résultat officiel renvoyé par le serveur, avec les boutons « Rejouer », « Nouveau quiz » et « Accueil » ;
  3. src/app/resultat.tsx : lit partieId avec lireIdentifiantPartie, valide les réglages avec lireConfigPartie, puis affiche ResultatServeur (ou un message d'erreur si l'adresse est invalide).

Complétez les paramètres envoyés à l'écran de résultat :


Bonne pratique - Transmettre un identifiant, pas une conclusion

Un identifiant de partie ne rapporte rien à un tricheur : le serveur ne renvoie que le résultat de sa propre partie. Un score dans l'adresse, lui, pouvait être inventé.

Étape 10 — Tester, puis tenter la triche​

  1. Jouez une partie complète : l'écran de résultat affiche « Calcul du score... » puis le score et le temps.
  2. Rechargez la page (F5) : le même score s'affiche. Dans le Table Editor, la table partie ne contient toujours qu'une ligne pour cette partie, avec terminee_le, score et duree_secondes renseignés.
  3. Essayez la falsification de la séance 30 : http://localhost:8081/resultat?score=5&total=5&secondes=1&theme=tous&niveau=tous&nombre=5 affiche « Résultat introuvable. » (il n'y a plus de partieId).
  4. Essayez .../resultat?partieId=1&theme=tous&niveau=tous&nombre=5 avec un numéro de partie qui n'est pas la vôtre : le serveur répond « Partie introuvable ».
  5. Tentez de modifier votre score directement avec la clé publique (remplacez les valeurs en majuscules, et 1 par le numéro de votre partie) :
curl -X PATCH "https://VOTRE-PROJET.supabase.co/rest/v1/partie?id=eq.1" \
-H "apikey: VOTRE_CLE_PUBLISHABLE" \
-H "Content-Type: application/json" \
-d '{"score": 99}'

Réponse attendue : un refus (visiteur sans droit sur la table partie). Même un joueur connecté échouerait : il n'a que le droit de lire ses parties.


Bonne pratique - Rejouer ses propres attaques

Vous avez maintenant essayé de tricher de quatre façons, et toutes échouent. Garder cette petite liste d'attaques dans un fichier de recette permet de la rejouer après chaque modification de la base.

Étape 11 — Enregistrer et publier la branche​

git add -A
git status
git commit -m "Score et durée calculés par le serveur (terminer_partie)"
git push

Bonne pratique - Pousser avant la Pull Request

Une Pull Request compare deux branches sur GitHub : tant que vos commits ne sont pas poussés, GitHub ne les voit pas.

Étape 12 — Pull Request et fusion dans main​

Le tutoriel 8 est terminé : il faut fusionner la branche feature/securite dans main.

  1. Sur GitHub, ouvrez votre dépôt quiz-cyber-expo : un bandeau propose Compare & pull request pour feature/securite ;
  2. choisissez la base main, donnez un titre (par exemple « Joueurs et sécurité (tutoriel 8) ») et décrivez en deux lignes ce que contient la branche (connexion anonyme, pseudo, fonctions SQL, score serveur) ;
  3. relisez l'onglet Files changed, puis Merge pull request.

Revenez ensuite sur votre ordinateur et mettez à jour main :

git switch main
git pull

Bonne pratique - Mettre à jour main après la fusion

La fusion a eu lieu sur GitHub : votre main local n'a pas bougé tant que vous n'avez pas fait git pull. Vous pouvez ensuite supprimer la branche devenue inutile avec git branch -d feature/securite.

📌 Une solution

Notions théoriques​

Terminer une partie : ce que fait le serveur​

Application Serveur
| rpc("terminer_partie", partieId) ---> vérifie que la partie est la vôtre (auth.uid())
| verrouille la ligne (for update)
| si pas encore terminée : score + durée + heure de fin
| <--- { score, nb_questions, duree_secondes }

Le score est count(*) des lignes de partie_question dont est_correcte est vrai, c'est-à-dire des réponses que le serveur a lui-même corrigées. La durée est mesurée avec l'horloge du serveur : extract(epoch from now() - debut_le) donne un nombre de secondes, ::integer le convertit en entier, least(..., 3600) le plafonne à une heure.

L'idempotence​

Une opération est idempotente quand la répéter donne le même résultat qu'une seule exécution. Pour terminer_partie :

  • le premier appel remplit terminee_le, score et duree_secondes ;
  • les appels suivants voient terminee_le renseigné, ne modifient plus rien et renvoient les valeurs déjà enregistrées ;
  • grâce à for update, deux appels simultanés sont traités l'un après l'autre : le second trouve déjà la partie terminée.

C'est ce qui rend sans danger : un double clic, un rechargement de la page (F5), un effet exécuté deux fois en mode développement, une requête renvoyée après une coupure réseau.

Le chemin d'une donnée de confiance​

InformationAvantMaintenant
Questions tiréestout le catalogue téléchargétirage au hasard par demarrer_partie
Correctioncomparée par l'applicationcalculée par repondre
Scorecompté par l'application, dans l'adresseterminer_partie
Duréechronomètre de l'applicationhorloge du serveur
Identifiant du joueur(aucun)auth.uid(), jamais un paramètre

Le chronomètre de l'écran continue d'exister, mais uniquement pour l'affichage pendant la partie. Le temps officiel est celui du serveur.

Valider un paramètre d'adresse​

Une adresse peut être tapée ou modifiée par n'importe qui. lireIdentifiantPartie applique la règle de la liste blanche : on décrit exactement ce qui est accepté (un entier positif de 1 à 15 chiffres, sans zéro initial) et on refuse tout le reste en renvoyant null. Cette validation évite les erreurs et les valeurs absurdes, mais elle ne remplace pas le contrôle du serveur : un identifiant valide d'une partie qui n'est pas la vôtre est refusé par la fonction SQL.

Les limites de ce système​

Aucune protection n'est parfaite. Soyons honnêtes sur ce que ce quiz ne garantit pas :

  • un joueur peut apprendre les bonnes réponses en rejouant (la correction est affichée après chaque réponse) : pour un quiz pédagogique, c'est même le but ; mais un robot pourrait le faire très vite pour obtenir un score parfait en un temps très court ;
  • la limite de 10 parties par minute ralentit cette automatisation, sans l'empêcher ;
  • des protections plus fortes existent (durée minimale plausible par question, CAPTCHA, comptes vérifiés, détection d'anomalies) mais dépassent le cadre de ce cours ;
  • l'objectif de la sécurité n'est pas « zéro risque » mais de rendre l'attaque plus coûteuse que son intérêt. Ici, tricher demande de la persévérance pour gagner... un rang dans un classement de quiz.

Bilan du tutoriel : les couches de protection​

MenaceProtection mise en place
Lire les bonnes réponsesdroit de lecture retiré sur est_correcte (par colonne)
Falsifier le score ou la duréecalculés par terminer_partie ; tables fermées à l'écriture
Usurper un autre joueurauth.uid() partout, jamais de paramètre d'identité
Répondre deux fois, double clicfor update, première réponse conservée, verrou côté application
Abus (milliers de parties)limite de 10 parties par minute
Saisies invalidescheck en base, validation côté application
Paramètres d'adresse falsifiésliste blanche (lireConfigPartie, lireIdentifiantPartie)

Test de mémorisation/compréhension​


Que signifie qu'une fonction comme terminer_partie est « idempotente » ?


Pourquoi le serveur mesure-t-il lui-même la durée de la partie ?


Que se passe-t-il si le joueur recharge la page de résultat (F5) ?


Que fait la fonction lireIdentifiantPartie avec le paramètre partieId reçu dans l'adresse ?


Pourquoi un joueur ne peut-il pas modifier son score avec une requête PATCH sur la table partie ?


Quelle limite subsiste malgré ce score calculé par le serveur ?



Exercices complémentaires​

Exercice A : tester l'idempotence​

Dans une partie terminée, appelez deux fois de suite l'écran de résultat (rechargez la page plusieurs fois). Comparez terminee_le et duree_secondes dans la table partie avant et après : sont-elles identiques ? Pourquoi ?

Exercice B : écrire la recette de sécurité​

Rédigez dans un fichier texte votre liste de cinq attaques à rejouer après chaque modification de la base (lecture de est_correcte, score dans l'adresse, PATCH du score, partie d'un autre joueur, appel d'une fonction sans session), avec le résultat attendu pour chacune.


Ce qu'il faut retenir​

NotionRésumé
Score serveurterminer_partie compte les réponses corrigées par le serveur et mesure la durée avec son horloge.
IdempotenceAppeler deux fois la fonction ne change rien ; for update sérialise les appels simultanés.
Identifiant dans l'adressepartieId validé par liste blanche, puis vérifié par le serveur (partie du joueur connecté).
Tables ferméesAucun droit d'écriture sur partie : impossible de modifier son score avec une requête directe.
LimitesUn robot peut apprendre les réponses en rejouant ; on rend la triche coûteuse, pas impossible.
Pull RequestFin de tutoriel : push, Pull Request, fusion dans main, puis git switch main et git pull.

Aperçu de la prochaine séance​

Votre quiz est maintenant sûr : les réponses sont cachées, les scores sont fiables. Dans le tutoriel suivant, vous allez construire le classement : une fonction SQL publique qui renvoie les meilleurs scores, sans jamais révéler l'identité technique des joueurs, puis un écran qui l'affiche avec une liste performante.