Aller au contenu principal

Choisir un pseudo : formulaire et validation

Créer la table joueur, protégée par des droits par colonne et la RLS, et le formulaire qui permet de choisir son pseudo.

Objectifs de la séance​

  • créer la table joueur liée à l'utilisateur Supabase Auth ;
  • écrire des règles RLS « chacun ne voit et ne modifie que sa propre fiche » avec auth.uid() ;
  • accorder des droits colonne par colonne (grant insert (...), grant update (...)) ;
  • écrire un formulaire avec TextInput, validé côté application et côté base ;
  • empêcher le double envoi avec un useRef et traiter les erreurs 23505 et 23514.

Petite vérification​

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

  1. lancer l'application dans le navigateur, lire les 8 caractères après « Joueur anonyme n° », puis recharger la page (F5) et vérifier qu'ils sont identiques
  2. retrouver, dans le tableau de bord Supabase (Authentication > Users), l'utilisateur anonyme dont l'identifiant commence par ces 8 caractères
  3. citer le nom de la variable de authService.ts qui évite de créer plusieurs utilisateurs anonymes en même temps
📌 Réponse attendue

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

Votre mission : permettre au joueur de choisir un pseudo, qui sera affiché sur l'accueil puis dans le classement. Le pseudo est enregistré dans une nouvelle table joueur. Vous commencerez par la base de données (SQL), puis vous écrirez l'application. Restez sur la branche feature/securite.

Étape 1 — Créer la table joueur​

Créez le fichier sql/05_joueur.sql et commencez par ce premier morceau. L'identifiant du joueur est le même que celui de l'utilisateur Auth : la colonne id est à la fois la clé primaire et une clé étrangère vers auth.users. Complétez la ligne qui déclare cette colonne :

Ajoutez ensuite l'index unique qui interdit deux pseudos identiques, sans tenir compte des majuscules (« Alice » et « alice » sont considérés comme le même pseudo), et activez la RLS :

create unique index joueur_pseudo_unique on public.joueur (lower(pseudo));

alter table public.joueur enable row level security;

Bonne pratique - La base de données est le dernier rempart

La contrainte check sur pseudo et l'index unique sont vérifiés par le serveur, quoi que fasse l'application. Une règle de validation placée seulement dans l'application peut toujours être contournée (voir la séance suivante).

Étape 2 — Accorder le minimum de droits​

Ajoutez au script les droits. Le principe du moindre privilège : on retire tout, puis on redonne seulement ce qui est nécessaire. Ici, un joueur doit pouvoir lire, créer sa fiche (colonnes id et pseudo uniquement) et modifier uniquement le pseudo. Complétez la ligne qui accorde ces droits :


Bonne pratique - Le moindre privilège

Sans le revoke all, Supabase accorde par défaut tous les droits aux rôles anon et authenticated sur les tables du schéma public. Partir de zéro droit puis ajouter le strict nécessaire est la règle d'or. Ici, même avec une règle RLS mal écrite, un joueur ne pourrait pas modifier la colonne cree_le ni changer son id.

Étape 3 — Écrire les règles RLS et exécuter le script​

Trois règles (policies) : un joueur lit, crée et modifie sa propre fiche, et rien d'autre. Dans chaque règle, (select auth.uid()) désigne l'uuid du joueur connecté. Complétez la ligne qui limite la création aux fiches dont l'id est celui du joueur :

Copiez tout le script 05_joueur.sql dans Supabase > SQL Editor et cliquez sur Run. Vérifiez ensuite dans le Table Editor que la table joueur existe (elle est vide). Ajoutez enfin une ligne au tableau de sql/README.md :

| `05_joueur.sql` | table `joueur` (pseudo) et ses règles RLS |

Bonne pratique - Une règle pour chaque opération

Une table protégée par la RLS n'autorise rien tant qu'aucune règle ne le permet. On écrit donc une règle par opération (select, insert, update). Remarquez la différence : using filtre les lignes existantes (lesquelles ai-je le droit de voir ou modifier ?) et with check contrôle les nouvelles valeurs écrites.

