게임 토큰 회수를 Redis에 유지하고 재시작과 알림 누락을 방어한다
This commit is contained in:
@@ -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<Date | null>;
|
||||
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 {
|
||||
private readonly client: {
|
||||
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.',
|
||||
});
|
||||
}
|
||||
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',
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user