From 005f88d7ca9228ab294aba013a58d20a85615b29 Mon Sep 17 00:00:00 2001 From: hide_d Date: Fri, 9 Oct 2026 05:59:28 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B2=8C=EC=9E=84=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=ED=9A=8C=EC=88=98=EB=A5=BC=20Redis=EC=97=90=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80=ED=95=98=EA=B3=A0=20=EC=9E=AC=EC=8B=9C=EC=9E=91?= =?UTF-8?q?=EA=B3=BC=20=EC=95=8C=EB=A6=BC=20=EB=88=84=EB=9D=BD=EC=9D=84=20?= =?UTF-8?q?=EB=B0=A9=EC=96=B4=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/auth/flushStore.ts | 66 ++++++++++++- app/game-api/src/router/auth/index.ts | 10 +- app/game-api/src/server.ts | 31 +++++- ...diplomacyHtmlTransport.integration.test.ts | 3 + app/game-api/test/flushStore.test.ts | 85 +++++++++++++++- .../nationHtmlTransport.integration.test.ts | 3 + .../securityTransport.integration.test.ts | 98 ++++++++++++++++++- app/gateway-api/src/auth/flushPublisher.ts | 55 ++++++++++- app/gateway-api/src/router.ts | 4 +- app/gateway-api/src/server.ts | 11 +++ app/gateway-api/test/authFlow.test.ts | 24 +++++ app/gateway-api/test/flushPublisher.test.ts | 79 +++++++++++++++ docs/release-operations.md | 46 ++++++++- packages/common/package.json | 4 + packages/common/src/auth/sessionRevocation.ts | 55 +++++++++++ .../common/test/session-revocation.test.ts | 47 +++++++++ packages/common/tsdown.config.ts | 1 + 17 files changed, 605 insertions(+), 17 deletions(-) create mode 100644 app/gateway-api/test/flushPublisher.test.ts create mode 100644 packages/common/src/auth/sessionRevocation.ts create mode 100644 packages/common/test/session-revocation.test.ts diff --git a/app/game-api/src/auth/flushStore.ts b/app/game-api/src/auth/flushStore.ts index 41ab28af..e17a32c1 100644 --- a/app/game-api/src/auth/flushStore.ts +++ b/app/game-api/src/auth/flushStore.ts @@ -1,3 +1,10 @@ +import { + buildSessionRevocationBaselineKey, + buildUserSessionRevocationKey, + parseSessionRevocationWatermark, + SessionRevocationCommandBudget, +} from '@sammo-ts/common/auth/sessionRevocation'; + export interface GatewayUserFlushEvent { userId: string; flushedAt: string; @@ -8,7 +15,7 @@ export interface GatewayUserFlushEvent { } export interface FlushStore { - getFlushedAt(userId: string): Date | null; + getFlushedAt(userId: string): Date | null | Promise; applyFlush(event: GatewayUserFlushEvent): void; } @@ -31,6 +38,63 @@ export class InMemoryFlushStore implements FlushStore { } } +interface RevocationRedisClient { + readonly isReady?: boolean; + mGet(keys: string[]): Promise<(string | null)[]>; + withAbortSignal?(signal: AbortSignal): RevocationRedisClient; +} + +/** Pub/sub accelerates closure; Redis is checked again at every authorization boundary. */ +export class RedisFlushStore implements FlushStore { + private readonly local = new Map(); + private readonly commands = new SessionRevocationCommandBudget(64); + constructor( + private readonly client: RevocationRedisClient, + private readonly channel: string + ) {} + + private commandClient(): RevocationRedisClient { + if (this.client.isReady === false) throw new Error('Session revocation storage is unavailable.'); + return this.client.withAbortSignal?.(AbortSignal.timeout(2000)) ?? this.client; + } + + async assertInitialized(): Promise { + const values = await this.commands.run(() => + this.commandClient().mGet([buildSessionRevocationBaselineKey(this.channel)]) + ); + if (values.length !== 1 || !parseSessionRevocationWatermark(values[0]!)) + throw new Error('Gateway session revocation must be initialized before game API startup.'); + } + + async getFlushedAt(userId: string): Promise { + const values = await this.commands.run(() => + this.commandClient().mGet([ + buildSessionRevocationBaselineKey(this.channel), + buildUserSessionRevocationKey(this.channel, userId), + ]) + ); + if (values.length !== 2) throw new Error('Session revocation storage is unavailable.'); + const baseline = parseSessionRevocationWatermark(values[0]!); + if (!baseline) throw new Error('Session revocation storage is unavailable.'); + const persisted = parseSessionRevocationWatermark(values[1]!); + return new Date( + Math.max(baseline.getTime(), persisted?.getTime() ?? 0, this.local.get(userId)?.getTime() ?? 0) + ); + } + + applyFlush(event: GatewayUserFlushEvent): void { + const parsed = new Date(event.flushedAt); + if (Number.isNaN(parsed.getTime())) return; + const prior = this.local.get(event.userId); + if (!prior || parsed > prior) { + this.local.delete(event.userId); + this.local.set(event.userId, parsed); + } + // 이 캐시는 재시작/eviction 뒤에도 Redis watermark를 확인하므로 권한 근거가 아니다. + if (this.local.size > 4096) this.local.delete(this.local.keys().next().value!); + } +} + export class RedisGatewayFlushSubscriber { private readonly client: { subscribe: (channel: string, listener: (message: string) => void) => Promise; diff --git a/app/game-api/src/router/auth/index.ts b/app/game-api/src/router/auth/index.ts index cc77befc..6c8b1f25 100644 --- a/app/game-api/src/router/auth/index.ts +++ b/app/game-api/src/router/auth/index.ts @@ -66,7 +66,15 @@ export const authRouter = router({ message: 'Game access is restricted for this account.', }); } - const flushedAt = ctx.flushStore.getFlushedAt(payload.user.id); + let flushedAt: Date | null; + try { + flushedAt = await ctx.flushStore.getFlushedAt(payload.user.id); + } catch { + throw new TRPCError({ + code: 'SERVICE_UNAVAILABLE', + message: 'Authentication protection is unavailable.', + }); + } if (flushedAt && new Date(payload.issuedAt) <= flushedAt) { throw new TRPCError({ code: 'UNAUTHORIZED', diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index 8adeb378..ca6e783d 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -4,6 +4,7 @@ import cors from '@fastify/cors'; import fastifyStatic from '@fastify/static'; import path from 'path'; import fs from 'node:fs/promises'; +import { TRPCError } from '@trpc/server'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; import { buildGameEventChannel, @@ -22,7 +23,7 @@ import { import { resolveGameApiConfigFromEnv } from './config.js'; import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './context.js'; import { DatabaseTurnDaemonTransport } from './daemon/databaseTransport.js'; -import { InMemoryFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js'; +import { RedisFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js'; import { RedisAccessTokenStore } from './auth/accessTokenStore.js'; import { consumeRealtimeAccessGrantHeader, @@ -83,7 +84,12 @@ const resolveAuthFromToken = async ( if (!stored) { return null; } - const flushedAt = flushStore.getFlushedAt(stored.user.id); + let flushedAt: Date | null; + try { + flushedAt = await flushStore.getFlushedAt(stored.user.id); + } catch { + throw new TRPCError({ code: 'SERVICE_UNAVAILABLE', message: 'Authentication protection is unavailable.' }); + } if (flushedAt && new Date(stored.issuedAt) <= flushedAt) { return null; } @@ -142,7 +148,18 @@ export const createGameApiServer = async () => { requestTimeoutMs: config.battleSimRequestTimeoutMs, resultTtlSeconds: config.battleSimResultTtlSeconds, }); - const flushStore = new InMemoryFlushStore(); + const flushStore = new RedisFlushStore(redis.client, config.flushChannel); + try { + await flushStore.assertInitialized(); + } catch { + await Promise.allSettled([ + Promise.resolve().then(() => { + if (redis.client.isOpen) redis.client.destroy(); + }), + postgres.disconnect(), + ]); + throw new Error('Gateway session revocation is unavailable; game API startup refused.'); + } const flushSubscriberClient = redis.client.duplicate(); try { await flushSubscriberClient.connect(); @@ -328,7 +345,13 @@ export const createGameApiServer = async () => { const query = request.query as { token?: string; scope?: string }; const subscriptionScope = query.scope === 'tournament' ? 'tournament' : 'dashboard'; const tokenFromHeader = extractBearerToken(request.headers.authorization); - const auth = query.token === undefined ? await resolveRealtimeAuth(tokenFromHeader) : null; + let auth: GameSessionTokenPayload | null = null; + try { + auth = query.token === undefined ? await resolveRealtimeAuth(tokenFromHeader) : null; + } catch { + await reply.status(503).send({ ok: false, error: 'authentication_unavailable' }); + return; + } if (!auth || auth.profile !== config.profileName) { await reply.status(401).send({ ok: false, error: 'unauthorized' }); diff --git a/app/game-api/test/diplomacyHtmlTransport.integration.test.ts b/app/game-api/test/diplomacyHtmlTransport.integration.test.ts index 3c58ee52..812b10e7 100644 --- a/app/game-api/test/diplomacyHtmlTransport.integration.test.ts +++ b/app/game-api/test/diplomacyHtmlTransport.integration.test.ts @@ -68,6 +68,7 @@ const restoreEnv = (): void => { const deleteProfileRedisKeys = async (): Promise => { if (!redis) return; + await redis.client.del(`${redisPrefix}:flush:revocation:v1:baseline`); for await (const keys of redis.client.scanIterator({ MATCH: `sammo:game:*:${profileName}:*`, COUNT: 100 })) { if (keys.length > 0) await redis.client.del(keys); } @@ -199,6 +200,8 @@ integration('diplomacy HTML purification over PostgreSQL and HTTP transport', () if (!created) throw new Error('failed to seed diplomacy HTML access token'); accessToken = created.accessToken; + // 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다. + await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true }); server = await createGameApiServer(); baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port }); }, 30_000); diff --git a/app/game-api/test/flushStore.test.ts b/app/game-api/test/flushStore.test.ts index 72f34742..386fa2b9 100644 --- a/app/game-api/test/flushStore.test.ts +++ b/app/game-api/test/flushStore.test.ts @@ -1,6 +1,8 @@ +import { createServer, type Socket } from 'node:net'; +import { createClient } from 'redis'; import { describe, expect, it, vi } from 'vitest'; -import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from '../src/auth/flushStore.js'; +import { InMemoryFlushStore, RedisFlushStore, RedisGatewayFlushSubscriber } from '../src/auth/flushStore.js'; describe('RedisGatewayFlushSubscriber', () => { it('reports asynchronous flush handler failures with event context', async () => { @@ -66,3 +68,84 @@ describe('RedisGatewayFlushSubscriber', () => { expect(stopped).toBe(true); }); }); + +describe('RedisFlushStore authorization state', () => { + it('merges startup, persistent and local watermarks without caching an allow result', async () => { + let persisted: string | null = null; + const mGet = vi.fn(async (keys: string[]) => (keys.length === 1 ? ['100'] : ['100', persisted])); + const store = new RedisFlushStore({ mGet }, 'fixture:flush'); + await store.assertInitialized(); + expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(100); + persisted = '200'; + expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(200); + store.applyFlush({ userId: 'user-1', flushedAt: new Date(300).toISOString() }); + expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(300); + expect(mGet).toHaveBeenCalledTimes(4); + }); + it('retains revocation after local cache eviction and a fresh store instance', async () => { + const client = { mGet: async (keys: string[]) => (keys.length === 1 ? ['0'] : ['0', '200']) }; + const store = new RedisFlushStore(client, 'fixture:flush'); + for (let i = 0; i < 4100; i++) + store.applyFlush({ userId: `user-${i}`, flushedAt: new Date(100).toISOString() }); + expect((await store.getFlushedAt('user-0'))?.getTime()).toBe(200); + expect((await new RedisFlushStore(client, 'fixture:flush').getFlushedAt('user-0'))?.getTime()).toBe(200); + }); + it.each([null, '', 'invalid', '-1', '8640000000000001'])( + 'rejects missing or corrupt initialization %s', + async (baseline) => { + const store = new RedisFlushStore({ mGet: async () => [baseline] }, 'fixture:flush'); + await expect(store.assertInitialized()).rejects.toThrow(); + } + ); + it('rejects corrupted per-user state instead of treating it as never revoked', async () => { + const store = new RedisFlushStore({ mGet: async () => ['0', 'not-a-watermark'] }, 'fixture:flush'); + await expect(store.getFlushedAt('user-1')).rejects.toThrow(); + }); + it('does not perform an offline read and bounds a connected read with an abort signal', async () => { + const offlineGet = vi.fn(async () => ['0', null]); + const offline = new RedisFlushStore({ isReady: false, mGet: offlineGet }, 'fixture:flush'); + await expect(offline.getFlushedAt('user')).rejects.toThrow('unavailable'); + expect(offlineGet).not.toHaveBeenCalled(); + const mGet = vi.fn(async () => ['0', '100']); + const withAbortSignal = vi.fn((_signal: AbortSignal) => ({ mGet })); + const store = new RedisFlushStore({ mGet, withAbortSignal }, 'fixture:flush'); + expect((await store.getFlushedAt('user'))?.getTime()).toBe(100); + expect(withAbortSignal.mock.calls[0]?.[0]).toBeInstanceOf(AbortSignal); + }); +}); + +it('bounds a connected Redis read when the actual TCP peer stops replying', async () => { + const sockets = new Set(); + let receivedRead = false; + const server = createServer((socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + socket.on('data', (data) => { + const request = data.toString(); + // Real node-redis handshake succeeds; its MGET command receives no reply. + if (request.includes('MGET')) { + receivedRead = true; + return; + } + const count = [...request.matchAll(/\*\d+\r\n/g)].length; + socket.write('+OK\r\n'.repeat(count)); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected TCP fixture address'); + const client = createClient({ socket: { host: '127.0.0.1', port: address.port, reconnectStrategy: false } }); + try { + await client.connect(); + expect(client.isReady).toBe(true); + const start = Date.now(); + await expect(new RedisFlushStore(client, 'fixture:flush').getFlushedAt('user')).rejects.toThrow(); + expect(receivedRead).toBe(true); + expect(Date.now() - start).toBeGreaterThanOrEqual(1800); + expect(Date.now() - start).toBeLessThan(4500); + } finally { + if (client.isOpen) client.destroy(); + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); diff --git a/app/game-api/test/nationHtmlTransport.integration.test.ts b/app/game-api/test/nationHtmlTransport.integration.test.ts index 0d83c391..d11d6e92 100644 --- a/app/game-api/test/nationHtmlTransport.integration.test.ts +++ b/app/game-api/test/nationHtmlTransport.integration.test.ts @@ -62,6 +62,7 @@ const deleteProfileRedisKeys = async (): Promise => { if (!redis) { return; } + await redis.client.del(`${redisPrefix}:flush:revocation:v1:baseline`); for await (const keys of redis.client.scanIterator({ MATCH: `sammo:game:*:${profileName}:*`, COUNT: 100, @@ -155,6 +156,8 @@ integration('nation HTML purification over HTTP transport', () => { } accessToken = created.accessToken; + // 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다. + await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true }); server = await createGameApiServer(); baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port }); }, 30_000); diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index e869dd79..d2936669 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -173,7 +173,7 @@ const deleteProfileRedisKeys = async (): Promise => { if (!redis) { return; } - for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) { + for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`, `${redisPrefix}:*`]) { for await (const keys of redis.client.scanIterator({ MATCH: pattern, COUNT: 100, @@ -417,7 +417,7 @@ const readAccessTelemetryState = async () => ({ const readRealtimeRedisState = async (): Promise> => { if (!redis) return []; const keys = new Set(); - for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) { + for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`, `${redisPrefix}:*`]) { for await (const batch of redis.client.scanIterator({ MATCH: pattern, COUNT: 100 })) { for (const key of batch) keys.add(key); } @@ -743,6 +743,8 @@ integration('game API security over HTTP transport', () => { await redis.connect(); accessTokenStore = new RedisAccessTokenStore(redis.client, profileName); + // 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다. + await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true }); server = await createGameApiServer(); baseUrl = await server.app.listen({ host: server.config.host, @@ -3675,6 +3677,98 @@ integration('game API security over HTTP transport', () => { } }); + it('rejects access using persisted revocation state without any pubsub delivery', async () => { + const actor = `security-http-durable-missed-${process.pid}`; + const accessToken = await createAccessToken('durable-missed', {}, actor); + try { + expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(200); + await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now())); + expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401); + } finally { + await accessTokenStore.revoke(accessToken); + } + }); + + it('retains persisted revocation state across an actual API restart', async () => { + const actor = `security-http-durable-restart-${process.pid}`; + const accessToken = await createAccessToken('durable-restart', {}, actor); + try { + const gatewayToken = encryptGameSessionToken(buildPayload('unused-durable-restart', {}, actor), secret); + await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now())); + await server!.app.close(); + server = await createGameApiServer(); + baseUrl = await server.app.listen({ host: '127.0.0.1', port: 0 }); + expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401); + const exchanged = await requestTrpc('auth.exchangeGatewayToken', { + method: 'POST', + input: { gatewayToken }, + }); + expect(exchanged.response.status).toBe(401); + } finally { + await accessTokenStore.revoke(accessToken); + } + }); + + it('SSE closes after persisted revocation even when the flush notification was missed', async () => { + const actor = `security-http-durable-sse-${process.pid}`; + const accessToken = await createAccessToken('durable-sse', {}, actor); + const stream = await openRealtime(accessToken); + try { + await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now())); + await redis!.client.publish( + buildGameEventChannel(profileName), + JSON.stringify({ type: 'tournamentChanged' }) + ); + expect(await stream.reader.read()).toMatchObject({ done: true }); + } finally { + stream.abort.abort(); + await accessTokenStore.revoke(accessToken); + } + }); + + it('denies legacy credentials before the initial cutoff and admits a fresh one afterwards', async () => { + const actor = `security-http-durable-cutoff-${process.pid}`; + const accessToken = await createAccessToken('durable-cutoff', {}, actor); + const key = `${redisPrefix}:flush:revocation:v1:baseline`; + try { + await redis!.client.set(key, String(Date.now())); + expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401); + const fresh = await accessTokenStore.create({ + ...buildPayload('fresh-cutoff', {}, actor), + issuedAt: new Date(Date.now() + 1).toISOString(), + }); + expect(fresh).not.toBeNull(); + try { + expect((await requestTrpc('auth.status', { accessToken: fresh!.accessToken })).response.status).toBe( + 200 + ); + } finally { + await accessTokenStore.revoke(fresh!.accessToken); + } + } finally { + await redis!.client.set(key, '0'); + await accessTokenStore.revoke(accessToken); + } + }); + + it('fails closed for missing initialization in HTTP, SSE and API startup', async () => { + const actor = `security-http-durable-missing-${process.pid}`; + const accessToken = await createAccessToken('durable-missing', {}, actor); + const key = `${redisPrefix}:flush:revocation:v1:baseline`; + try { + await redis!.client.del(key); + expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(503); + const sse = await fetch(`${baseUrl}${server!.config.eventsPath}`, { + headers: { authorization: `Bearer ${accessToken}` }, + }); + expect(sse.status).toBe(503); + await expect(createGameApiServer()).rejects.toThrow('startup refused'); + } finally { + await redis!.client.set(key, '0'); + await accessTokenStore.revoke(accessToken); + } + }); + // Flush invalidates every token issued before the user watermark. Keep it // last so this lifecycle assertion cannot invalidate the actor tokens used // by the transport authorization matrix above. diff --git a/app/gateway-api/src/auth/flushPublisher.ts b/app/gateway-api/src/auth/flushPublisher.ts index 86cce640..6e0679e0 100644 --- a/app/gateway-api/src/auth/flushPublisher.ts +++ b/app/gateway-api/src/auth/flushPublisher.ts @@ -1,3 +1,28 @@ +import { + buildSessionRevocationBaselineKey, + buildUserSessionRevocationKey, + SessionRevocationCommandBudget, +} from '@sammo-ts/common/auth/sessionRevocation'; + +interface RedisClientLike { + readonly isReady?: boolean; + set(key: string, value: string, options: { NX: true }): Promise; + eval(script: string, options: { keys: string[]; arguments: string[] }): Promise; + withAbortSignal?(signal: AbortSignal): RedisClientLike; +} + +// 지속 watermark를 먼저 저장한 뒤 알림을 보낸다. 구독자가 0이어도 회수는 유효하다. +const PUBLISH_FLUSH_SCRIPT = ` +redis.call('SET', KEYS[1], ARGV[1], 'NX') +local raw = redis.call('GET', KEYS[2]) +local prior = tonumber(raw) +if raw and not prior then return redis.error_reply('Invalid session revocation state') end +local cutoff = math.max(prior or 0, tonumber(ARGV[1])) +redis.call('SET', KEYS[2], string.format('%.0f', cutoff)) +redis.call('PUBLISH', ARGV[2], ARGV[3]) +return 1 +`; + export interface GatewayUserFlushEvent { userId: string; flushedAt: string; @@ -17,13 +42,28 @@ export interface GatewayFlushPublisher { export class RedisGatewayFlushPublisher implements GatewayFlushPublisher { private readonly channel: string; - private readonly client: { publish: (channel: string, message: string) => Promise }; + private readonly client: RedisClientLike; + private readonly commands = new SessionRevocationCommandBudget(32); - constructor(client: { publish: (channel: string, message: string) => Promise }, channel: string) { + constructor(client: RedisClientLike, channel: string) { this.client = client; this.channel = channel; } + private commandClient(): RedisClientLike { + if (this.client.isReady === false) throw new Error('Session revocation storage is unavailable.'); + return this.client.withAbortSignal?.(AbortSignal.timeout(2000)) ?? this.client; + } + + async initialize(): Promise { + // 새 계약 최초 활성화에서만 기존 게임 credential을 회수한다. 재시작은 cutoff를 보존한다. + await this.commands.run(() => + this.commandClient().set(buildSessionRevocationBaselineKey(this.channel), String(Date.now()), { + NX: true, + }) + ); + } + async publishUserFlush( userId: string, reason?: string, @@ -37,6 +77,15 @@ export class RedisGatewayFlushPublisher implements GatewayFlushPublisher { ...(metadata?.displayName ? { displayName: metadata.displayName } : {}), ...(metadata?.identityRevision ? { identityRevision: metadata.identityRevision } : {}), }; - await this.client.publish(this.channel, JSON.stringify(payload)); + const result = await this.commands.run(() => + this.commandClient().eval(PUBLISH_FLUSH_SCRIPT, { + keys: [ + buildSessionRevocationBaselineKey(this.channel), + buildUserSessionRevocationKey(this.channel, userId), + ], + arguments: [String(Date.parse(payload.flushedAt)), this.channel, JSON.stringify(payload)], + }) + ); + if (result !== 1) throw new Error('Session revocation persistence failed.'); } } diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index 7e9122eb..bab1c949 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -1056,6 +1056,8 @@ export const appRouter = router({ }) ) .mutation(async ({ ctx, input }) => { + // 권한 snapshot보다 이후에 발생한 flush도 이 발급을 회수해야 한다. + const authorizationStartedAt = Date.now(); const gatewaySession = await ctx.sessions.getSession(input.sessionToken); if (!gatewaySession) { throw new TRPCError({ @@ -1108,7 +1110,7 @@ export const appRouter = router({ }); } // 발급 뒤 logout/flush가 겹쳐도 issuedAt을 나중 시각으로 다시 쓰지 않는다. - const now = new Date(gameSession.issuedAt); + const now = new Date(Math.min(authorizationStartedAt, Date.parse(gameSession.issuedAt))); const accountIcon = resolveEffectiveAccountIcon(user); const accountIcons = await ctx.users.listIcons(user.id); const payload = { diff --git a/app/gateway-api/src/server.ts b/app/gateway-api/src/server.ts index d8ee053f..49b3dcd7 100644 --- a/app/gateway-api/src/server.ts +++ b/app/gateway-api/src/server.ts @@ -73,6 +73,17 @@ export const createGatewayApiServer = async () => { config.gameTokenSecret ); const flushPublisher = new RedisGatewayFlushPublisher(redis.client, config.flushChannel); + try { + await flushPublisher.initialize(); + } catch { + await Promise.allSettled([ + Promise.resolve().then(() => { + if (redis.client.isOpen) redis.client.destroy(); + }), + postgres.disconnect(), + ]); + throw new Error('Gateway session revocation initialization failed.'); + } const kakaoClient = new KakaoOAuthClient({ restKey: config.kakaoRestKey, adminKey: config.kakaoAdminKey, diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index df97e95a..f9b15da2 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -1242,6 +1242,30 @@ describe('gateway auth flow', () => { expect(validated?.user.username).toBe('tester'); }); + it('starts token age before the user snapshot so a concurrent role flush can revoke stale identity', async () => { + const { caller, users, sessions } = buildCaller(); + const user = await users.createUser({ username: 'role-snapshot-race', password: 'synthetic-password' }); + const parent = await sessions.createSession(user); + const issuedAt = new Date().toISOString(); + vi.useFakeTimers({ now: new Date(issuedAt) }); + const original = users.findById.bind(users); + const find = vi.spyOn(users, 'findById').mockImplementationOnce(async (id) => { + const snapshot = await original(id); + vi.advanceTimersByTime(5000); // admin mutation/flush arrives after the snapshot. + return snapshot; + }); + try { + const result = await caller.auth.issueGameSession({ + sessionToken: parent.sessionToken, + profile: 'che:default', + }); + expect(decryptGameSessionToken(result.gameToken, 'test-secret')?.issuedAt).toBe(issuedAt); + } finally { + find.mockRestore(); + vi.useRealTimers(); + } + }); + it('retains the original issuance instant when logout flush overlaps later identity reads', async () => { const { caller, users, sessions } = buildCaller(); const user = await users.createUser({ username: 'issue-flush-race', password: 'synthetic-password' }); diff --git a/app/gateway-api/test/flushPublisher.test.ts b/app/gateway-api/test/flushPublisher.test.ts new file mode 100644 index 00000000..8bdc6149 --- /dev/null +++ b/app/gateway-api/test/flushPublisher.test.ts @@ -0,0 +1,79 @@ +import { randomUUID } from 'node:crypto'; +import { createClient } from 'redis'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + buildSessionRevocationBaselineKey, + buildUserSessionRevocationKey, +} from '@sammo-ts/common/auth/sessionRevocation'; +import { RedisGatewayFlushPublisher } from '../src/auth/flushPublisher.js'; + +const url = process.env.GATEWAY_SESSION_REDIS_TEST_URL; +describe.skipIf(!url)('persistent Gateway flush over real Redis', () => { + const client = createClient({ url }); + const channel = `revocation-publisher-test:${randomUUID()}:flush`; + const publisher = new RedisGatewayFlushPublisher(client, channel); + beforeAll(async () => { + await client.connect(); + }); + afterAll(async () => { + for await (const keys of client.scanIterator({ MATCH: `${channel}:*`, COUNT: 100 })) + if (keys.length) await client.del(keys); + await client.quit(); + }); + it('initializes once and preserves the original cutoff across publisher restarts', async () => { + await publisher.initialize(); + const baseline = await client.get(buildSessionRevocationBaselineKey(channel)); + expect(Number(baseline)).toBeGreaterThan(0); + await new RedisGatewayFlushPublisher(client, channel).initialize(); + expect(await client.get(buildSessionRevocationBaselineKey(channel))).toBe(baseline); + expect(await client.pTTL(buildSessionRevocationBaselineKey(channel))).toBe(-1); + }); + it('persists a revocation when there are zero subscribers', async () => { + const userId = randomUUID(); + await publisher.publishUserFlush(userId, 'logout'); + expect(Number(await client.get(buildUserSessionRevocationKey(channel, userId)))).toBeGreaterThan(0); + expect(await client.pTTL(buildUserSessionRevocationKey(channel, userId))).toBe(-1); + }); + it('makes the watermark readable before delivering flush metadata', async () => { + const subscriber = client.duplicate(); + await subscriber.connect(); + const userId = randomUUID(); + let delivered: { event: Record; persisted: string | null } | undefined; + await subscriber.subscribe(channel, async (raw) => { + delivered = { + event: JSON.parse(raw) as Record, + persisted: await client.get(buildUserSessionRevocationKey(channel, userId)), + }; + }); + try { + await publisher.publishUserFlush(userId, 'identity-updated', { + displayName: 'fixture', + identityRevision: 'revision-1', + }); + await vi.waitFor(() => expect(delivered).toBeDefined()); + expect(delivered!.event).toMatchObject({ + userId, + reason: 'identity-updated', + displayName: 'fixture', + identityRevision: 'revision-1', + }); + expect(Number(delivered!.persisted)).toBe(Date.parse(delivered!.event.flushedAt as string)); + } finally { + await subscriber.unsubscribe(channel); + await subscriber.quit(); + } + }); + it('never moves a newer watermark backwards under late or concurrent flushes', async () => { + const userId = randomUUID(); + const future = String(Date.now() + 60_000); + await client.set(buildUserSessionRevocationKey(channel, userId), future); + await Promise.all(Array.from({ length: 8 }, () => publisher.publishUserFlush(userId, 'concurrent'))); + expect(await client.get(buildUserSessionRevocationKey(channel, userId))).toBe(future); + }); + it('fails closed for an unavailable client instead of publishing without storage', async () => { + const offline = client.duplicate(); + const closed = new RedisGatewayFlushPublisher(offline, channel); + await expect(closed.initialize()).rejects.toThrow('unavailable'); + await expect(closed.publishUserFlush(randomUUID(), 'logout')).rejects.toThrow('unavailable'); + }); +}); diff --git a/docs/release-operations.md b/docs/release-operations.md index cbb02f78..08d40013 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -552,7 +552,8 @@ Caddy/HTTPS, 방화벽과 실제 운영 DB 전환을 증명하지 않습니다. 갱신하면 구형 탭의 실시간 갱신이 중단될 수 있다. SSE는 최초 요청과 매 frame 작업 전·전송 전에 Redis token, flush watermark, -user/session/profile과 게임 접근 제재를 확인한다. 만료 타이머와 Gateway flush가 +user/session/profile과 게임 접근 제재를 확인한다. flush watermark는 아래 지속 회수 +계약에 따라 Redis에서 읽으며 메모리에만 의존하지 않는다. 만료 타이머와 Gateway flush가 대기 중인 연결을 닫고 Redis 장애는 fail closed로 처리한다. 연결별 대기 작업은 32개, frame과 socket buffer는 64 KiB로 제한하고 slow consumer를 닫는다. 재연결 뒤 메인은 기존 갱신 큐로 snapshot을 복구한다. 게임 계산·RNG·DB schema와 @@ -638,10 +639,47 @@ Redis Lua가 부모 session의 존재·snapshot 일치·만료, 정리·상한 앞당기지는 않는다. index TTL은 남은 부모 TTL과 게임 TTL 중 큰 값이며 새 부모 TTL로 매번 초기화하지 않는다. 새로운 env key와 DB migration은 필요 없다. -암호화 게임 token의 issuedAt/expiresAt은 추적 항목의 최초 발급 시각에서 계산한다. +암호화 게임 token의 issuedAt/expiresAt은 권한 조회 시작과 추적 항목 생성 중 더 +이른 시각에서 계산한다. 이후 icon/identity 조회 중 logout/flush가 겹쳐도 issuedAt을 더 늦은 시각으로 -바꿔 이전 flush를 피하지 않는다. 이는 한 발급 flow의 경합 방어이며 pub/sub 누락이나 -API 재시작 후 회수 유지까지 증명하는 정책은 아니다. +바꿔 이전 flush를 피하지 않는다. user snapshot 이후 role/sanction 변경과 겹친 발급도 +그 flush보다 오래된 credential로 취급한다. 회수 지속성은 아래 별도 Redis 계약을 따른다. + +### 게임 token 회수의 지속성 (2026-10-09) + +Gateway는 기존 `GATEWAY_REDIS_PREFIX`의 flush channel 아래 `revocation:v1` namespace에 +최초 전환 cutoff와 사용자별 마지막 회수 시각을 저장한다. 사용자당 한 값이며 +DB/Redis credential, 게임 actor와 profile 경계는 바꾸지 않는다. Lua가 최신 watermark를 +보존하고 저장한 뒤 pub/sub을 보내므로 구독자 0명이나 알림 누락도 회수를 되돌리지 +않는다. HTTP 인증, 미사용 Gateway token 교환과 SSE 작업 전/쓰기 전이 매번 동일한 +Redis watermark와 최초 cutoff를 읽는다. allow 결과를 캐시하지 않고 Redis 조회는 +2초 deadline/연결 준비 상태 검사 후 fail closed다. pub/sub은 즉시 연결 종료와 +기존 identity/icon durable 동기화를 빠르게 하는 경로다. + +진행 중인 회수 Redis 명령은 Gateway publisher당 32개, game API store당 64개로 +제한한다. 2초 뒤 호출자가 실패해도 이미 전송된 명령은 실제 완료/연결 실패까지 +그 용량을 점유한다. 응답하지 않는 Redis가 caller timeout 뒤에도 계속 명령을 +쌓게 하지 않는다. startup 실패는 이 프로세스가 소유한 Redis 연결을 강제 종료한다. + +최초 Gateway 활성화 시 `SET NX`로 이전 게임 credential 전체를 회수하는 cutoff를 +한 번 만든다. 일반 Gateway session은 유지하므로 사용자는 Gateway에서 새 game token을 +받아 재접속한다. 반복 기동은 cutoff를 보존한다. 이 전환은 이전 메모리에서만 알고 +있던 회수 이력을 추측하여 이관하지 않고 기존 credential을 폐기하는 보안 정책이다. +Gateway publisher를 먼저 갱신한 뒤 모든 game API를 갱신한다. 새 game API는 초기화 +marker가 없거나 잘못됐으면 기동을 거절하며 실행 중 조회 장애/marker 누락도 인증을 +허용하지 않는다. 여러 Gateway writer가 있으면 모두 새 publisher 계약을 적용해야 한다. + +cutoff/watermark는 TTL로 삭제하지 않는다. game TTL 설정을 줄인 뒤 과거의 더 긴 +token이 다시 살아나는 것을 막는다. 저장량은 회수한 사용자 수에 비례하는 작은 +metadata이며 Redis의 기존 persistence/volume에 보존한다. 회수 key 수명 정리는 +모든 기존 credential의 만료를 증명한 별도 유지보수 작업이다. watermark 삭제, +Redis 복원/namespace 변경을 일반 restart와 같은 것으로 처리하지 않는다. + +Gateway DB 변경과 Redis 회수는 하나의 분산 transaction이 아니다. DB 변경 뒤 회수 +저장이 실패하면 API는 성공을 반환하지 않으며 운영자는 partial progress와 재시도를 +확인해야 한다. 이 계약은 성공한 회수의 pub/sub 누락·API 재시작 내구성을 보장하며, +DB/Redis backup의 시점 불일치나 이미 허용된 in-flight mutation을 자동 취소하는 +정책으로 확대하지 않는다. 기존 대기 frame/identity 동기화는 별도 기존 경계를 유지한다. ### Builder 요청 인증과 자원 경계 (2026-10-09) diff --git a/packages/common/package.json b/packages/common/package.json index 66c19d24..1a256cfc 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -14,6 +14,10 @@ "types": "./dist/auth/gameToken.d.ts", "default": "./dist/auth/gameToken.js" }, + "./auth/sessionRevocation": { + "types": "./dist/auth/sessionRevocation.d.ts", + "default": "./dist/auth/sessionRevocation.js" + }, "./auth/sanctions": { "types": "./dist/auth/sanctions.d.ts", "default": "./dist/auth/sanctions.js" diff --git a/packages/common/src/auth/sessionRevocation.ts b/packages/common/src/auth/sessionRevocation.ts new file mode 100644 index 00000000..830f2218 --- /dev/null +++ b/packages/common/src/auth/sessionRevocation.ts @@ -0,0 +1,55 @@ +// Gateway/game API는 같은 Redis namespace의 회수 계약을 공유한다. +// 최초 전환 cutoff와 사용자 watermark는 token TTL 설정 변화에도 유효해야 하므로 +// TTL로 지우지 않는다. 사용자당 한 값이며 삭제는 모든 기존 token 만료 확인 뒤 운영 작업이다. +export const buildSessionRevocationBaselineKey = (channel: string): string => `${channel}:revocation:v1:baseline`; +export const buildUserSessionRevocationKey = (channel: string, userId: string): string => + `${channel}:revocation:v1:user:${userId}`; + +export const parseSessionRevocationWatermark = (raw: string | null): Date | null => { + if (raw === null) return null; + if (!/^\d{1,16}$/.test(raw)) throw new Error('Invalid session revocation state.'); + const value = Number(raw); + if (!Number.isSafeInteger(value) || value > 8_640_000_000_000_000) + throw new Error('Invalid session revocation state.'); + return new Date(value); +}; + +/** A caller timeout does not prove a command already sent to Redis has settled. */ +export class SessionRevocationCommandBudget { + private pending = 0; + constructor(private readonly maxPending = 64) {} + + async run(start: () => Promise): Promise { + if (this.pending >= this.maxPending) throw new Error('Session revocation storage is unavailable.'); + this.pending += 1; + let command: Promise; + try { + command = start(); + } catch { + this.pending -= 1; + throw new Error('Session revocation storage is unavailable.'); + } + void command.then( + () => { + this.pending -= 1; + }, + () => { + this.pending -= 1; + } + ); + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + command, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error('Session revocation storage is unavailable.')), 2000); + timeout.unref(); + }), + ]); + } catch { + throw new Error('Session revocation storage is unavailable.'); + } finally { + clearTimeout(timeout); + } + } +} diff --git a/packages/common/test/session-revocation.test.ts b/packages/common/test/session-revocation.test.ts new file mode 100644 index 00000000..0a78edfe --- /dev/null +++ b/packages/common/test/session-revocation.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SessionRevocationCommandBudget, parseSessionRevocationWatermark } from '../src/auth/sessionRevocation.js'; + +describe('session revocation command ownership', () => { + it('holds capacity after caller timeout until the actual command settles, then recovers', async () => { + vi.useFakeTimers(); + const budget = new SessionRevocationCommandBudget(1); + let finish!: () => void; + const actual = new Promise((resolve) => { + finish = resolve; + }); + try { + const timed = expect(budget.run(() => actual)).rejects.toThrow('unavailable'); + await vi.advanceTimersByTimeAsync(2000); + await timed; + const extra = vi.fn(async () => 'extra'); + await expect(budget.run(extra)).rejects.toThrow('unavailable'); + expect(extra).not.toHaveBeenCalled(); + finish(); + await actual; + await expect(budget.run(extra)).resolves.toBe('extra'); + } finally { + finish(); + vi.useRealTimers(); + } + }); + it('releases capacity after synchronous startup or asynchronous backend failure', async () => { + const budget = new SessionRevocationCommandBudget(1); + await expect( + budget.run(() => { + throw new Error('synthetic'); + }) + ).rejects.toThrow('unavailable'); + await expect( + budget.run(async () => { + throw new Error('synthetic'); + }) + ).rejects.toThrow('unavailable'); + await expect(budget.run(async () => 'ok')).resolves.toBe('ok'); + }); + it('validates numeric persisted timestamps while distinguishing absent state', () => { + expect(parseSessionRevocationWatermark(null)).toBeNull(); + expect(parseSessionRevocationWatermark('0')?.getTime()).toBe(0); + expect(() => parseSessionRevocationWatermark('NaN')).toThrow(); + expect(() => parseSessionRevocationWatermark('8640000000000001')).toThrow(); + }); +}); diff --git a/packages/common/tsdown.config.ts b/packages/common/tsdown.config.ts index 275b5c57..603e8b2b 100644 --- a/packages/common/tsdown.config.ts +++ b/packages/common/tsdown.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ index: 'src/index.ts', 'auth/gameToken': 'src/auth/gameToken.ts', 'auth/gameSessionTransfer': 'src/auth/gameSessionTransfer.ts', + 'auth/sessionRevocation': 'src/auth/sessionRevocation.ts', 'auth/sanctions': 'src/auth/sanctions.ts', 'gateway/profileStatus': 'src/gateway/profileStatus.ts', 'http/trpcTransport': 'src/http/trpcTransport.ts',