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/securitedansmainavec une Pull Request.
Petite vérification
Avant de commencer, vérifiez si vous savez...
- rejouer dans un terminal la commande
curlqui demandeselect=question_id,texte,est_correctesur la tablereponse, et lire le code de l'erreur renvoyée - dans le Table Editor de Supabase, ouvrir la table
partie_questionet repérer, pour une question à laquelle vous avez répondu, les trois colonnes renseignées - 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;
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(),
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;
$$;
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 ».
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.
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.)
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 :
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.
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é) :
src/components/PartieEnCours.tsx:afficherLeResultatne transmet plusscore,totaletsecondesmais seulementpartieId;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 » ;src/app/resultat.tsx: litpartieIdaveclireIdentifiantPartie, valide les réglages aveclireConfigPartie, puis afficheResultatServeur(ou un message d'erreur si l'adresse est invalide).
Complétez les paramètres envoyés à l'écran de résultat :
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
- Jouez une partie complète : l'écran de résultat affiche « Calcul du score... » puis le score et le temps.
- Rechargez la page (
F5) : le même score s'affiche. Dans le Table Editor, la tablepartiene contient toujours qu'une ligne pour cette partie, avecterminee_le,scoreetduree_secondesrenseignés. - Essayez la falsification de la séance 30 :
http://localhost:8081/resultat?score=5&total=5&secondes=1&theme=tous&niveau=tous&nombre=5affiche « Résultat introuvable. » (il n'y a plus departieId). - Essayez
.../resultat?partieId=1&theme=tous&niveau=tous&nombre=5avec un numéro de partie qui n'est pas la vôtre : le serveur répond « Partie introuvable ». - Tentez de modifier votre score directement avec la clé publique (remplacez les valeurs en majuscules, et
1par 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.
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
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.
- Sur GitHub, ouvrez votre dépôt
quiz-cyber-expo: un bandeau propose Compare & pull request pourfeature/securite; - 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) ; - relisez l'onglet Files changed, puis Merge pull request.
Revenez ensuite sur votre ordinateur et mettez à jour main :
git switch main
git pull
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,scoreetduree_secondes; - les appels suivants voient
terminee_lerenseigné, 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
| Information | Avant | Maintenant |
|---|---|---|
| Questions tirées | tout le catalogue téléchargé | tirage au hasard par demarrer_partie |
| Correction | comparée par l'application | calculée par repondre |
| Score | compté par l'application, dans l'adresse | terminer_partie |
| Durée | chronomètre de l'application | horloge 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
| Menace | Protection mise en place |
|---|---|
| Lire les bonnes réponses | droit de lecture retiré sur est_correcte (par colonne) |
| Falsifier le score ou la durée | calculés par terminer_partie ; tables fermées à l'écriture |
| Usurper un autre joueur | auth.uid() partout, jamais de paramètre d'identité |
| Répondre deux fois, double clic | for update, première réponse conservée, verrou côté application |
| Abus (milliers de parties) | limite de 10 parties par minute |
| Saisies invalides | check en base, validation côté application |
| Paramètres d'adresse falsifiés | liste blanche (lireConfigPartie, lireIdentifiantPartie) |
Test de mémorisation/compréhension
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
| Notion | Résumé |
|---|---|
| Score serveur | terminer_partie compte les réponses corrigées par le serveur et mesure la durée avec son horloge. |
| Idempotence | Appeler deux fois la fonction ne change rien ; for update sérialise les appels simultanés. |
| Identifiant dans l'adresse | partieId validé par liste blanche, puis vérifié par le serveur (partie du joueur connecté). |
| Tables fermées | Aucun droit d'écriture sur partie : impossible de modifier son score avec une requête directe. |
| Limites | Un robot peut apprendre les réponses en rejouant ; on rend la triche coûteuse, pas impossible. |
| Pull Request | Fin 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.