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
joueurlié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
useRefet traiter les erreurs23505et23514.
Petite vérification
Avant de commencer, vérifiez si vous savez...
- 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 - retrouver, dans le tableau de bord Supabase (Authentication > Users), l'utilisateur anonyme dont l'identifiant commence par ces 8 caractères
- citer le nom de la variable de
authService.tsqui é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;
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 :
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 |
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.
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 :
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 :
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 :
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 :
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 :
- dans
src/app/_layout.tsx, déclarez l'écranpseudo; - dans
src/app/index.tsx, remplacezuseSessionAnonymeparuseMonPseudoet affichez, selon l'état, le bouton « Choisir un pseudo pour jouer » ou « Bonjour ... ! » avec les boutons « Créer un quiz » et « Changer de pseudo » ; - 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 :
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 :
- un pseudo trop court (
ab) : un message d'aide s'affiche et le bouton est grisé ; - un pseudo avec un accent (
Zoé) : refusé ; - un pseudo valide (
CyberNinja_92) : retour à l'accueil, « Bonjour CyberNinja_92 ! » ; - un double clic très rapide sur « Enregistrer » : une seule ligne dans la table
joueur(Table Editor) ; - 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 ».
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
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
| Couche | Question posée | Exemple 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ôle | confort : 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 ? | oui | oui, 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 :
- le bouton est désactivé pendant l'envoi et affiche « Enregistrement... » ;
- un verrou
useRef(envoiEnCours) bloque immédiatement un deuxième envoi : modifier.currentne provoque pas de rendu, la valeur est donc à jour tout de suite, alors quesetEnCoursEnvoi(true)ne prend effet qu'au prochain rendu ; - 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
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
| Notion | Résumé |
|---|---|
Table joueur | Clé primaire = auth.users (id), une fiche par utilisateur, pseudo contrôlé par check et index unique sur lower(pseudo). |
| Moindre privilège | revoke all puis grant seulement ce qui est nécessaire, colonne par colonne. |
| RLS | Règles par opération : using filtre les lignes existantes, with check contrôle les nouvelles valeurs. |
insert / update | Pas d'upsert quand les droits sont accordés par colonne. |
| Validation | Application = confort, base = sécurité : toujours les deux. |
| Double envoi | Bouton 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.