diff --git a/backend/prisma/migrations/20261026_001_deconnexions/migration.sql b/backend/prisma/migrations/20261026_001_deconnexions/migration.sql new file mode 100644 index 0000000..b72d021 --- /dev/null +++ b/backend/prisma/migrations/20261026_001_deconnexions/migration.sql @@ -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"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index efe2104..aae8be7 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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. diff --git a/backend/src/app.ts b/backend/src/app.ts index 3c933a3..35df21a 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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, { diff --git a/backend/src/lib/token-revocation.ts b/backend/src/lib/token-revocation.ts index 44fe0fa..dfdf3ea 100644 --- a/backend/src/lib/token-revocation.ts +++ b/backend/src/lib/token-revocation.ts @@ -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() function nowSeconds(): number { return Math.floor(Date.now() / 1000) } -export function revokeUserToken(jti: string, exp: number): void { +type RevocationStore = Pick + +export async function revokeUserToken(store: RevocationStore, jti: string, exp: number): Promise { 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 { + 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() +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 959cfc3..559f428 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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 }) diff --git a/backend/test/api-routes.test.ts b/backend/test/api-routes.test.ts index 1f32072..b7cde91 100644 --- a/backend/test/api-routes.test.ts +++ b/backend/test/api-routes.test.ts @@ -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), diff --git a/backend/test/api-security.integration.test.ts b/backend/test/api-security.integration.test.ts index 72f4e65..f178306 100644 --- a/backend/test/api-security.integration.test.ts +++ b/backend/test/api-security.integration.test.ts @@ -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), diff --git a/backend/test/auth-prenom.test.ts b/backend/test/auth-prenom.test.ts index e778246..83bd63b 100644 --- a/backend/test/auth-prenom.test.ts +++ b/backend/test/auth-prenom.test.ts @@ -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(), diff --git a/backend/test/avatar-crop.test.ts b/backend/test/avatar-crop.test.ts index 90d45c5..c5fa192 100644 --- a/backend/test/avatar-crop.test.ts +++ b/backend/test/avatar-crop.test.ts @@ -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(), }, diff --git a/backend/test/data-deletion.test.ts b/backend/test/data-deletion.test.ts index 0919f42..d422ef0 100644 --- a/backend/test/data-deletion.test.ts +++ b/backend/test/data-deletion.test.ts @@ -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(), diff --git a/backend/test/deconnexion.db.test.ts b/backend/test/deconnexion.db.test.ts new file mode 100644 index 0000000..a417fa5 --- /dev/null +++ b/backend/test/deconnexion.db.test.ts @@ -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, + 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() + }) +}) diff --git a/backend/test/e2e-journeys.test.ts b/backend/test/e2e-journeys.test.ts index ac7fe9c..b7328bf 100644 --- a/backend/test/e2e-journeys.test.ts +++ b/backend/test/e2e-journeys.test.ts @@ -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), diff --git a/backend/test/immich-config.test.ts b/backend/test/immich-config.test.ts index 3d5f090..fbd7902 100644 --- a/backend/test/immich-config.test.ts +++ b/backend/test/immich-config.test.ts @@ -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(), }, diff --git a/backend/test/immich-unconfigured.test.ts b/backend/test/immich-unconfigured.test.ts index 9702573..4ae5442 100644 --- a/backend/test/immich-unconfigured.test.ts +++ b/backend/test/immich-unconfigured.test.ts @@ -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), }, diff --git a/backend/test/immich.test.ts b/backend/test/immich.test.ts index c8d2b62..c3062e6 100644 --- a/backend/test/immich.test.ts +++ b/backend/test/immich.test.ts @@ -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(), }, diff --git a/backend/test/media-people.test.ts b/backend/test/media-people.test.ts index bf567de..976068b 100644 --- a/backend/test/media-people.test.ts +++ b/backend/test/media-people.test.ts @@ -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), diff --git a/backend/test/media-update.test.ts b/backend/test/media-update.test.ts index 64699c5..58d4fa4 100644 --- a/backend/test/media-update.test.ts +++ b/backend/test/media-update.test.ts @@ -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), diff --git a/backend/test/oidc.test.ts b/backend/test/oidc.test.ts index 9477f96..2d1648b 100644 --- a/backend/test/oidc.test.ts +++ b/backend/test/oidc.test.ts @@ -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(), diff --git a/backend/test/registration-closed.test.ts b/backend/test/registration-closed.test.ts index 299a3d9..1c3a7ec 100644 --- a/backend/test/registration-closed.test.ts +++ b/backend/test/registration-closed.test.ts @@ -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(), diff --git a/backend/test/revocation-demarrage.test.ts b/backend/test/revocation-demarrage.test.ts new file mode 100644 index 0000000..2debb15 --- /dev/null +++ b/backend/test/revocation-demarrage.test.ts @@ -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') + }) +}) diff --git a/backend/test/search-sources.test.ts b/backend/test/search-sources.test.ts index 7fd2fcb..72ff65d 100644 --- a/backend/test/search-sources.test.ts +++ b/backend/test/search-sources.test.ts @@ -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) },