Comptes : une déconnexion survit au redémarrage de l'API (#163)

* Comptes : une déconnexion reste valable après un redémarrage de l'API (sessions fermées gardées en base)

* Déconnexion durable : sans rechargement des sessions fermées, l'API ne démarre pas ; modèle remonté au-dessus du commentaire des photos
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-26 13:24:06 +01:00
1 parent f1b4ad8db2
commit ef503c570d
21 files changed
+272 -4

No files matched your search

@@ -0,0 +1,12 @@
-- Sessions fermées (déconnexion, compte supprimé), gardées en base pour survivre au redémarrage de l'API.
-- Migration additive, une table neuve.
-- CreateTable
CREATE TABLE "revoked_sessions" (
"jti" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "revoked_sessions_pkey" PRIMARY KEY ("jti")
);
-- CreateIndex
CREATE INDEX "revoked_sessions_expiresAt_idx" ON "revoked_sessions"("expiresAt");
+9
View File
@@ -215,6 +215,15 @@ model TreeInvitation {
@@map("tree_invitations")
}
// Sessions fermées (déconnexion, compte supprimé), gardées jusqu'à la fin de leur jeton
model RevokedSession {
jti String @id
expiresAt DateTime
@@index([expiresAt])
@@map("revoked_sessions")
}
// Photo posée sur l'arbre par la famille, en attente de relecture (1.5.1). La ligne vit tant que la photo n'est
// ni acceptée (ligne retirée, fichier gardé) ni refusée (fichier et ligne effacés). Un envoi jamais proposé est
// effacé après 7 jours, la durée d'un brouillon. Les photos des gestionnaires n'ont pas de ligne.
+7 -1
View File
@@ -8,7 +8,7 @@ import { Prisma } from '@prisma/client'
import { env } from './config/env.js'
import { prisma } from './lib/prisma.js'
import { isUserTokenRevoked } from './lib/token-revocation.js'
import { isUserTokenRevoked, loadRevokedTokens } from './lib/token-revocation.js'
import { authRoutes } from './routes/auth.js'
import { contributionRoutes } from './routes/contributions.js'
import { graphRoutes } from './routes/graph.js'
@@ -58,6 +58,12 @@ export function createApp() {
})
app.decorate('prisma', prisma)
// Les déconnexions d'avant le redémarrage reviennent en mémoire avant la première requête (onReady passe avant
// listen). Sans elles, l'API ne démarre pas : chaque session fermée redeviendrait valable sans que personne le
// voie, et pm2 comme Docker relancent l'API.
app.addHook('onReady', async () => {
await loadRevokedTokens(app.prisma)
})
app.decorateRequest('actor', null)
app.register(cors, {
+29 -1
View File
@@ -1,11 +1,21 @@
import type { PrismaClient } from '@prisma/client'
// Sessions fermées par « Se déconnecter » ou par la suppression du compte, jusqu'à la fin de leur jeton.
// Gardées en mémoire pour ne coûter aucune requête par appel, et en base pour survivre au redémarrage de l'API :
// avant, chaque déploiement les oubliait et un jeton déconnecté redevenait valable jusqu'à 7 jours.
// L'API tourne en un seul processus (pm2 fork, un conteneur) : la mémoire suffit entre deux redémarrages.
const revokedUserTokens = new Map<string, number>()
function nowSeconds(): number {
return Math.floor(Date.now() / 1000)
}
export function revokeUserToken(jti: string, exp: number): void {
type RevocationStore = Pick<PrismaClient, 'revokedSession'>
export async function revokeUserToken(store: RevocationStore, jti: string, exp: number): Promise<void> {
revokedUserTokens.set(jti, exp)
const expiresAt = new Date(exp * 1000)
await store.revokedSession.upsert({ where: { jti }, create: { jti, expiresAt }, update: {} })
}
export function isUserTokenRevoked(jti: string): boolean {
@@ -22,3 +32,21 @@ export function isUserTokenRevoked(jti: string): boolean {
return true
}
// Au démarrage : les déconnexions encore valables reviennent en mémoire, les expirées sont effacées. L'effacement
// n'a lieu qu'ici : la table grossit entre deux redémarrages, d'une ligne par déconnexion, ce qui reste minuscule
// avec un déploiement par jour ou presque.
export async function loadRevokedTokens(store: RevocationStore): Promise<number> {
const now = new Date()
await store.revokedSession.deleteMany({ where: { expiresAt: { lte: now } } })
const rows = await store.revokedSession.findMany({ where: { expiresAt: { gt: now } }, select: { jti: true, expiresAt: true } })
for (const row of rows) {
revokedUserTokens.set(row.jti, Math.floor(row.expiresAt.getTime() / 1000))
}
return rows.length
}
// Tests : repartir d'un processus neuf, comme après un redémarrage
export function forgetRevokedTokensInMemory(): void {
revokedUserTokens.clear()
}
+2 -2
View File
@@ -451,7 +451,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
})
if (payload.kind === 'user' && typeof payload.exp === 'number') {
revokeUserToken(payload.jti, payload.exp)
await revokeUserToken(app.prisma, payload.jti, payload.exp)
}
await removeTreeMediaDirectories(ownedTreeIds, request.log)
@@ -471,7 +471,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
}
if (typeof payload.exp === 'number') {
revokeUserToken(payload.jti, payload.exp)
await revokeUserToken(app.prisma, payload.jti, payload.exp)
}
return reply.send({ ok: true })
+6
View File
@@ -7,6 +7,12 @@ import { parseGedcom } from '../src/routes/gedcom'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
count: vi.fn().mockResolvedValue(1),
@@ -4,6 +4,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
count: vi.fn().mockResolvedValue(1),
+6
View File
@@ -4,6 +4,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
// Prénom du compte : renvoyé à l'inscription, à la connexion et sur /me.
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
create: vi.fn(),
+6
View File
@@ -9,6 +9,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
count: vi.fn(),
},
+6
View File
@@ -8,6 +8,12 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
count: vi.fn(),
+79
View File
@@ -0,0 +1,79 @@
import type { FastifyInstance } from 'fastify'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
// Une session fermée reste fermée après un redémarrage de l'API (un déploiement), sur une vraie base. Avant, la
// déconnexion ne vivait qu'en mémoire et le jeton redevenait valable jusqu'à 7 jours. Lancé seulement avec
// TEST_DATABASE_URL (base jetable, migrée) :
// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/deconnexion.db.test.ts
const TEST_DB = process.env.TEST_DATABASE_URL
let app: FastifyInstance
let ip = 0
const nextIp = () => `10.5.${Math.floor(++ip / 250)}.${ip % 250 + 1}`
const run = Date.now().toString(36)
async function call(method: string, url: string, token?: string, payload?: unknown) {
const res = await app.inject({
method: method as 'GET',
url,
payload: payload as Record<string, unknown>,
headers: token ? { authorization: `Bearer ${token}` } : {},
remoteAddress: nextIp(),
})
return { status: res.statusCode, body: res.body ? res.json() : null }
}
// Nouveau processus : la mémoire des déconnexions repart de zéro, puis l'API la recharge depuis la base
async function restart() {
await app.close()
const { forgetRevokedTokensInMemory } = await import('../src/lib/token-revocation.js')
forgetRevokedTokensInMemory()
const { createApp } = await import('../src/app.js')
app = createApp()
await app.ready()
}
async function login(name: string) {
const email = `${name}-${run}@exemple.fr`
const reg = await call('POST', '/auth/register', undefined, { email, password: 'mot-de-passe-solide', firstName: name })
expect(reg.status).toBe(201)
return reg.body.token as string
}
describe.skipIf(!TEST_DB)('déconnexion durable, vraie base', () => {
beforeAll(async () => {
process.env.NODE_ENV = 'test'
process.env.DATABASE_URL = TEST_DB
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
process.env.CORS_ORIGIN = 'http://localhost:5173'
const { env } = await import('../src/config/env.js')
env.ALLOW_REGISTRATION = true
const { createApp } = await import('../src/app.js')
app = createApp()
})
afterAll(async () => {
await app?.close()
})
it('un jeton déconnecté reste refusé après un redémarrage', async () => {
const token = await login('deconnecte')
const other = await login('reste')
expect((await call('GET', '/auth/me', token)).status).toBe(200)
expect((await call('POST', '/auth/logout', token)).status).toBe(200)
expect((await call('GET', '/auth/me', token)).status).toBe(401)
await restart()
expect((await call('GET', '/auth/me', token)).status).toBe(401)
// Une autre session, jamais fermée, passe toujours
expect((await call('GET', '/auth/me', other)).status).toBe(200)
})
it('une déconnexion expirée est effacée au démarrage', async () => {
const { prisma } = await import('../src/lib/prisma.js')
const jti = `expire-${run}`
await prisma.revokedSession.create({ data: { jti, expiresAt: new Date(Date.now() - 1000) } })
await restart()
expect(await prisma.revokedSession.findUnique({ where: { jti } })).toBeNull()
})
})
+6
View File
@@ -14,6 +14,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
count: vi.fn().mockResolvedValue(1),
+6
View File
@@ -7,6 +7,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
count: vi.fn(),
},
+6
View File
@@ -10,6 +10,12 @@ import { beforeAll, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
count: vi.fn().mockResolvedValue(1),
},
+6
View File
@@ -9,6 +9,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
count: vi.fn(),
},
+6
View File
@@ -4,6 +4,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
count: vi.fn().mockResolvedValue(1),
+6
View File
@@ -4,6 +4,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
count: vi.fn().mockResolvedValue(1),
+6
View File
@@ -6,6 +6,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
// fournisseur à part. Ce fichier vérifie ce que Parenthèse décide avec ce que le fournisseur renvoie.
const { prismaMock, oidcMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
create: vi.fn(),
+6
View File
@@ -5,6 +5,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
// se connectent, et une instance neuve accepte quand même son premier compte.
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
create: vi.fn(),
+50
View File
@@ -0,0 +1,50 @@
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
// Les sessions fermées se rechargent avant la première requête : sans elles, l'API refuse de démarrer plutôt que
// de rouvrir chaque session fermée sans que personne le voie.
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
revokedSession: {
upsert: vi.fn(),
findMany: vi.fn(),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
$disconnect: vi.fn(),
},
}))
vi.mock('../src/lib/prisma.js', () => ({
prisma: prismaMock,
}))
describe('sessions fermées au démarrage', () => {
let createApp: typeof import('../src/app.js')['createApp']
beforeAll(async () => {
process.env.NODE_ENV = 'test'
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
process.env.CORS_ORIGIN = 'http://localhost:5173'
;({ createApp } = await import('../src/app.js'))
})
afterEach(() => {
vi.clearAllMocks()
})
it('recharge les sessions fermées, et un jeton fermé est refusé dès la première requête', async () => {
prismaMock.revokedSession.findMany.mockResolvedValue([{ jti: 'ferme-1', expiresAt: new Date(Date.now() + 3600_000) }])
const app = createApp()
await app.ready()
const { isUserTokenRevoked } = await import('../src/lib/token-revocation.js')
expect(isUserTokenRevoked('ferme-1')).toBe(true)
expect(prismaMock.revokedSession.deleteMany).toHaveBeenCalled()
await app.close()
})
it('ne démarre pas si la base ne répond pas', async () => {
prismaMock.revokedSession.findMany.mockRejectedValue(new Error('base injoignable'))
const app = createApp()
await expect(app.ready()).rejects.toThrow('base injoignable')
})
})
+6
View File
@@ -7,6 +7,12 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: { count: vi.fn().mockResolvedValue(1) },
treeMembership: { findUnique: vi.fn() },
userTreeAccess: { findUnique: vi.fn().mockResolvedValue(null) },