Fermer une session
Comment une session meurt : la déconnexion (la ligne en base, puis le cookie), la purge quotidienne des sessions abandonnées, les limites d’une minuterie dans le serveur, et le changement de mot de passe qui ferme les autres appareils.
Précédemment. Chaque procédure reçoit la session dans
ctx.sessionClient, etprotectedProceduregarantit qu’un membre est présent. Le code métier lit son identifiant sans vérification.
La déconnexion : casser le lien#
Nous savons construire une session. Voyons maintenant comment la détruire proprement.
/** 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);}Lecture guidée de la déconnexion#
Pourquoi une déconnexion fait deux opérations
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; }}Pourquoi les deux opérations sont nécessaires#
Imaginons que nous supprimions seulement le cookie, sans toucher à la base. Le navigateur du membre est déconnecté — mais la session, elle, reste valide en base pendant sept jours. Si le jeton avait été copié (un ordinateur partagé, une extension de navigateur malveillante), il resterait utilisable tout ce temps. La déconnexion n’aurait rien révoqué : elle aurait seulement fait oublier le jeton à un navigateur.
Inversement, si nous supprimions seulement la ligne en base : le membre est bien déconnecté, puisque son jeton ne désigne plus rien (chapitre 4). Mais son navigateur continue d’envoyer, à chaque requête, un cookie devenu inutile, que le serveur vérifie, cherche en base, et rejette, encore et encore. Et l’interface, qui ne voit plus de session, a besoin de savoir que l’absence est voulue.
Les deux suppressions sont complémentaires. La ligne en base fait la révocation ; le cookie supprimé fait le ménage côté navigateur.
Une déconnexion qui ne supprime que le cookie ne révoque rien : elle cache le jeton à un navigateur, sans l’invalider.
C’est la suppression de la ligne qui déconnecte vraiment.
👉 Où nous en sommes : la déconnexion est propre. Mais toutes les sessions ne meurent pas par une déconnexion : beaucoup sont simplement abandonnées. Sans nettoyage, la table grossirait indéfiniment.
Le cycle de vie : la purge des sessions expirées#
Nous avons vu deux façons dont une session disparaît :
- au passage, dans
getSessionFromRequest: une session expirée présentée par un navigateur est supprimée ; - explicitement, à la déconnexion.
Mais il manque un cas : les sessions abandonnées. Le membre ferme son navigateur et ne revient pas, ou revient depuis un autre appareil. Sa session expire, mais personne ne la présente plus pour déclencher la suppression au passage.
La première version du projet confiait ce cas à un setInterval qui purgeait les sessions toutes les dix minutes. Il a été remplacé par une purge plus générale, celle des durées de conservation du RGPD, qui s’occupe aussi des sessions :
export async function purgeExpiredData(db: PrismaClient, now = new Date()) { const { ticketMonths } = await retentionSettings(db); const cutoff = monthsAgo(now, ticketMonths); const tickets = await db.ticket.deleteMany({ where: { status: "CLOSED", closedAt: { lt: cutoff }, messages: { none: { createdAt: { gte: cutoff } } } }, }); const sessions = await db.session.deleteMany({ where: { expiresAt: { lt: now } } }); const resetTokens = await db.passwordResetToken.deleteMany({ where: { expiresAt: { lt: now } } }); // Vignettes et affiches dont la photo ou la vidéo d'origine a disparu (sans R2 configuré : 0). const derivatives = await sweepOrphanDerivatives().catch((err) => { console.error("[rgpd/purge] déclinaisons :", err instanceof Error ? err.message : err); return 0; }); const imageConsentForms = await purgeDetachedImageConsentForms(db, now).catch((err) => { console.error("[rgpd/purge] formulaires de droit à l'image :", err instanceof Error ? err.message : err); return 0; }); return { tickets: tickets.count, sessions: sessions.count, resetTokens: resetTokens.count, derivatives, imageConsentForms };Lecture guidée de la purge#
La purge, de la requête à la minuterie
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import type { PrismaClient } from "@prisma/client";import { parisClock } from "@backend/modules/tickets/recap.service";import { sweepOrphanDerivatives } from "@backend/modules/media/services/mediaDerivatives.service";import { purgeDetachedImageConsentForms } from "@backend/modules/imageConsent/imageConsentForm.service";/* * Durées de conservation (RGPD) APPLIQUÉES, pas seulement annoncées : * - demande close depuis plus de N mois (réglage ticketRetentionMonths) et sans message depuis : * supprimée avec ses messages et son historique ; * - sessions expirées et liens de réinitialisation périmés : supprimés ; * - formulaires de droit à l'image d'un compte supprimé depuis plus de 5 ans : supprimés. * Les comptes ne sont JAMAIS supprimés automatiquement (le site ne connaît pas la fin d'adhésion) : * listInactiveAccounts les signale aux administrateurs (registre RGPD), qui décident. */const MONTH_MS = 30.4375 * 24 * 3600 * 1000;export function monthsAgo(now: Date, months: number): Date { return new Date(now.getTime() - months * MONTH_MS);}export async function retentionSettings(db: PrismaClient): Promise<{ ticketMonths: number; accountMonths: number }> { const s = await db.siteSettings.findUnique({ where: { id: "site" }, select: { ticketRetentionMonths: true, accountRetentionMonths: true }, }); return { ticketMonths: s?.ticketRetentionMonths ?? 36, accountMonths: s?.accountRetentionMonths ?? 12 };}export async function purgeExpiredData(db: PrismaClient, now = new Date()) { const { ticketMonths } = await retentionSettings(db); const cutoff = monthsAgo(now, ticketMonths); const tickets = await db.ticket.deleteMany({ where: { status: "CLOSED", closedAt: { lt: cutoff }, messages: { none: { createdAt: { gte: cutoff } } } }, }); const sessions = await db.session.deleteMany({ where: { expiresAt: { lt: now } } }); const resetTokens = await db.passwordResetToken.deleteMany({ where: { expiresAt: { lt: now } } }); // Vignettes et affiches dont la photo ou la vidéo d'origine a disparu (sans R2 configuré : 0). const derivatives = await sweepOrphanDerivatives().catch((err) => { console.error("[rgpd/purge] déclinaisons :", err instanceof Error ? err.message : err); return 0; }); const imageConsentForms = await purgeDetachedImageConsentForms(db, now).catch((err) => { console.error("[rgpd/purge] formulaires de droit à l'image :", err instanceof Error ? err.message : err); return 0; }); return { tickets: tickets.count, sessions: sessions.count, resetTokens: resetTokens.count, derivatives, imageConsentForms };}export type InactiveAccount = { id: string; name: string; email: string; lastLoginAt: Date | null; createdAt: Date; memberSince: Date | null;};/** Comptes sans connexion depuis plus de N mois (accountRetentionMonths) : à examiner par un admin. */export async function listInactiveAccounts(db: PrismaClient, now = new Date()): Promise<{ months: number; accounts: InactiveAccount[] }> { const { accountMonths } = await retentionSettings(db); const cutoff = monthsAgo(now, accountMonths); const users = await db.user.findMany({ where: { OR: [{ lastLoginAt: { lt: cutoff } }, { lastLoginAt: null, createdAt: { lt: cutoff } }] }, select: { id: true, firstName: true, lastName: true, email: true, lastLoginAt: true, createdAt: true, memberSince: true }, orderBy: [{ lastLoginAt: { sort: "asc", nulls: "first" } }, { createdAt: "asc" }], }); return { months: accountMonths, accounts: users.map((u) => ({ id: u.id, name: [u.firstName, u.lastName].filter(Boolean).join(" ").trim() || u.email, email: u.email, lastLoginAt: u.lastLoginAt, createdAt: u.createdAt, memberSince: u.memberSince, })), };}let retentionTimer: ReturnType<typeof setInterval> | null = null;/** Purge quotidienne à 3 h (heure de Paris), plus une passe au démarrage. RETENTION_PURGE_DISABLED=1 la coupe. */export function startRetentionSchedule(db: PrismaClient): boolean { if (process.env.RETENTION_PURGE_DISABLED === "1" || retentionTimer) return false; let lastDay: string | null = null; let busy = false; const run = (now: Date): void => { busy = true; lastDay = parisClock(now).day; purgeExpiredData(db, now) .then((r) => { if (r.tickets || r.sessions || r.resetTokens || r.derivatives || r.imageConsentForms) { console.log(`[rgpd/purge] ${r.tickets} demande(s) close(s), ${r.sessions} session(s), ${r.resetTokens} lien(s) de réinitialisation, ${r.derivatives} vignette(s) orpheline(s), ${r.imageConsentForms} formulaire(s) de droit à l'image supprimé(s)`); } }) .catch((err) => console.error("[rgpd/purge] échec :", err instanceof Error ? err.message : err)) .finally(() => { busy = false; }); }; run(new Date()); retentionTimer = setInterval(() => { const now = new Date(); const c = parisClock(now); if (busy || c.hour !== 3 || c.day === lastDay) return; run(now); }, 60_000); return true;}🔭 Pour aller plus loin — les limites d’une minuterie dans le serveur#
Cette purge vit dans le processus Node du site. Cela fonctionne parce que le projet tourne sur un seul serveur, dans un conteneur Docker qui reste allumé.
Deux situations la mettraient en défaut :
1) Un hébergement « serverless ».
Sur ce type de plateforme, le code ne tourne qu’à la demande et s’arrête aussitôt : aucune minuterie ne survit entre deux requêtes.
2) Plusieurs instances du site.
Chaque instance lancerait sa propre purge à 3 h. Pour un deleteMany, ce n’est pas grave, mais c’est inutile.
Les alternatives existent : une tâche planifiée par le système (cron) qui appelle une route dédiée, la planification de l’hébergeur, ou une tâche planifiée par PostgreSQL lui-même (extension pg_cron). Pour le site du club, la minuterie suffit ; il faut seulement savoir qu’elle dépend de l’hébergement.
La triple défense contre les sessions fantômes#
Au total, le projet élimine les sessions mortes par trois mécanismes complémentaires :
Les trois façons dont une session disparaît
Mécanisme | Quand | Ce qu’il couvre |
|---|---|---|
Suppression au passage (getSessionFromRequest) | Quand une session expirée est présentée | Les sessions expirées de membres qui reviennent |
Suppression explicite (déconnexion, changement de mot de passe) | À la demande du membre | Les sessions volontairement fermées |
Purge quotidienne (purgeExpiredData) | Chaque nuit à 3 h, et au démarrage | Les sessions abandonnées |
Showing entries1to3entries
Chaque mécanisme couvre un cas que les autres ne couvrent pas. Ensemble, ils garantissent que la table ne grossit jamais indéfiniment.
Changer de mot de passe : fermer les autres appareils#
Le changement de mot de passe est une autre façon, plus ciblée, de faire mourir des sessions. Un membre connecté vérifie son mot de passe actuel, en choisit un nouveau, et toutes ses autres sessions sont fermées :
/** * 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 }; }),Lecture guidée du changement de mot de passe#
Changer de mot de passe, et fermer les autres appareils
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 }; }),});Quelqu’un qui change son mot de passe parce qu’il le croit compromis déconnecte ainsi tous les autres appareils — y compris celui d’un éventuel intrus —, tout en restant connecté sur celui qu’il utilise.
👉 Où nous en sommes : toute la mécanique normale de vie et de mort d’une session est en place. Reste le cas le plus sensible, celui qui concentre le plus de pièges : le mot de passe oublié.
À suivre#
Les sessions naissent, vivent et meurent proprement. Reste le seul chemin qui permet d’ouvrir une session sans connaître le mot de passe.
Chapitre suivant : le mot de passe oublié. Le jeton brut et son empreinte, la demande qui ne trahit pas les comptes, et la réinitialisation, dont le lien ne peut servir qu’une fois.