게임 토큰 회수를 Redis에 유지하고 재시작과 알림 누락을 방어한다

This commit is contained in:
2026-10-09 05:59:28 +09:00
parent eeda225429
commit 005f88d7ca
17 changed files with 605 additions and 17 deletions
+52 -3
View File
@@ -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 {
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<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.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(
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.');
}
}
+3 -1
View File
@@ -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 = {
+11
View File
@@ -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,
+24
View File
@@ -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' });
@@ -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');
});
});