Le mot de passe oublié
Le seul chemin vers un compte sans son mot de passe : le jeton brut et son empreinte SHA-256, une demande qui ne trahit pas les comptes (ni par son message, ni par sa durée), et une réinitialisation en transaction, dont le lien est consommé une seule fois, même en cas d’envois simultanés.
Précédemment. Une session meurt par la déconnexion, par expiration (supprimée au passage ou par la purge de la nuit), ou parce que le mot de passe change.
Le mot de passe oublié : un sous-flux complet et sensible#
La réinitialisation est un mécanisme à part. Elle mérite une attention particulière parce qu’elle est la seule façon de reprendre la main sur un compte sans connaître son mot de passe. C’est donc une cible privilégiée.
Le projet l’implémente en deux procédures : requestPasswordReset (le membre demande un lien par e-mail) et resetPassword (il suit le lien et choisit un nouveau mot de passe).
Voyons d’abord le service qui les soutient.
import crypto from "crypto";/** * passwordReset.service.ts * * - token brut envoyé par email * - token hashé stocké en DB * - hash = sha256(token) */export const RESET_TOKEN_TTL_MS = 1000 * 60 * 30; // 30 minexport const RESET_TOKEN_COOLDOWN_MS = 1000 * 60 * 2; // 2 minexport function createPasswordResetToken(): { token: string; tokenHash: string } { const token = crypto.randomBytes(32).toString("hex"); // 64 chars const tokenHash = hashResetToken(token); return { token, tokenHash };}export function hashResetToken(token: string): string { return crypto.createHash("sha256").update(token).digest("hex");}Lecture guidée du service de réinitialisation#
Deux constantes, deux fonctions
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import crypto from "crypto";/** * passwordReset.service.ts * * - token brut envoyé par email * - token hashé stocké en DB * - hash = sha256(token) */export const RESET_TOKEN_TTL_MS = 1000 * 60 * 30; // 30 minexport const RESET_TOKEN_COOLDOWN_MS = 1000 * 60 * 2; // 2 minexport function createPasswordResetToken(): { token: string; tokenHash: string } { const token = crypto.randomBytes(32).toString("hex"); // 64 chars const tokenHash = hashResetToken(token); return { token, tokenHash };}export function hashResetToken(token: string): string { return crypto.createHash("sha256").update(token).digest("hex");}Le motif jeton brut + empreinte#
C’est un motif de sécurité classique, souvent mal compris.
Le service génère un seul jeton, puis en garde deux représentations :
- le jeton brut part dans l’e-mail. C’est lui qui apparaît dans le lien
…?token=…; - son empreinte est enregistrée en base, dans .
Quand le membre suit le lien, le backend reçoit le jeton brut, en calcule l’empreinte à son tour, et cherche cette empreinte en base.
🔭 Pour aller plus loin — pourquoi ne pas stocker le jeton brut ?#
Imaginons que nous enregistrions le jeton brut en base.
Si la base fuitait (une sauvegarde mal rangée, une copie de la base), tous les liens en attente deviendraient aussitôt utilisables : il suffirait de construire l’adresse pour changer le mot de passe d’un membre.
En n’enregistrant que l’empreinte, une copie de la base ne donne rien d’utilisable : SHA-256 ne se renverse pas. Pour utiliser un lien, il faudrait la valeur d’origine, qui n’a jamais été enregistrée et n’existe que dans l’e-mail du membre.
C’est le même principe que pour les mots de passe : on garde l’empreinte, jamais la valeur. Avec une différence, déjà notée dans la lecture guidée : le jeton est aléatoire et long, là où un mot de passe est choisi par un humain. SHA-256, rapide, suffit pour le premier ; il faut bcrypt, lent exprès, pour le second.
La demande de lien#
requestPasswordReset: publicProcedure .input(requestPasswordResetSchema) .mutation(async ({ input, ctx }) => { // Déjà nettoyée et mise en minuscules par emailSchema, comme toutes les adresses enregistrées. const user = await ctx.prisma.user.findUnique({ where: { email: input.email }, select: { id: true, email: true }, }); if (!user) { return { ok: true as const }; } const lastToken = await ctx.prisma.passwordResetToken.findFirst({ where: { userId: user.id }, orderBy: { createdAt: "desc" }, select: { createdAt: true }, }); if (lastToken) { const elapsed = Date.now() - lastToken.createdAt.getTime(); if (elapsed < RESET_TOKEN_COOLDOWN_MS) { return { ok: true as const }; } } const { token, tokenHash } = createPasswordResetToken(); const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS); await ctx.prisma.passwordResetToken.create({ data: { userId: user.id, tokenHash, expiresAt, requestIp: ctx.requestIp ?? null, userAgent: ctx.userAgent ?? null, }, }); // L'envoi n'est PAS attendu : il prend plusieurs secondes, et une réponse lente pour une // adresse connue, rapide pour une inconnue, trahirait qui a un compte. Jamais le jeton dans // les journaux ; un échec d'envoi est journalisé, la réponse reste la même. void sendPasswordResetEmail({ toEmail: user.email, token }).catch((err: unknown) => { console.error( "[auth] e-mail de réinitialisation non envoyé :", err instanceof Error ? err.message : err, ); }); return { ok: true as const }; }),Lecture guidée de la demande#
La demande de lien, sans rien révéler
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import { router, publicProcedure, protectedProcedure } from "@backend/trpc/core";import { deleteSessionFromCookie } from "@backend/lib/session/session.server";import { loginService } from "@backend/modules/auth/services/auth.service";import { createPasswordResetToken, hashResetToken, RESET_TOKEN_COOLDOWN_MS, RESET_TOKEN_TTL_MS,} from "@backend/modules/auth/services/passwordReset.service";import { sendPasswordResetEmail } from "@backend/email/services/sendPasswordResetEmail";import { TRPCError } from "@trpc/server";import { hashPassword, verifyPassword } from "@backend/lib/auth/password";import { getToken, verifyJwt } from "@backend/lib/session/session.server";import { changePasswordSchema, loginSchema, requestPasswordResetSchema, resetPasswordSchema,} from "@contracts/auth/auth.schema";import { assertPasswordOfConnectedUser } from "@backend/lib/security/passwordCheck";/** * Connexion, déconnexion, session courante, mot de passe (changement, oubli, réinitialisation). * `getSession` est appelée en permanence par le front : elle répond à « qui suis-je, maintenant ? ». */export const authRouter = router({ login: publicProcedure // Même schéma que le formulaire de connexion (packages/contracts) : une seule règle, un seul message. .input(loginSchema) .mutation(async ({ input }) => { const { email, password } = input; try { await loginService(email, password); return { success: true as const, }; } catch (error) { // Un mauvais identifiant fait partie du fonctionnement normal du formulaire : c'est un // résultat. Tout le reste (base injoignable, bug) est une vraie erreur : elle remonte, // est journalisée par la route /api/trpc, et le formulaire affiche son message générique. if (error instanceof TRPCError && error.message === "INVALID_CREDENTIALS") { return { success: false as const, error: "INVALID_CREDENTIALS" as const, }; } // Trop d'échecs pour cette adresse : un résultat aussi, que le formulaire explique. if (error instanceof TRPCError && error.message === "TOO_MANY_ATTEMPTS") { return { success: false as const, error: "TOO_MANY_ATTEMPTS" as const, }; } throw error; } }), logout: protectedProcedure.mutation(async () => { await deleteSessionFromCookie(); return { success: true, }; }), /** * Changement de mot de passe (utilisateur connecté). * Vérifie le mot de passe actuel, refuse un nouveau identique, re-hash, et * DÉCONNECTE LES AUTRES APPAREILS (supprime leurs sessions ; garde la * courante via le sessionId du cookie). */ changePassword: protectedProcedure .input(changePasswordSchema) .mutation(async ({ ctx, input }) => { const userId = ctx.sessionClient.user.id; const user = await ctx.prisma.user.findUnique({ where: { id: userId }, select: { password: true }, }); if (!user) { throw new TRPCError({ code: "NOT_FOUND", message: "Membre introuvable." }); } // BAD_REQUEST, pas UNAUTHORIZED : le membre EST connecté ; c'est la saisie qui est fausse. // UNAUTHORIZED signifie « aucune session », et un client pourrait le traiter comme une déconnexion. // Essais limités : une session volée ne permet pas d'essayer des mots de passe sans fin. await assertPasswordOfConnectedUser({ userId, password: input.currentPassword, hash: user.password, wrongMessage: "Mot de passe actuel incorrect.", }); const same = await verifyPassword(input.newPassword, user.password); if (same) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le nouveau mot de passe doit être différent de l'actuel.", }); } const hashed = await hashPassword(input.newPassword); const payload = verifyJwt(await getToken()); const currentSessionId = payload?.sessionId ?? null; await ctx.prisma.$transaction(async (tx) => { await tx.user.update({ where: { id: userId }, data: { password: hashed }, }); await tx.session.deleteMany({ where: { userId, ...(currentSessionId ? { NOT: { id: currentSessionId } } : {}), }, }); }); return { success: true }; }), getSession: publicProcedure.query(async ({ ctx }) => { return ctx.sessionClient; }), requestPasswordReset: publicProcedure .input(requestPasswordResetSchema) .mutation(async ({ input, ctx }) => { // Déjà nettoyée et mise en minuscules par emailSchema, comme toutes les adresses enregistrées. const user = await ctx.prisma.user.findUnique({ where: { email: input.email }, select: { id: true, email: true }, }); if (!user) { return { ok: true as const }; } const lastToken = await ctx.prisma.passwordResetToken.findFirst({ where: { userId: user.id }, orderBy: { createdAt: "desc" }, select: { createdAt: true }, }); if (lastToken) { const elapsed = Date.now() - lastToken.createdAt.getTime(); if (elapsed < RESET_TOKEN_COOLDOWN_MS) { return { ok: true as const }; } } const { token, tokenHash } = createPasswordResetToken(); const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS); await ctx.prisma.passwordResetToken.create({ data: { userId: user.id, tokenHash, expiresAt, requestIp: ctx.requestIp ?? null, userAgent: ctx.userAgent ?? null, }, }); // L'envoi n'est PAS attendu : il prend plusieurs secondes, et une réponse lente pour une // adresse connue, rapide pour une inconnue, trahirait qui a un compte. Jamais le jeton dans // les journaux ; un échec d'envoi est journalisé, la réponse reste la même. void sendPasswordResetEmail({ toEmail: user.email, token }).catch((err: unknown) => { console.error( "[auth] e-mail de réinitialisation non envoyé :", err instanceof Error ? err.message : err, ); }); return { ok: true as const }; }), resetPassword: publicProcedure .input(resetPasswordSchema) .mutation(async ({ ctx, input }) => { const token = input.token.trim(); const tokenHash = hashResetToken(token); const entry = await ctx.prisma.passwordResetToken.findUnique({ where: { tokenHash }, select: { id: true, userId: true, expiresAt: true, usedAt: true, }, }); if (!entry) { throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien n'est pas valide. Demandez-en un nouveau avec « Mot de passe oublié »." }); } if (entry.usedAt) { throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien a déjà servi. Connectez-vous, ou demandez-en un nouveau avec « Mot de passe oublié »." }); } if (entry.expiresAt.getTime() < Date.now()) { throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien a expiré. Demandez-en un nouveau avec « Mot de passe oublié »." }); } const hashedPassword = await hashPassword(input.newPassword); await ctx.prisma.$transaction(async (tx) => { await tx.user.update({ where: { id: entry.userId }, // `isFirstLogin` n'est pas touché : choisir un mot de passe ne complète pas le profil. // Un nouveau membre qui passe par « Mot de passe oublié » y reste invité. data: { password: hashedPassword }, }); // Consommation atomique : la ligne n'est marquée que si elle ne l'était pas encore. Deux // envois simultanés du même lien passent tous deux les vérifications ci-dessus ; un seul // obtient ici count === 1, l'autre annule sa transaction. const consumed = await tx.passwordResetToken.updateMany({ where: { id: entry.id, usedAt: null }, data: { usedAt: new Date() }, }); if (consumed.count !== 1) { throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien a déjà servi. Connectez-vous, ou demandez-en un nouveau avec « Mot de passe oublié »." }); } await tx.session.deleteMany({ where: { userId: entry.userId }, }); }); return { ok: true as const }; }),});Cette procédure a eu le même défaut que la connexion (chapitre 3), en plus visible. Elle attendait la fin de l’envoi de l’e-mail avant de répondre : plusieurs secondes, le temps de joindre le serveur de messagerie. Pour une adresse sans compte, elle répondait aussitôt. Le texte affiché était identique — « Si un compte existe pour cet email, un lien a été envoyé. » —, mais un simple chronomètre disait qui était membre du club. L’envoi part désormais sans être attendu.
La réinitialisation et sa transaction#
C’est ici que le motif se révèle dans toute sa rigueur.
resetPassword: publicProcedure .input(resetPasswordSchema) .mutation(async ({ ctx, input }) => { const token = input.token.trim(); const tokenHash = hashResetToken(token); const entry = await ctx.prisma.passwordResetToken.findUnique({ where: { tokenHash }, select: { id: true, userId: true, expiresAt: true, usedAt: true, }, }); if (!entry) { throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien n'est pas valide. Demandez-en un nouveau avec « Mot de passe oublié »." }); } if (entry.usedAt) { throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien a déjà servi. Connectez-vous, ou demandez-en un nouveau avec « Mot de passe oublié »." }); } if (entry.expiresAt.getTime() < Date.now()) { throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien a expiré. Demandez-en un nouveau avec « Mot de passe oublié »." }); } const hashedPassword = await hashPassword(input.newPassword); await ctx.prisma.$transaction(async (tx) => { await tx.user.update({ where: { id: entry.userId }, // `isFirstLogin` n'est pas touché : choisir un mot de passe ne complète pas le profil. // Un nouveau membre qui passe par « Mot de passe oublié » y reste invité. data: { password: hashedPassword }, }); // Consommation atomique : la ligne n'est marquée que si elle ne l'était pas encore. Deux // envois simultanés du même lien passent tous deux les vérifications ci-dessus ; un seul // obtient ici count === 1, l'autre annule sa transaction. const consumed = await tx.passwordResetToken.updateMany({ where: { id: entry.id, usedAt: null }, data: { usedAt: new Date() }, }); if (consumed.count !== 1) { throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien a déjà servi. Connectez-vous, ou demandez-en un nouveau avec « Mot de passe oublié »." }); } await tx.session.deleteMany({ where: { userId: entry.userId }, }); }); return { ok: true as const }; }),Lecture guidée de la réinitialisation#
La réinitialisation, en une transaction
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import { router, publicProcedure, protectedProcedure } from "@backend/trpc/core";import { deleteSessionFromCookie } from "@backend/lib/session/session.server";import { loginService } from "@backend/modules/auth/services/auth.service";import { createPasswordResetToken, hashResetToken, RESET_TOKEN_COOLDOWN_MS, RESET_TOKEN_TTL_MS,} from "@backend/modules/auth/services/passwordReset.service";import { sendPasswordResetEmail } from "@backend/email/services/sendPasswordResetEmail";import { TRPCError } from "@trpc/server";import { hashPassword, verifyPassword } from "@backend/lib/auth/password";import { getToken, verifyJwt } from "@backend/lib/session/session.server";import { changePasswordSchema, loginSchema, requestPasswordResetSchema, resetPasswordSchema,} from "@contracts/auth/auth.schema";import { assertPasswordOfConnectedUser } from "@backend/lib/security/passwordCheck";/** * Connexion, déconnexion, session courante, mot de passe (changement, oubli, réinitialisation). * `getSession` est appelée en permanence par le front : elle répond à « qui suis-je, maintenant ? ». */export const authRouter = router({ login: publicProcedure // Même schéma que le formulaire de connexion (packages/contracts) : une seule règle, un seul message. .input(loginSchema) .mutation(async ({ input }) => { const { email, password } = input; try { await loginService(email, password); return { success: true as const, }; } catch (error) { // Un mauvais identifiant fait partie du fonctionnement normal du formulaire : c'est un // résultat. Tout le reste (base injoignable, bug) est une vraie erreur : elle remonte, // est journalisée par la route /api/trpc, et le formulaire affiche son message générique. if (error instanceof TRPCError && error.message === "INVALID_CREDENTIALS") { return { success: false as const, error: "INVALID_CREDENTIALS" as const, }; } // Trop d'échecs pour cette adresse : un résultat aussi, que le formulaire explique. if (error instanceof TRPCError && error.message === "TOO_MANY_ATTEMPTS") { return { success: false as const, error: "TOO_MANY_ATTEMPTS" as const, }; } throw error; } }), logout: protectedProcedure.mutation(async () => { await deleteSessionFromCookie(); return { success: true, }; }), /** * Changement de mot de passe (utilisateur connecté). * Vérifie le mot de passe actuel, refuse un nouveau identique, re-hash, et * DÉCONNECTE LES AUTRES APPAREILS (supprime leurs sessions ; garde la * courante via le sessionId du cookie). */ changePassword: protectedProcedure .input(changePasswordSchema) .mutation(async ({ ctx, input }) => { const userId = ctx.sessionClient.user.id; const user = await ctx.prisma.user.findUnique({ where: { id: userId }, select: { password: true }, }); if (!user) { throw new TRPCError({ code: "NOT_FOUND", message: "Membre introuvable." }); } // BAD_REQUEST, pas UNAUTHORIZED : le membre EST connecté ; c'est la saisie qui est fausse. // UNAUTHORIZED signifie « aucune session », et un client pourrait le traiter comme une déconnexion. // Essais limités : une session volée ne permet pas d'essayer des mots de passe sans fin. await assertPasswordOfConnectedUser({ userId, password: input.currentPassword, hash: user.password, wrongMessage: "Mot de passe actuel incorrect.", }); const same = await verifyPassword(input.newPassword, user.password); if (same) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le nouveau mot de passe doit être différent de l'actuel.", }); } const hashed = await hashPassword(input.newPassword); const payload = verifyJwt(await getToken()); const currentSessionId = payload?.sessionId ?? null; await ctx.prisma.$transaction(async (tx) => { await tx.user.update({ where: { id: userId }, data: { password: hashed }, }); await tx.session.deleteMany({ where: { userId, ...(currentSessionId ? { NOT: { id: currentSessionId } } : {}), }, }); }); return { success: true }; }), getSession: publicProcedure.query(async ({ ctx }) => { return ctx.sessionClient; }), requestPasswordReset: publicProcedure .input(requestPasswordResetSchema) .mutation(async ({ input, ctx }) => { // Déjà nettoyée et mise en minuscules par emailSchema, comme toutes les adresses enregistrées. const user = await ctx.prisma.user.findUnique({ where: { email: input.email }, select: { id: true, email: true }, }); if (!user) { return { ok: true as const }; } const lastToken = await ctx.prisma.passwordResetToken.findFirst({ where: { userId: user.id }, orderBy: { createdAt: "desc" }, select: { createdAt: true }, }); if (lastToken) { const elapsed = Date.now() - lastToken.createdAt.getTime(); if (elapsed < RESET_TOKEN_COOLDOWN_MS) { return { ok: true as const }; } } const { token, tokenHash } = createPasswordResetToken(); const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS); await ctx.prisma.passwordResetToken.create({ data: { userId: user.id, tokenHash, expiresAt, requestIp: ctx.requestIp ?? null, userAgent: ctx.userAgent ?? null, }, }); // L'envoi n'est PAS attendu : il prend plusieurs secondes, et une réponse lente pour une // adresse connue, rapide pour une inconnue, trahirait qui a un compte. Jamais le jeton dans // les journaux ; un échec d'envoi est journalisé, la réponse reste la même. void sendPasswordResetEmail({ toEmail: user.email, token }).catch((err: unknown) => { console.error( "[auth] e-mail de réinitialisation non envoyé :", err instanceof Error ? err.message : err, ); }); return { ok: true as const }; }), resetPassword: publicProcedure .input(resetPasswordSchema) .mutation(async ({ ctx, input }) => { const token = input.token.trim(); const tokenHash = hashResetToken(token); const entry = await ctx.prisma.passwordResetToken.findUnique({ where: { tokenHash }, select: { id: true, userId: true, expiresAt: true, usedAt: true, }, }); if (!entry) { throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien n'est pas valide. Demandez-en un nouveau avec « Mot de passe oublié »." }); } if (entry.usedAt) { throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien a déjà servi. Connectez-vous, ou demandez-en un nouveau avec « Mot de passe oublié »." }); } if (entry.expiresAt.getTime() < Date.now()) { throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien a expiré. Demandez-en un nouveau avec « Mot de passe oublié »." }); } const hashedPassword = await hashPassword(input.newPassword); await ctx.prisma.$transaction(async (tx) => { await tx.user.update({ where: { id: entry.userId }, // `isFirstLogin` n'est pas touché : choisir un mot de passe ne complète pas le profil. // Un nouveau membre qui passe par « Mot de passe oublié » y reste invité. data: { password: hashedPassword }, }); // Consommation atomique : la ligne n'est marquée que si elle ne l'était pas encore. Deux // envois simultanés du même lien passent tous deux les vérifications ci-dessus ; un seul // obtient ici count === 1, l'autre annule sa transaction. const consumed = await tx.passwordResetToken.updateMany({ where: { id: entry.id, usedAt: null }, data: { usedAt: new Date() }, }); if (consumed.count !== 1) { throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien a déjà servi. Connectez-vous, ou demandez-en un nouveau avec « Mot de passe oublié »." }); } await tx.session.deleteMany({ where: { userId: entry.userId }, }); }); return { ok: true as const }; }),});🔭 Pour aller plus loin — pourquoi une transaction, et pourquoi updateMany ?#
Sans transaction, imaginons que les opérations s’exécutent l’une après l’autre et qu’une erreur survienne au milieu.
Le scénario à éviter : le mot de passe est changé, puis le serveur s’arrête juste avant que le lien soit marqué comme utilisé. Le lien reste valable, et celui qui le détient — peut-être pas le membre — peut changer le mot de passe à nouveau.
Avec ctx.prisma.$transaction, soit les trois opérations réussissent ensemble, soit aucune n’est appliquée. Cette atomicité est garantie par PostgreSQL : la transaction est un mécanisme du moteur, pas une convention du code.
Mais la transaction ne règle pas tout. Les vérifications (le lien existe, n’a pas servi, n’a pas expiré) sont faites avant elle. Si le même lien est envoyé deux fois au même instant — un double clic, ou un intrus qui a intercepté l’e-mail et agit en même temps que le membre —, les deux requêtes passent toutes les deux les vérifications, puis entrent chacune dans sa transaction. La première version marquait le lien avec un simple update, qui réussissait les deux fois : deux mots de passe étaient enregistrés l’un après l’autre, et le dernier gagnait.
La correction est la même idée que l’erreur P2002 de la partie « Données » : confier la règle à la base. updateMany avec usedAt: null dans sa condition ne marque la ligne que si elle ne l’était pas encore. PostgreSQL verrouille la ligne le temps de l’écriture : des deux requêtes, une seule obtient count === 1 ; l’autre lève une erreur, et sa transaction est annulée, nouveau mot de passe compris.
Quand plusieurs opérations doivent réussir ou échouer ensemble, la transaction n’est pas un bonus : c’est une obligation.
Et quand une ressource ne doit servir qu’une fois, la vérification « déjà utilisée ? » doit être faite par l’écriture elle-même, pas par une lecture qui la précède.
Les règles, une par une#
Règle | Ce qu’elle protège |
|---|---|
Seule l’empreinte SHA-256 du jeton est stockée | Une copie de la base ne donne aucun lien utilisable. |
Lien valable 30 minutes | Un e-mail ancien n’ouvre plus rien. |
Lien consommé une seule fois, par l’écriture elle-même (usedAt) | Un lien déjà utilisé, ou utilisé deux fois en même temps, ne change le mot de passe qu’une fois. |
Une demande toutes les 2 minutes au plus par compte | Personne ne peut bombarder la boîte d’un membre. |
Même réponse, dans le même temps, que l’adresse existe ou non | Le formulaire ne révèle pas qui a un compte, ni par son message, ni par sa lenteur. |
Le jeton n’apparaît jamais dans les journaux | Un accès aux journaux ne suffit pas à prendre un compte. |
La réinitialisation supprime toutes les sessions | Un éventuel intrus est déconnecté partout. |
Showing entries1to7entries
👉 Où nous en sommes : toutes les façons dont une session peut naître, vivre, être lue, détruite, nettoyée ou révoquée sont désormais couvertes. Le second mouvement est complet.
Ce que nous venons de construire dans le Mouvement 2#
À la fin de ce deuxième mouvement, voici ce qui se passe à chaque requête d’un membre déjà connecté :
Scroller horizontalement si nécessaire
Et quand le membre se déconnecte, change ou réinitialise son mot de passe, ou que sa session expire, le système sait exactement comment nettoyer — en base, dans le cookie, ou en masse chaque nuit.
Le Mouvement 3 va maintenant regarder autour de la session : le navigateur, qui doit savoir dès le premier affichage qui est connecté ; le proxy, à l’entrée du site ; et l’autorisation, avec les groupes.
À suivre#
Le Mouvement 2 s’achève. La session est complète, côté serveur. Il reste à regarder ce qui l’entoure : le navigateur, et l’entrée du site.
Chapitre suivant : le navigateur et le proxy. La session dès le premier affichage, le formulaire de connexion, et le proxy — avec un matcher qui ne correspondait à aucune adresse.