Se connecter
Le parcours d’une connexion : les règles partagées des identifiants (adresse en minuscules), le service de connexion — même message et même temps pour une adresse inconnue et un mauvais mot de passe, limite d’essais par adresse —, puis la création de la session : une ligne en base, un jeton signé, un cookie et ses options.
Précédemment. Une session est une ligne en base, reliée au membre ; aucun droit n’y est recopié. Ce qui part vers le navigateur est borné par le contrat
SessionClient.
La validation des identifiants#
Entrons dans le flux de connexion.
Tout commence par une frontière simple : valider la forme des identifiants envoyés par le navigateur.
/** Longueur minimale d'un mot de passe. Les textes d'aide l'affichent à partir de cette valeur. */export const PASSWORD_MIN_LENGTH = 12;/** * Une adresse e-mail (connexion, mot de passe oublié, création d'un compte). Toujours en minuscules : * les adresses sont enregistrées ainsi, et une adresse tapée avec une majuscule retrouve son compte. */export const emailSchema = z .string() .trim() .toLowerCase() .min(1, "Saisissez votre adresse e-mail.") .email("Adresse e-mail invalide.");/** Un mot de passe choisi par le membre (réinitialisation, changement). */export const passwordSchema = z .string() .min(PASSWORD_MIN_LENGTH, `Le mot de passe doit faire au moins ${PASSWORD_MIN_LENGTH} caractères.`);/** * Connexion. Un champ vide reçoit son propre message, avant les règles de forme : Zod renvoie les * problèmes dans l'ordre des vérifications, et le formulaire affiche le premier. */export const loginSchema = z.object({ email: emailSchema, password: z.string().min(1, "Saisissez votre mot de passe.").pipe(passwordSchema),});Ce schéma impose deux contraintes, chacune avec son message :
- une adresse e-mail, nettoyée de ses espaces, mise en minuscules, non vide, au format valide ;
- un mot de passe non vide, puis d’au moins
PASSWORD_MIN_LENGTHcaractères — douze.
Pourquoi 12 caractères ?#
Ce nombre n’est pas arbitraire.
Les recommandations actuelles (ANSSI, NIST) considèrent qu’un mot de passe de 8 caractères se casse en quelques heures avec du matériel grand public, dès que son empreinte a fuité. À partir de 12 caractères, l’effort devient considérable ; à partir de 16, prohibitif. Le projet se place au minimum raisonnable pour un site moderne — et les mots de passe qu’il génère lui-même, à la création d’un compte, font 12 caractères variés.
Pourquoi le schéma vit dans packages/contracts ?#
C’est le motif vu dans la partie « API » : un schéma utilisé à la fois par le formulaire (retour immédiat) et par la procédure (validation qui fait foi) doit vivre dans le paquet partagé, et être importé des deux côtés.
Le projet en a fait l’expérience. La règle des douze caractères et celle de l’adresse étaient réécrites dans six fichiers — le formulaire, trois procédures, deux pages —, chacun à sa façon, alors qu’un schéma « partagé » existait déjà sans être importé nulle part. Aujourd’hui, auth.login déclare .input(loginSchema), et le formulaire appelle loginSchema.safeParse(…) : si la règle passe un jour à 16 caractères, une seule ligne change, et les deux côtés suivent.
Pourquoi en minuscules ?#
Une adresse e-mail ne distingue pas, en pratique, les majuscules des minuscules : Jean.Dupont@club.fr et jean.dupont@club.fr arrivent dans la même boîte. La base, elle, les distingue : pour PostgreSQL, ce sont deux chaînes différentes.
Le projet en a subi la conséquence. La création d’un compte enregistrait l’adresse telle que l’administrateur l’avait tapée. « Mot de passe oublié » la cherchait en minuscules, et la connexion telle que le membre la saisissait. Un membre créé avec une majuscule ne pouvait donc jamais réinitialiser son mot de passe — la recherche ne trouvait pas son compte, et la réponse, volontairement identique, ne le lui disait pas —, et devait respecter la casse exacte pour se connecter.
La règle vit désormais dans emailSchema : toute adresse qui entre est mise en minuscules, à la création d’un compte comme à la connexion et à la réinitialisation. Une migration a mis au même format les adresses déjà enregistrées ; si deux comptes ne différaient que par la casse, elle s’arrête avec un message explicite plutôt que de choisir à notre place.
Une donnée qui sert de clé de recherche se normalise à l’entrée, une fois, au même endroit pour tous les chemins qui l’écrivent ou la cherchent. Normaliser à la lecture seulement, ou sur un seul des chemins, crée exactement ce défaut : deux formes de la même valeur, qui ne se retrouvent plus.
La validation est la première frontière du système.
Elle ne résout aucun problème métier — mais elle empêche tous les problèmes en aval qui surviendraient sur des données malformées.
👉 Où nous en sommes : la forme des identifiants est garantie. Le backend peut maintenant entrer dans la vraie vérification — celle qui consulte la base pour savoir si ce compte existe, et si le mot de passe est le bon.
Le service de connexion : la vraie logique#
C’est ici que se joue l’authentification proprement dite.
import { TRPCError } from "@trpc/server";import { prisma } from "@backend/prisma";import { createSessionJWT } from "@backend/lib/session/session.server";import { UNKNOWN_ACCOUNT_HASH, verifyPassword } from "@backend/lib/auth/password";import { createThrottle } from "@backend/lib/security/throttle";/** * Essais de mot de passe : 10 échecs par quart d'heure et par adresse. Au-delà, l'adresse est * refusée sans comparaison jusqu'à ce que la fenêtre glisse. La clé est l'adresse, pas l'adresse IP : * l'IP vient d'un en-tête qu'un client peut écrire lui-même, et tout un club peut partager la même. */const loginThrottle = createThrottle({ limit: 10, windowMs: 15 * 60 * 1000 });/** * Connexion : vérifie l'adresse et le mot de passe, puis ouvre une session (ligne en base, jeton * signé dans un cookie). * * Une adresse inconnue et un mauvais mot de passe produisent la même erreur, EN LE MÊME TEMPS : * sans compte, le mot de passe est quand même comparé, à une empreinte factice. Ni le message ni * la durée de la réponse ne disent si une adresse a un compte au club. La limite d'essais * s'applique de même à toute adresse, qu'elle ait un compte ou non. */export async function loginService(email: string, password: string): Promise<void> { const key = `login:${email}`; if (loginThrottle.isLimited([key])) { throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "TOO_MANY_ATTEMPTS" }); } const user = await prisma.user.findUnique({ where: { email }, select: { id: true, password: true }, }); const valid = await verifyPassword(password, user?.password ?? UNKNOWN_ACCOUNT_HASH); if (!user || !valid) { loginThrottle.record([key]); throw new TRPCError({ code: "UNAUTHORIZED", message: "INVALID_CREDENTIALS" }); } loginThrottle.clear([key]); await createSessionJWT(user.id);}Lecture guidée du service de connexion#
Anatomie du service de connexion
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import { TRPCError } from "@trpc/server";import { prisma } from "@backend/prisma";import { createSessionJWT } from "@backend/lib/session/session.server";import { UNKNOWN_ACCOUNT_HASH, verifyPassword } from "@backend/lib/auth/password";import { createThrottle } from "@backend/lib/security/throttle";/** * Essais de mot de passe : 10 échecs par quart d'heure et par adresse. Au-delà, l'adresse est * refusée sans comparaison jusqu'à ce que la fenêtre glisse. La clé est l'adresse, pas l'adresse IP : * l'IP vient d'un en-tête qu'un client peut écrire lui-même, et tout un club peut partager la même. */const loginThrottle = createThrottle({ limit: 10, windowMs: 15 * 60 * 1000 });/** * Connexion : vérifie l'adresse et le mot de passe, puis ouvre une session (ligne en base, jeton * signé dans un cookie). * * Une adresse inconnue et un mauvais mot de passe produisent la même erreur, EN LE MÊME TEMPS : * sans compte, le mot de passe est quand même comparé, à une empreinte factice. Ni le message ni * la durée de la réponse ne disent si une adresse a un compte au club. La limite d'essais * s'applique de même à toute adresse, qu'elle ait un compte ou non. */export async function loginService(email: string, password: string): Promise<void> { const key = `login:${email}`; if (loginThrottle.isLimited([key])) { throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "TOO_MANY_ATTEMPTS" }); } const user = await prisma.user.findUnique({ where: { email }, select: { id: true, password: true }, }); const valid = await verifyPassword(password, user?.password ?? UNKNOWN_ACCOUNT_HASH); if (!user || !valid) { loginThrottle.record([key]); throw new TRPCError({ code: "UNAUTHORIZED", message: "INVALID_CREDENTIALS" }); } loginThrottle.clear([key]); await createSessionJWT(user.id);}🔭 Pour aller plus loin — pourquoi le même message pour deux erreurs ?#
Quand l’adresse n’existe pas en base, et quand le mot de passe est faux, le service renvoie exactement la même erreur : INVALID_CREDENTIALS.
À première vue, cela semble peu utile pour le membre. Pourquoi ne pas dire « cette adresse n’a pas de compte » ? Ce serait plus pédagogique.
C’est précisément pour cela que c’est une mauvaise idée.
Si le message diffère, un attaquant peut énumérer les comptes en essayant des adresses :
- « cette adresse n’a pas de compte » → elle n’est pas dans la base ;
- « mot de passe incorrect » → elle y est.
Il construit ainsi la liste des adresses des membres, qu’il peut ensuite viser par hameçonnage, ou en essayant des mots de passe fuités ailleurs. Pour un club, cette liste est déjà une donnée personnelle qui ne doit pas sortir.
Renvoyer le même message dans les deux cas empêche cette énumération. C’est un compromis assumé : un peu moins d’ergonomie, beaucoup plus de sécurité.
🔭 Pour aller plus loin — le même message, mais pas le même temps#
Le message identique ne suffit pas, et le projet l’a appris en relisant son propre code.
La première version du service s’arrêtait dès que l’adresse était inconnue :
Avant : une réponse plus rapide pour une adresse inconnue
const user = await prisma.user.findUnique({ where: { email } });if (!user) { throw new TRPCError({ code: "UNAUTHORIZED", message: "INVALID_CREDENTIALS" });}const valid = await bcrypt.compare(password, user.password); // ~250 ms, seulement iciif (!valid) { throw new TRPCError({ code: "UNAUTHORIZED", message: "INVALID_CREDENTIALS" });}Pour une adresse inconnue, la réponse partait aussitôt. Pour une adresse connue, elle attendait la comparaison bcrypt — environ 250 ms, puisque bcrypt est lent exprès. Le message était le même ; la durée ne l’était pas. Un script qui chronomètre ses tentatives distingue sans peine 5 ms de 250 ms, et retrouve l’énumération que le message identique devait empêcher. C’est ce qu’on appelle une attaque par canal auxiliaire : l’information ne fuit pas par ce que le serveur dit, mais par la façon dont il le dit.
La correction tient en une ligne : quand l’adresse est inconnue, le mot de passe est quand même comparé, à une empreinte que personne ne peut satisfaire (UNKNOWN_ACCOUNT_HASH, calculée au même coût que les vraies). Mesurée sur le service : 285 ms pour une adresse inconnue, 293 ms pour un mauvais mot de passe, 262 ms pour le bon. Les écarts restants sont du bruit, de l’ordre de ce qui varie d’un essai à l’autre.
Une protection contre l’énumération se vérifie sur tout ce que le serveur renvoie : le message, le code HTTP, et la durée. Le chapitre 7 montrera que « Mot de passe oublié » avait le même défaut, en plus visible.
Limiter les essais#
bcrypt freine chaque essai, mais ne les compte pas. Rien n’empêchait un script d’essayer des milliers de mots de passe sur une même adresse, quatre par seconde, jour et nuit. Le service limite désormais les échecs : dix par quart d’heure et par adresse. Au-delà, il refuse sans même chercher le compte (TOO_MANY_ATTEMPTS), jusqu’à ce que la fenêtre glisse ; une connexion réussie efface les échecs de l’adresse.
Trois choix méritent d’être expliqués.
La clé est l’adresse, pas l’adresse IP. L’IP du visiteur arrive dans un en-tête (x-forwarded-for) qu’un client peut écrire lui-même : un attaquant changerait d’« adresse » à chaque essai. Et une limite par IP bloquerait tout un club derrière la même box. L’adresse e-mail, elle, est exactement ce qu’on veut protéger.
La limite vaut pour toute adresse, qu’elle ait un compte ou non. Sinon, l’arrivée du message « trop d’essais » trahirait qu’une adresse existe — l’énumération, encore.
Le prix est assumé. Un tiers peut, en se trompant exprès dix fois, empêcher un membre de se connecter pendant un quart d’heure. C’est le compromis de toute limite : le formulaire le dit au membre, et lui propose « Mot de passe oublié », qui n’est pas limité de la même façon.
Le limiteur lui-même est un petit module partagé, lib/security/throttle.ts. Il existait déjà, enfermé dans le formulaire de contact ; il a été sorti pour servir aux deux. Il vit en mémoire : remis à zéro au redémarrage, et valable pour un seul serveur — c’est le cas du site.
👉 Où nous en sommes : le membre est identifié — ou freiné, s’il insiste. Il ne reste plus qu’à transformer cette identification en une session qui survivra aux prochaines requêtes. C’est l’étape finale du premier mouvement.
createSessionJWT : la naissance de la session#
Une fois le membre identifié, il faut lui poser un tampon sur la main.
C’est le rôle de createSessionJWT.
import { cookies } from "next/headers";import jwt from "jsonwebtoken";import { prisma } from "@backend/prisma";import { COOKIE_NAME, SESSION_DURATION_MS } from "@contracts/auth/constants";import type { Session, User } from "@prisma/client";import type { SessionJwtPayload } from "@backend/lib/session/session.types";const JWT_SECRET = process.env.JWT_SECRET;/** Seul algorithme accepté à la vérification : un jeton signé autrement est refusé. */export const JWT_ALGORITHM = "HS256" as const;type CreateSessionResult = { sessionId: Session["id"];};/** * Ouvre une session pour un membre : une ligne Session en base, un jeton signé qui ne contient * que l'identifiant de cette ligne, et un cookie qui transporte le jeton. Le jeton, la ligne et * le cookie expirent ensemble (SESSION_DURATION_MS). */export async function createSessionJWT(userId: User["id"]): Promise<CreateSessionResult> { const expiresAt = new Date(Date.now() + SESSION_DURATION_MS); // Dernière connexion, en SQL direct pour ne pas modifier updatedAt (dernière modification du profil). await prisma.$executeRaw`UPDATE "User" SET "lastLoginAt" = NOW() WHERE "id" = ${userId}`.catch((err: unknown) => { console.error("[session] lastLoginAt non mis à jour :", err instanceof Error ? err.message : err); }); const session = await prisma.session.create({ data: { userId, expiresAt, }, }); const payload: SessionJwtPayload = { sessionId: session.id, }; const token = jwt.sign(payload, JWT_SECRET!, { algorithm: JWT_ALGORITHM, expiresIn: Math.floor(SESSION_DURATION_MS / 1000), }); const cookieStore = await cookies(); cookieStore.set(COOKIE_NAME, token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", expires: expiresAt, path: "/", }); return { sessionId: session.id, };}Lecture guidée de la création de session#
Du membre identifié au cookie posé dans le navigateur
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import { cookies } from "next/headers";import jwt from "jsonwebtoken";import { prisma } from "@backend/prisma";import { COOKIE_NAME, SESSION_DURATION_MS } from "@contracts/auth/constants";import type { Session, User } from "@prisma/client";import type { SessionJwtPayload } from "@backend/lib/session/session.types";const JWT_SECRET = process.env.JWT_SECRET;/** Seul algorithme accepté à la vérification : un jeton signé autrement est refusé. */export const JWT_ALGORITHM = "HS256" as const;type CreateSessionResult = { sessionId: Session["id"];};/** * Ouvre une session pour un membre : une ligne Session en base, un jeton signé qui ne contient * que l'identifiant de cette ligne, et un cookie qui transporte le jeton. Le jeton, la ligne et * le cookie expirent ensemble (SESSION_DURATION_MS). */export async function createSessionJWT(userId: User["id"]): Promise<CreateSessionResult> { const expiresAt = new Date(Date.now() + SESSION_DURATION_MS); // Dernière connexion, en SQL direct pour ne pas modifier updatedAt (dernière modification du profil). await prisma.$executeRaw`UPDATE "User" SET "lastLoginAt" = NOW() WHERE "id" = ${userId}`.catch((err: unknown) => { console.error("[session] lastLoginAt non mis à jour :", err instanceof Error ? err.message : err); }); const session = await prisma.session.create({ data: { userId, expiresAt, }, }); const payload: SessionJwtPayload = { sessionId: session.id, }; const token = jwt.sign(payload, JWT_SECRET!, { algorithm: JWT_ALGORITHM, expiresIn: Math.floor(SESSION_DURATION_MS / 1000), }); const cookieStore = await cookies(); cookieStore.set(COOKIE_NAME, token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", expires: expiresAt, path: "/", }); return { sessionId: session.id, };}/** Déconnexion : supprime la ligne Session désignée par le cookie, puis le cookie. */export async function deleteSessionFromCookie(): Promise<void> { const cookieStore = await cookies(); const payload = verifyJwt(cookieStore.get(COOKIE_NAME)?.value ?? null); if (payload) { // Une base injoignable ne doit pas empêcher de retirer le cookie : le navigateur est // déconnecté quoi qu'il arrive, et la ligne expirera d'elle-même. await prisma.session.deleteMany({ where: { id: payload.sessionId } }).catch((err: unknown) => { console.error("[session] suppression à la déconnexion :", err instanceof Error ? err.message : err); }); } cookieStore.delete(COOKIE_NAME);}export async function getToken(): Promise<string | null> { const cookieStore = await cookies(); return cookieStore.get(COOKIE_NAME)?.value ?? null;}/** Le contenu du jeton si sa signature et sa date sont valides, sinon null. */export function verifyJwt(token: string | null): SessionJwtPayload | null { if (!token || !JWT_SECRET) return null; try { return jwt.verify(token, JWT_SECRET, { algorithms: [JWT_ALGORITHM] }) as SessionJwtPayload; } catch { return null; }}🔭 Pour aller plus loin — anatomie des options du cookie#
Chaque option du cookie joue un rôle de sécurité précis. Les oublier, c’est ouvrir des failles.
httpOnly: true
Le cookie est invisible pour le JavaScript de la page : document.cookie ne le voit pas. Si un attaquant parvient à injecter un script dans une page (une faille XSS), il ne pourra pas emporter la session.
secure: process.env.NODE_ENV === "production"
Le cookie ne circule que sur des connexions HTTPS. En développement local (HTTP), la contrainte est levée, sinon le cookie ne serait jamais transmis. En production, elle est non négociable.
sameSite: "lax"
C’est la protection contre la falsification de requête (CSRF). Avec lax, le cookie n’accompagne pas les requêtes qu’un autre site déclenche en arrière-plan (un formulaire envoyé en POST, un fetch). Il reste envoyé quand on suit un lien vers le site. C’est le bon compromis entre sécurité et confort.
expires: expiresAt
Aligne la durée de vie du cookie sur celle de la session en base. Quand la session expire, le navigateur oublie le cookie de lui-même.
path: "/"
Le cookie est envoyé pour toutes les adresses du site. Sans cela, il ne le serait que pour le chemin de la requête qui l’a posé (/api/trpc).
Oublier l’une de ces options — surtout httpOnly ou sameSite — est une faille classique.
La création de session est triple :
👉 une ligne en base
👉 un jeton signé contenant son identifiant
👉 un cookie sécurisé qui transporte le jeton
Chacune de ces trois opérations est nécessaire. Aucune n’est suffisante seule.
👉 Où nous en sommes : le membre a maintenant un cookie de session dans son navigateur, et le backend possède une trace persistante de sa session. Le premier mouvement est complet.
Ce que nous venons de construire dans le Mouvement 1#
Voilà le chemin parcouru depuis l'email tapé dans le formulaire jusqu'au cookie posé :
Scroller horizontalement si nécessaire
Chaque étape résout un problème spécifique :
- Zod a garanti que les données avaient la bonne forme
- la comparaison bcrypt a vérifié le mot de passe sans jamais le stocker en clair, et sans trahir par sa durée qu’une adresse existe
- la création Session DB a produit un identifiant unique, révocable, avec une date d'expiration
- la signature JWT a transformé cet identifiant en un jeton infalsifiable
- le cookie a posé ce jeton dans le navigateur avec tous les flags de sécurité
Mais ce cookie seul ne sert à rien.
Ce qui rend le système vivant, c'est ce qui va se passer à la requête suivante : comment le backend va-t-il lire ce cookie, vérifier le JWT, relire la session en base, et injecter cette identité dans le contexte de chaque procédure tRPC ?
C'est tout l'objet du Mouvement 2.
À suivre#
Un cookie seul ne sert à rien. Ce qui rend le système vivant, c’est ce qui se passe à la requête suivante.
Chapitre suivant : lire la session. Le routeur d’authentification, puis getSessionFromRequest, qui reconstruit l’identité à chaque requête : jeton, base, expiration, projection — en une requête SQL.