Aller au contenu

Turnstile For Front

Ce document décrit la cible front à implémenter en même temps que la refonte backend antibot.

Objectif:

  • supprimer toute dépendance front à reCAPTCHA
  • supprimer toute logique front basée sur recaptchaToken ou turnstileToken
  • standardiser tous les formulaires publics protégés sur un seul contrat: challengeToken
  • lier les challenges Turnstile au bon flow, et quand nécessaire, au bon token métier

Statut: cible à implémenter telle quelle

Règle générale

Le front ne doit plus raisonner en "provider captcha".

Le front doit raisonner en:

  • "je dois obtenir un challenge pour ce flow"
  • "ce flow a éventuellement un binding métier"
  • "j'envoie ensuite challengeToken à l'API"

Le front ne doit plus envoyer:

  • recaptchaToken
  • turnstileToken

Le front doit envoyer partout:

  • challengeToken

Configuration front requise

  • ajouter TURNSTILE_SITE_KEY
  • exposer un helper unique d'acquisition de challenge

Exemple de shape cible:

type ChallengeFlow =
  | "signup_account_v1"
  | "contact_us_v1"
  | "waitlist_register_v1"
  | "waitlist_invite_signup_v1"
  | "user_invite_signup_v1"
  | "structure_adherent_invite_signup_v1";

type AcquireChallengeInput = {
  flow: ChallengeFlow;
  binding?: string;
};

type AcquireChallengeResult = {
  challengeToken: string;
};

async function acquireChallenge(
  input: AcquireChallengeInput,
): Promise<AcquireChallengeResult> {
  // wrapper Turnstile
}

Flows Turnstile à utiliser

  • création de compte: signup_account_v1
  • contact-us: contact_us_v1
  • inscription waitlist: waitlist_register_v1
  • signup depuis invitation waitlist: waitlist_invite_signup_v1
  • signup depuis invitation utilisateur: user_invite_signup_v1
  • signup depuis invitation adhérent structure: structure_adherent_invite_signup_v1

Chaque écran protégé doit demander un challenge avec le flow exact correspondant à son endpoint.

Endpoints et payloads cibles

Les routes backend cibles à appeler sont:

  • POST /v1/public/accounts
  • POST /v1/public/contact-us
  • POST /v1/public/waitlist-entries
  • POST /v1/public/waitlist-invitations/signup
  • POST /v1/public/user-invitations/signup
  • POST /v1/public/structure-adherent-invitations/signup

Les routes publiques sans challenge dans ce périmètre restent:

  • POST /v1/public/waitlist-confirmations/confirm
  • POST /v1/public/structure-adherent-invitations/resolve
  • POST /v1/public/structure-adherent-invitations/decline

Payloads cibles:

Création de compte

{
  "pseudo": "club-alpha",
  "email": "contact@club-alpha.test",
  "accountType": "pro",
  "challengeToken": "token"
}

Contact-us

{
  "name": "Jane",
  "email": "jane@example.com",
  "message": "Message de plus de 30 caractères pour le support.",
  "challengeToken": "token"
}

Inscription waitlist

{
  "email": "alice@example.com",
  "accountType": "individual",
  "cityId": 12,
  "sportIds": [1, 2],
  "challengeToken": "token"
}

accountType est obligatoire pour toute inscription waitlist. Valeurs autorisées: individual ou pro. Le backend refuse le payload si le champ est absent ou invalide; le front ne doit donc pas appliquer de fallback silencieux.

Signup depuis invitation waitlist

{
  "token": "invite-token",
  "pseudo": "alice-alpha",
  "challengeToken": "token"
}

Signup depuis invitation utilisateur

{
  "token": "invite-token",
  "pseudo": "alice-alpha",
  "challengeToken": "token"
}

Signup depuis invitation adhérent structure

{
  "token": "invite-token",
  "pseudo": "alice-alpha",
  "challengeToken": "token"
}

Binding: quand il est obligatoire

Le binding sert à lier le challenge Turnstile à un token métier précis.

Pas de binding pour:

  • création de compte
  • contact-us
  • inscription waitlist

Binding obligatoire pour:

  • signup invitation waitlist
  • signup invitation utilisateur
  • signup invitation adhérent structure

Valeur du binding:

  • base64url(sha256(token))

Important:

  • ne jamais envoyer le token métier brut dans cData
  • ne pas lowercaser le token
  • éviter toute normalisation autre qu'un éventuel trim() strictement identique au backend

Exemple TypeScript:

async function toBase64UrlSha256(value: string): Promise<string> {
  const data = new TextEncoder().encode(value);
  const digest = await crypto.subtle.digest("SHA-256", data);
  const bytes = Array.from(new Uint8Array(digest));
  const base64 = btoa(String.fromCharCode(...bytes));
  return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}

Exemple d'usage:

const binding = await toBase64UrlSha256(token);

const { challengeToken } = await acquireChallenge({
  flow: "user_invite_signup_v1",
  binding,
});

Règles d'implémentation des écrans

Pour chaque submit protégé:

  • calculer le binding si nécessaire
  • appeler acquireChallenge({ flow, binding })
  • envoyer le challengeToken reçu dans le payload JSON

Pattern recommandé:

async function onSubmit() {
  const binding = token ? await toBase64UrlSha256(token) : undefined;

  const { challengeToken } = await acquireChallenge({
    flow: "structure_adherent_invite_signup_v1",
    binding,
  });

  await api.structureInvitationSignup({
    token,
    pseudo,
    challengeToken,
  });
}

Le front ne doit pas:

  • stocker durablement un challengeToken
  • réutiliser un même challenge pour plusieurs tentatives
  • réutiliser un challenge d'un écran sur un autre écran
  • mutualiser un challenge entre plusieurs formulaires

Un challengeToken est jetable et doit être réacquis à chaque tentative utile.

Gestion d'erreur cible

Le front doit gérer les réponses antibot suivantes:

  • 403 avec code = "invalid_challenge"
  • 503 avec code = "challenge_unavailable"

UX recommande:

  • invalid_challenge: afficher une erreur courte, invalider le challenge local, proposer une nouvelle tentative
  • challenge_unavailable: afficher une erreur temporaire, ne pas insister silencieusement, permettre de réessayer plus tard

Le front doit privilégier le champ code pour la logique applicative, et le champ error pour l'affichage si nécessaire.

Definition of done côté front

  • plus aucun écran public ne dépend de reCAPTCHA
  • plus aucun payload front n'utilise recaptchaToken
  • plus aucun payload front n'utilise turnstileToken
  • tous les formulaires publics protégés envoient challengeToken
  • tous les flows cibles utilisent le bon action
  • tous les flows tokenisés utilisent le bon binding
  • les erreurs invalid_challenge et challenge_unavailable sont gérées explicitement