게임 토큰 회수를 Redis에 유지하고 재시작과 알림 누락을 방어한다
This commit is contained in:
@@ -1,3 +1,10 @@
|
|||||||
|
import {
|
||||||
|
buildSessionRevocationBaselineKey,
|
||||||
|
buildUserSessionRevocationKey,
|
||||||
|
parseSessionRevocationWatermark,
|
||||||
|
SessionRevocationCommandBudget,
|
||||||
|
} from '@sammo-ts/common/auth/sessionRevocation';
|
||||||
|
|
||||||
export interface GatewayUserFlushEvent {
|
export interface GatewayUserFlushEvent {
|
||||||
userId: string;
|
userId: string;
|
||||||
flushedAt: string;
|
flushedAt: string;
|
||||||
@@ -8,7 +15,7 @@ export interface GatewayUserFlushEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FlushStore {
|
export interface FlushStore {
|
||||||
getFlushedAt(userId: string): Date | null;
|
getFlushedAt(userId: string): Date | null | Promise<Date | null>;
|
||||||
applyFlush(event: GatewayUserFlushEvent): void;
|
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<string, Date>();
|
||||||
|
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<void> {
|
||||||
|
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<Date | null> {
|
||||||
|
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 {
|
export class RedisGatewayFlushSubscriber {
|
||||||
private readonly client: {
|
private readonly client: {
|
||||||
subscribe: (channel: string, listener: (message: string) => void) => Promise<void>;
|
subscribe: (channel: string, listener: (message: string) => void) => Promise<void>;
|
||||||
|
|||||||
@@ -66,7 +66,15 @@ export const authRouter = router({
|
|||||||
message: 'Game access is restricted for this account.',
|
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) {
|
if (flushedAt && new Date(payload.issuedAt) <= flushedAt) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'UNAUTHORIZED',
|
code: 'UNAUTHORIZED',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import cors from '@fastify/cors';
|
|||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||||
import {
|
import {
|
||||||
buildGameEventChannel,
|
buildGameEventChannel,
|
||||||
@@ -22,7 +23,7 @@ import {
|
|||||||
import { resolveGameApiConfigFromEnv } from './config.js';
|
import { resolveGameApiConfigFromEnv } from './config.js';
|
||||||
import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './context.js';
|
import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './context.js';
|
||||||
import { DatabaseTurnDaemonTransport } from './daemon/databaseTransport.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 { RedisAccessTokenStore } from './auth/accessTokenStore.js';
|
||||||
import {
|
import {
|
||||||
consumeRealtimeAccessGrantHeader,
|
consumeRealtimeAccessGrantHeader,
|
||||||
@@ -83,7 +84,12 @@ const resolveAuthFromToken = async (
|
|||||||
if (!stored) {
|
if (!stored) {
|
||||||
return null;
|
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) {
|
if (flushedAt && new Date(stored.issuedAt) <= flushedAt) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -142,7 +148,18 @@ export const createGameApiServer = async () => {
|
|||||||
requestTimeoutMs: config.battleSimRequestTimeoutMs,
|
requestTimeoutMs: config.battleSimRequestTimeoutMs,
|
||||||
resultTtlSeconds: config.battleSimResultTtlSeconds,
|
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();
|
const flushSubscriberClient = redis.client.duplicate();
|
||||||
try {
|
try {
|
||||||
await flushSubscriberClient.connect();
|
await flushSubscriberClient.connect();
|
||||||
@@ -328,7 +345,13 @@ export const createGameApiServer = async () => {
|
|||||||
const query = request.query as { token?: string; scope?: string };
|
const query = request.query as { token?: string; scope?: string };
|
||||||
const subscriptionScope = query.scope === 'tournament' ? 'tournament' : 'dashboard';
|
const subscriptionScope = query.scope === 'tournament' ? 'tournament' : 'dashboard';
|
||||||
const tokenFromHeader = extractBearerToken(request.headers.authorization);
|
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) {
|
if (!auth || auth.profile !== config.profileName) {
|
||||||
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ const restoreEnv = (): void => {
|
|||||||
|
|
||||||
const deleteProfileRedisKeys = async (): Promise<void> => {
|
const deleteProfileRedisKeys = async (): Promise<void> => {
|
||||||
if (!redis) return;
|
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 })) {
|
for await (const keys of redis.client.scanIterator({ MATCH: `sammo:game:*:${profileName}:*`, COUNT: 100 })) {
|
||||||
if (keys.length > 0) await redis.client.del(keys);
|
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');
|
if (!created) throw new Error('failed to seed diplomacy HTML access token');
|
||||||
accessToken = created.accessToken;
|
accessToken = created.accessToken;
|
||||||
|
|
||||||
|
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
|
||||||
|
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
|
||||||
server = await createGameApiServer();
|
server = await createGameApiServer();
|
||||||
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
|
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { createServer, type Socket } from 'node:net';
|
||||||
|
import { createClient } from 'redis';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
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', () => {
|
describe('RedisGatewayFlushSubscriber', () => {
|
||||||
it('reports asynchronous flush handler failures with event context', async () => {
|
it('reports asynchronous flush handler failures with event context', async () => {
|
||||||
@@ -66,3 +68,84 @@ describe('RedisGatewayFlushSubscriber', () => {
|
|||||||
expect(stopped).toBe(true);
|
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<Socket>();
|
||||||
|
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<void>((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<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const deleteProfileRedisKeys = async (): Promise<void> => {
|
|||||||
if (!redis) {
|
if (!redis) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await redis.client.del(`${redisPrefix}:flush:revocation:v1:baseline`);
|
||||||
for await (const keys of redis.client.scanIterator({
|
for await (const keys of redis.client.scanIterator({
|
||||||
MATCH: `sammo:game:*:${profileName}:*`,
|
MATCH: `sammo:game:*:${profileName}:*`,
|
||||||
COUNT: 100,
|
COUNT: 100,
|
||||||
@@ -155,6 +156,8 @@ integration('nation HTML purification over HTTP transport', () => {
|
|||||||
}
|
}
|
||||||
accessToken = created.accessToken;
|
accessToken = created.accessToken;
|
||||||
|
|
||||||
|
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
|
||||||
|
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
|
||||||
server = await createGameApiServer();
|
server = await createGameApiServer();
|
||||||
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
|
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ const deleteProfileRedisKeys = async (): Promise<void> => {
|
|||||||
if (!redis) {
|
if (!redis) {
|
||||||
return;
|
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({
|
for await (const keys of redis.client.scanIterator({
|
||||||
MATCH: pattern,
|
MATCH: pattern,
|
||||||
COUNT: 100,
|
COUNT: 100,
|
||||||
@@ -417,7 +417,7 @@ const readAccessTelemetryState = async () => ({
|
|||||||
const readRealtimeRedisState = async (): Promise<Array<[string, string | null]>> => {
|
const readRealtimeRedisState = async (): Promise<Array<[string, string | null]>> => {
|
||||||
if (!redis) return [];
|
if (!redis) return [];
|
||||||
const keys = new Set<string>();
|
const keys = new Set<string>();
|
||||||
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 await (const batch of redis.client.scanIterator({ MATCH: pattern, COUNT: 100 })) {
|
||||||
for (const key of batch) keys.add(key);
|
for (const key of batch) keys.add(key);
|
||||||
}
|
}
|
||||||
@@ -743,6 +743,8 @@ integration('game API security over HTTP transport', () => {
|
|||||||
await redis.connect();
|
await redis.connect();
|
||||||
accessTokenStore = new RedisAccessTokenStore(redis.client, profileName);
|
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();
|
server = await createGameApiServer();
|
||||||
baseUrl = await server.app.listen({
|
baseUrl = await server.app.listen({
|
||||||
host: server.config.host,
|
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
|
// Flush invalidates every token issued before the user watermark. Keep it
|
||||||
// last so this lifecycle assertion cannot invalidate the actor tokens used
|
// last so this lifecycle assertion cannot invalidate the actor tokens used
|
||||||
// by the transport authorization matrix above.
|
// by the transport authorization matrix above.
|
||||||
|
|||||||
@@ -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<string | null>;
|
||||||
|
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
||||||
|
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 {
|
export interface GatewayUserFlushEvent {
|
||||||
userId: string;
|
userId: string;
|
||||||
flushedAt: string;
|
flushedAt: string;
|
||||||
@@ -17,13 +42,28 @@ export interface GatewayFlushPublisher {
|
|||||||
|
|
||||||
export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
|
export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
|
||||||
private readonly channel: string;
|
private readonly channel: string;
|
||||||
private readonly client: { publish: (channel: string, message: string) => Promise<number> };
|
private readonly client: RedisClientLike;
|
||||||
|
private readonly commands = new SessionRevocationCommandBudget(32);
|
||||||
|
|
||||||
constructor(client: { publish: (channel: string, message: string) => Promise<number> }, channel: string) {
|
constructor(client: RedisClientLike, channel: string) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.channel = channel;
|
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<void> {
|
||||||
|
// 새 계약 최초 활성화에서만 기존 게임 credential을 회수한다. 재시작은 cutoff를 보존한다.
|
||||||
|
await this.commands.run(() =>
|
||||||
|
this.commandClient().set(buildSessionRevocationBaselineKey(this.channel), String(Date.now()), {
|
||||||
|
NX: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async publishUserFlush(
|
async publishUserFlush(
|
||||||
userId: string,
|
userId: string,
|
||||||
reason?: string,
|
reason?: string,
|
||||||
@@ -37,6 +77,15 @@ export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
|
|||||||
...(metadata?.displayName ? { displayName: metadata.displayName } : {}),
|
...(metadata?.displayName ? { displayName: metadata.displayName } : {}),
|
||||||
...(metadata?.identityRevision ? { identityRevision: metadata.identityRevision } : {}),
|
...(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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1056,6 +1056,8 @@ export const appRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// 권한 snapshot보다 이후에 발생한 flush도 이 발급을 회수해야 한다.
|
||||||
|
const authorizationStartedAt = Date.now();
|
||||||
const gatewaySession = await ctx.sessions.getSession(input.sessionToken);
|
const gatewaySession = await ctx.sessions.getSession(input.sessionToken);
|
||||||
if (!gatewaySession) {
|
if (!gatewaySession) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -1108,7 +1110,7 @@ export const appRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// 발급 뒤 logout/flush가 겹쳐도 issuedAt을 나중 시각으로 다시 쓰지 않는다.
|
// 발급 뒤 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 accountIcon = resolveEffectiveAccountIcon(user);
|
||||||
const accountIcons = await ctx.users.listIcons(user.id);
|
const accountIcons = await ctx.users.listIcons(user.id);
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|||||||
@@ -73,6 +73,17 @@ export const createGatewayApiServer = async () => {
|
|||||||
config.gameTokenSecret
|
config.gameTokenSecret
|
||||||
);
|
);
|
||||||
const flushPublisher = new RedisGatewayFlushPublisher(redis.client, config.flushChannel);
|
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({
|
const kakaoClient = new KakaoOAuthClient({
|
||||||
restKey: config.kakaoRestKey,
|
restKey: config.kakaoRestKey,
|
||||||
adminKey: config.kakaoAdminKey,
|
adminKey: config.kakaoAdminKey,
|
||||||
|
|||||||
@@ -1242,6 +1242,30 @@ describe('gateway auth flow', () => {
|
|||||||
expect(validated?.user.username).toBe('tester');
|
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 () => {
|
it('retains the original issuance instant when logout flush overlaps later identity reads', async () => {
|
||||||
const { caller, users, sessions } = buildCaller();
|
const { caller, users, sessions } = buildCaller();
|
||||||
const user = await users.createUser({ username: 'issue-flush-race', password: 'synthetic-password' });
|
const user = await users.createUser({ username: 'issue-flush-race', password: 'synthetic-password' });
|
||||||
|
|||||||
@@ -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<string, unknown>; persisted: string | null } | undefined;
|
||||||
|
await subscriber.subscribe(channel, async (raw) => {
|
||||||
|
delivered = {
|
||||||
|
event: JSON.parse(raw) as Record<string, unknown>,
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -552,7 +552,8 @@ Caddy/HTTPS, 방화벽과 실제 운영 DB 전환을 증명하지 않습니다.
|
|||||||
갱신하면 구형 탭의 실시간 갱신이 중단될 수 있다.
|
갱신하면 구형 탭의 실시간 갱신이 중단될 수 있다.
|
||||||
|
|
||||||
SSE는 최초 요청과 매 frame 작업 전·전송 전에 Redis token, flush watermark,
|
SSE는 최초 요청과 매 frame 작업 전·전송 전에 Redis token, flush watermark,
|
||||||
user/session/profile과 게임 접근 제재를 확인한다. 만료 타이머와 Gateway flush가
|
user/session/profile과 게임 접근 제재를 확인한다. flush watermark는 아래 지속 회수
|
||||||
|
계약에 따라 Redis에서 읽으며 메모리에만 의존하지 않는다. 만료 타이머와 Gateway flush가
|
||||||
대기 중인 연결을 닫고 Redis 장애는 fail closed로 처리한다. 연결별 대기 작업은
|
대기 중인 연결을 닫고 Redis 장애는 fail closed로 처리한다. 연결별 대기 작업은
|
||||||
32개, frame과 socket buffer는 64 KiB로 제한하고 slow consumer를 닫는다.
|
32개, frame과 socket buffer는 64 KiB로 제한하고 slow consumer를 닫는다.
|
||||||
재연결 뒤 메인은 기존 갱신 큐로 snapshot을 복구한다. 게임 계산·RNG·DB schema와
|
재연결 뒤 메인은 기존 갱신 큐로 snapshot을 복구한다. 게임 계산·RNG·DB schema와
|
||||||
@@ -638,10 +639,47 @@ Redis Lua가 부모 session의 존재·snapshot 일치·만료, 정리·상한
|
|||||||
앞당기지는 않는다. index TTL은 남은 부모 TTL과 게임 TTL 중 큰 값이며 새 부모 TTL로
|
앞당기지는 않는다. index TTL은 남은 부모 TTL과 게임 TTL 중 큰 값이며 새 부모 TTL로
|
||||||
매번 초기화하지 않는다. 새로운 env key와 DB migration은 필요 없다.
|
매번 초기화하지 않는다. 새로운 env key와 DB migration은 필요 없다.
|
||||||
|
|
||||||
암호화 게임 token의 issuedAt/expiresAt은 추적 항목의 최초 발급 시각에서 계산한다.
|
암호화 게임 token의 issuedAt/expiresAt은 권한 조회 시작과 추적 항목 생성 중 더
|
||||||
|
이른 시각에서 계산한다.
|
||||||
이후 icon/identity 조회 중 logout/flush가 겹쳐도 issuedAt을 더 늦은 시각으로
|
이후 icon/identity 조회 중 logout/flush가 겹쳐도 issuedAt을 더 늦은 시각으로
|
||||||
바꿔 이전 flush를 피하지 않는다. 이는 한 발급 flow의 경합 방어이며 pub/sub 누락이나
|
바꿔 이전 flush를 피하지 않는다. user snapshot 이후 role/sanction 변경과 겹친 발급도
|
||||||
API 재시작 후 회수 유지까지 증명하는 정책은 아니다.
|
그 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)
|
### Builder 요청 인증과 자원 경계 (2026-10-09)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
"types": "./dist/auth/gameToken.d.ts",
|
"types": "./dist/auth/gameToken.d.ts",
|
||||||
"default": "./dist/auth/gameToken.js"
|
"default": "./dist/auth/gameToken.js"
|
||||||
},
|
},
|
||||||
|
"./auth/sessionRevocation": {
|
||||||
|
"types": "./dist/auth/sessionRevocation.d.ts",
|
||||||
|
"default": "./dist/auth/sessionRevocation.js"
|
||||||
|
},
|
||||||
"./auth/sanctions": {
|
"./auth/sanctions": {
|
||||||
"types": "./dist/auth/sanctions.d.ts",
|
"types": "./dist/auth/sanctions.d.ts",
|
||||||
"default": "./dist/auth/sanctions.js"
|
"default": "./dist/auth/sanctions.js"
|
||||||
|
|||||||
@@ -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<T>(start: () => Promise<T>): Promise<T> {
|
||||||
|
if (this.pending >= this.maxPending) throw new Error('Session revocation storage is unavailable.');
|
||||||
|
this.pending += 1;
|
||||||
|
let command: Promise<T>;
|
||||||
|
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<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
command,
|
||||||
|
new Promise<never>((_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void>((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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ export default defineConfig({
|
|||||||
index: 'src/index.ts',
|
index: 'src/index.ts',
|
||||||
'auth/gameToken': 'src/auth/gameToken.ts',
|
'auth/gameToken': 'src/auth/gameToken.ts',
|
||||||
'auth/gameSessionTransfer': 'src/auth/gameSessionTransfer.ts',
|
'auth/gameSessionTransfer': 'src/auth/gameSessionTransfer.ts',
|
||||||
|
'auth/sessionRevocation': 'src/auth/sessionRevocation.ts',
|
||||||
'auth/sanctions': 'src/auth/sanctions.ts',
|
'auth/sanctions': 'src/auth/sanctions.ts',
|
||||||
'gateway/profileStatus': 'src/gateway/profileStatus.ts',
|
'gateway/profileStatus': 'src/gateway/profileStatus.ts',
|
||||||
'http/trpcTransport': 'src/http/trpcTransport.ts',
|
'http/trpcTransport': 'src/http/trpcTransport.ts',
|
||||||
|
|||||||
Reference in New Issue
Block a user