Étape 4 — Valider le pseudo dans l'application​

Créez src/logic/validerPseudo.ts. C'est une fonction pure (comme au tutoriel 2) : elle renvoie un message d'erreur, ou null si le pseudo est valide. Complétez la ligne qui détecte un caractère interdit :

Le fichier complet (avec les constantes et nettoyerPseudo) est dans le corrigé en fin de TP. Créez aussi le fichier de tests tests/validerPseudo.test.ts (également dans le corrigé), puis lancez :

npm test

Tous les tests doivent passer. Remarquez que les tests essaient de faire entrer "<script>" et "x'; drop" : ces pseudos sont refusés.


Bonne pratique - Valider côté application ET côté base

La validation dans l'application sert au confort (message immédiat, bouton désactivé). La validation dans la base (contrainte check) sert à la sécurité : un attaquant qui envoie sa requête sans passer par votre formulaire n'est arrêté que par elle. Les deux règles doivent rester identiques.

Étape 5 — Lire le pseudo du joueur​

Créez src/services/joueurService.ts avec la première fonction : elle lit la fiche du joueur connecté. Une fiche peut ne pas exister encore (le joueur n'a pas choisi de pseudo) : on attend donc zéro ou une ligne. Complétez la méthode qui accepte zéro ligne :


Bonne pratique - Choisir la bonne méthode de lecture

single() déclenche une erreur s'il n'y a pas exactement une ligne, ce qui n'est pas un cas d'erreur ici (un nouveau joueur n'a pas de fiche). maybeSingle() traduit « aucune fiche » par null, un cas normal à traiter simplement.

Étape 6 — Enregistrer le pseudo : insert ou update, jamais upsert​

Dans le même fichier, ajoutez enregistrerPseudo. Un nouveau joueur crée sa fiche (insert), un joueur existant la modifie (update). Complétez la ligne de modification :


Bonne pratique - Les codes d'erreur plutôt que les messages

Comparer error.code ("23505") est fiable ; comparer le texte du message d'erreur ne l'est pas (il peut changer de langue ou de formulation). Le détail technique part en console.warn, le joueur lit un message en français clair.

Étape 7 — Le hook qui lit le pseudo à chaque retour sur l'accueil​

Créez src/hooks/useMonPseudo.ts. Quand le joueur revient de l'écran « Pseudo », l'accueil doit recharger le pseudo : useEffect ne suffit pas (l'accueil n'est pas démonté, il reste dessous dans la pile). On utilise useFocusEffect d'Expo Router, qui s'exécute à chaque fois que l'écran redevient visible. Complétez la ligne de nettoyage qui annule la requête quand l'écran perd le focus :


Bonne pratique - useCallback exigé par useFocusEffect

useFocusEffect exige que sa fonction soit enveloppée dans useCallback, sinon elle serait relancée à chaque rendu. C'est un des rares cas, dans ce projet, où l'on écrit useCallback à la main (le compilateur React s'occupe du reste).

Étape 8 — Le formulaire et le verrou anti double envoi​

Créez l'écran src/app/pseudo.tsx. La fonction valider est le cœur du formulaire. Elle commence par refuser d'agir si le pseudo est invalide ou si un envoi est déjà en cours, puis pose le verrou. Complétez la ligne qui pose le verrou :


Bonne pratique - Deux protections valent mieux qu'une

Le bouton est aussi désactivé (desactive={erreurSaisie !== null || enCoursEnvoi}) et affiche « Enregistrement... ». Mais l'état React est mis à jour après le prochain rendu : deux appuis très rapprochés peuvent passer avant. Le useRef, lui, change immédiatement. Le bloc finally libère toujours le verrou, succès ou échec.

Étape 9 — Brancher l'écran dans la navigation et sur l'accueil​

Trois modifications :

  1. dans src/app/_layout.tsx, déclarez l'écran pseudo ;
  2. dans src/app/index.tsx, remplacez useSessionAnonyme par useMonPseudo et affichez, selon l'état, le bouton « Choisir un pseudo pour jouer » ou « Bonjour ... ! » avec les boutons « Créer un quiz » et « Changer de pseudo » ;
  3. supprimez le fichier src/hooks/useSessionAnonyme.ts : il a joué son rôle (la connexion est désormais déclenchée par le service du pseudo).

Complétez la ligne du _layout.tsx :


Bonne pratique - Supprimer le code devenu inutile

Un fichier qui n'est plus utilisé induit en erreur le prochain lecteur et peut cacher des bugs. Après une refonte, supprimez ce qui ne sert plus (Git en garde l'historique si vous en avez besoin).

Étape 10 — Tester les cas qui dérangent​

Lancez npm test, npx tsc --noEmit puis npx expo start (version Web). Essayez :

  1. un pseudo trop court (ab) : un message d'aide s'affiche et le bouton est grisé ;
  2. un pseudo avec un accent (Zoé) : refusé ;
  3. un pseudo valide (CyberNinja_92) : retour à l'accueil, « Bonjour CyberNinja_92 ! » ;
  4. un double clic très rapide sur « Enregistrer » : une seule ligne dans la table joueur (Table Editor) ;
  5. avec une fenêtre de navigation privée (qui crée un autre joueur anonyme), le même pseudo en changeant la casse (cyberninja_92) : « Ce pseudo est déjà pris ».

Bonne pratique - Casser volontairement son application

Un formulaire n'est pas terminé quand il marche avec une saisie correcte. Testez la saisie vide, trop longue, avec des caractères spéciaux, la double validation et le pseudo déjà pris : ce sont ces cas que les vrais utilisateurs (et les attaquants) essaieront.

Étape 11 — Enregistrer votre travail​

git add -A
git status
git commit -m "Choisir un pseudo : formulaire, validation et table joueur"
git push

Bonne pratique - Vérifier avec git status avant de valider

git status vous montre exactement ce qui va entrer dans le commit : la suppression de useSessionAnonyme.ts doit y apparaître, et le fichier .env.local ne doit jamais y figurer.

📌 Une solution

Notions théoriques​

La table joueur et le lien avec Supabase Auth​

Les utilisateurs de Supabase Auth vivent dans auth.users, un schéma que l'application ne peut pas lire. Pour y rattacher des informations publiques (le pseudo), on crée une table public.joueur dont la clé primaire id est aussi une clé étrangère vers auth.users (id). Ainsi :

  • il y a au plus une fiche par utilisateur ;
  • si l'utilisateur est supprimé, sa fiche l'est aussi (on delete cascade) ;
  • l'identifiant n'est pas inventé : c'est auth.uid().

Deux couches de protection : droits et RLS​

CoucheQuestion poséeExemple dans ce TP
Droits (grant / revoke)Ce rôle a-t-il le droit d'effectuer cette opération sur cette colonne ?update (pseudo) : seul pseudo est modifiable
RLS (policy)Sur quelles lignes cette opération est-elle permise ?(select auth.uid()) = id : uniquement sa propre ligne

Les deux couches se cumulent : pour modifier son pseudo, il faut le droit sur la colonne et que la ligne soit la sienne. Les droits par colonne sont indispensables ici : la RLS ne sait filtrer que des lignes, jamais des colonnes. Nous réutiliserons ce mécanisme pour cacher la colonne est_correcte (séance 50).

Les règles RLS : using et with check​

  • using (...) : condition que doit remplir une ligne déjà présente pour être visible ou modifiable ;
  • with check (...) : condition que doit remplir la ligne après l'écriture (nouvelle ligne, ou ligne modifiée).

Pour le update, on écrit les deux : le joueur ne peut modifier que sa ligne (using) et ne peut pas la transformer en ligne appartenant à quelqu'un d'autre (with check). L'écriture (select auth.uid()) plutôt que auth.uid() demande à PostgreSQL de calculer l'uuid une seule fois par requête, au lieu de le recalculer pour chaque ligne : c'est une recommandation de performance de Supabase.

Pourquoi pas d'upsert ?​

upsert signifie « insère, ou modifie si la ligne existe ». Le client Supabase le traduit en INSERT ... ON CONFLICT DO UPDATE, qui renvoie à la base toutes les colonnes envoyées, y compris id, pour la mise à jour. Or nous n'accordons le droit de modification que sur pseudo : la requête est refusée. C'est pourquoi l'application choisit elle-même entre insert (premier pseudo) et update (changement de pseudo). Contourner ce refus en accordant plus de droits affaiblirait la sécurité : mieux vaut écrire un peu plus de code.

Validation côté application, validation côté base​

Application (validerPseudo)Base de données (check, index unique)
Rôleconfort : réponse immédiate, bouton désactivésécurité : impossible à contourner
Contournable ?oui (requête envoyée sans l'application)non
Doit-on la garder ?ouioui, toujours

Les codes d'erreur de la base permettent d'expliquer le refus au joueur : 23505 (unique_violation, pseudo déjà pris) et 23514 (check_violation, pseudo invalide).

Empêcher le double envoi d'un formulaire​

Deux appuis rapprochés sur « Enregistrer » déclencheraient deux requêtes. Pour l'éviter :

  1. le bouton est désactivé pendant l'envoi et affiche « Enregistrement... » ;
  2. un verrou useRef (envoiEnCours) bloque immédiatement un deuxième envoi : modifier .current ne provoque pas de rendu, la valeur est donc à jour tout de suite, alors que setEnCoursEnvoi(true) ne prend effet qu'au prochain rendu ;
  3. le verrou est libéré dans un bloc finally, que la requête réussisse ou échoue.

Pseudo et données personnelles​

Le pseudo sera visible de tous dans le classement. C'est une donnée que le joueur choisit lui-même : n'utilisez pas votre vrai nom. L'application n'enregistre ni e-mail, ni nom, ni adresse IP dans ses propres tables : c'est le principe de minimisation du RGPD (ne collecter que le strict nécessaire). Un pseudo contenant un prénom ou un nom reste une donnée personnelle : il faudrait pouvoir la supprimer à la demande du joueur.


Test de mémorisation/compréhension​


Quelle partie du contrôle d'un pseudo garantit la sécurité (impossible à contourner) ?


Que renvoie auth.uid() dans une policy RLS ?


Pourquoi utilise-t-on un useRef (envoiEnCours) en plus du bouton désactivé ?


Que signifie le code d'erreur PostgreSQL 23505 reçu lors de l'enregistrement d'un pseudo ?


Pourquoi utilise-t-on insert puis update plutôt qu'un upsert sur la table joueur ?


Quelle est la différence entre single() et maybeSingle() ?


Pourquoi le pseudo « Alice » est-il refusé si « alice » existe déjà ?



Exercices complémentaires​

Exercice A : observer le refus de la base​

Dans le Table Editor de Supabase, essayez d'insérer à la main une ligne dans joueur avec le pseudo ab : quel est le message d'erreur ? Quelle contrainte l'a déclenché ?

Exercice B : allonger la limite​

Quelles modifications faut-il faire (SQL et application) pour autoriser des pseudos de 25 caractères ? Dans quel ordre les faire pour ne jamais avoir d'application plus permissive que la base ?


Ce qu'il faut retenir​

NotionRésumé
Table joueurClé primaire = auth.users (id), une fiche par utilisateur, pseudo contrôlé par check et index unique sur lower(pseudo).
Moindre privilègerevoke all puis grant seulement ce qui est nécessaire, colonne par colonne.
RLSRègles par opération : using filtre les lignes existantes, with check contrôle les nouvelles valeurs.
insert / updatePas d'upsert quand les droits sont accordés par colonne.
ValidationApplication = confort, base = sécurité : toujours les deux.
Double envoiBouton désactivé + verrou useRef libéré dans finally.

Aperçu de la prochaine séance​

Votre pseudo est protégé, mais il reste un point faible bien plus grave : les bonnes réponses du quiz sont lisibles par tout le monde. Dans la prochaine séance, vous allez jouer l'attaquant, contre votre propre base de TP, pour constater par vous-même ce qu'un tricheur peut faire avec les seuls outils d'un navigateur et la clé publique.