mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
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:
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");
|
||||
@@ -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
@@ -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, {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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) },
|
||||
|
||||
Reference in new issue
Block a user