feat: implement user session management with Redis and add game token verification
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
export interface GatewayUserFlushEvent {
|
||||
userId: string;
|
||||
flushedAt: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface FlushStore {
|
||||
getFlushedAt(userId: string): Date | null;
|
||||
applyFlush(event: GatewayUserFlushEvent): void;
|
||||
}
|
||||
|
||||
export class InMemoryFlushStore implements FlushStore {
|
||||
private readonly flushedAtByUser = new Map<string, Date>();
|
||||
|
||||
getFlushedAt(userId: string): Date | null {
|
||||
return this.flushedAtByUser.get(userId) ?? null;
|
||||
}
|
||||
|
||||
applyFlush(event: GatewayUserFlushEvent): void {
|
||||
const parsed = new Date(event.flushedAt);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return;
|
||||
}
|
||||
const existing = this.flushedAtByUser.get(event.userId);
|
||||
if (!existing || parsed > existing) {
|
||||
this.flushedAtByUser.set(event.userId, parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RedisGatewayFlushSubscriber {
|
||||
private readonly client: {
|
||||
subscribe: (channel: string, listener: (message: string) => void) => Promise<void>;
|
||||
unsubscribe: (channel: string) => Promise<void>;
|
||||
};
|
||||
private readonly channel: string;
|
||||
private readonly store: FlushStore;
|
||||
|
||||
constructor(
|
||||
client: {
|
||||
subscribe: (channel: string, listener: (message: string) => void) => Promise<void>;
|
||||
unsubscribe: (channel: string) => Promise<void>;
|
||||
},
|
||||
channel: string,
|
||||
store: FlushStore
|
||||
) {
|
||||
this.client = client;
|
||||
this.channel = channel;
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.client.subscribe(this.channel, (message) => {
|
||||
try {
|
||||
const payload = JSON.parse(message) as GatewayUserFlushEvent;
|
||||
if (!payload || typeof payload.userId !== 'string') {
|
||||
return;
|
||||
}
|
||||
this.store.applyFlush(payload);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await this.client.unsubscribe(this.channel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken.js';
|
||||
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken.js';
|
||||
|
||||
import type { FlushStore } from './flushStore.js';
|
||||
|
||||
export interface GameTokenVerifier {
|
||||
verify(token: string): GameSessionTokenPayload | null;
|
||||
}
|
||||
|
||||
const parseDate = (value: string): Date | null => {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const createGameTokenVerifier = (options: {
|
||||
secret: string;
|
||||
profileName: string;
|
||||
flushStore: FlushStore;
|
||||
}): GameTokenVerifier => {
|
||||
return {
|
||||
verify: (token: string): GameSessionTokenPayload | null => {
|
||||
const payload = decryptGameSessionToken(token, options.secret);
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
if (payload.profile !== options.profileName) {
|
||||
return null;
|
||||
}
|
||||
const expiresAt = parseDate(payload.expiresAt);
|
||||
const issuedAt = parseDate(payload.issuedAt);
|
||||
if (!expiresAt || !issuedAt) {
|
||||
return null;
|
||||
}
|
||||
if (Date.now() > expiresAt.getTime()) {
|
||||
return null;
|
||||
}
|
||||
const flushedAt = options.flushStore.getFlushedAt(payload.user.id);
|
||||
if (flushedAt && issuedAt <= flushedAt) {
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -6,6 +6,8 @@ export interface GameApiConfig {
|
||||
scenario: string;
|
||||
profileName: string;
|
||||
daemonRequestTimeoutMs: number;
|
||||
gameTokenSecret: string;
|
||||
flushChannel: string;
|
||||
}
|
||||
|
||||
const parseNumber = (value: string | undefined, fallback: number, label: string): number => {
|
||||
@@ -25,6 +27,11 @@ export const resolveGameApiConfigFromEnv = (
|
||||
const profile = env.PROFILE ?? env.SERVER_PROFILE ?? 'che';
|
||||
const scenario = env.SCENARIO ?? 'default';
|
||||
const profileName = `${profile}:${scenario}`;
|
||||
const secret = env.GAME_TOKEN_SECRET ?? env.GATEWAY_TOKEN_SECRET ?? '';
|
||||
if (!secret) {
|
||||
throw new Error('GAME_TOKEN_SECRET is required for game token verification.');
|
||||
}
|
||||
const gatewayPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway';
|
||||
|
||||
return {
|
||||
host: env.GAME_API_HOST ?? '0.0.0.0',
|
||||
@@ -38,5 +45,7 @@ export const resolveGameApiConfigFromEnv = (
|
||||
5000,
|
||||
'DAEMON_REQUEST_TIMEOUT_MS'
|
||||
),
|
||||
gameTokenSecret: secret,
|
||||
flushChannel: `${gatewayPrefix}:flush`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken.js';
|
||||
|
||||
import type { TurnDaemonTransport } from './daemon/transport.js';
|
||||
|
||||
export interface GameProfile {
|
||||
@@ -26,16 +28,19 @@ export interface GameApiContext {
|
||||
db: DatabaseClient;
|
||||
turnDaemon: TurnDaemonTransport;
|
||||
profile: GameProfile;
|
||||
auth: GameSessionTokenPayload | null;
|
||||
}
|
||||
|
||||
export const createGameApiContext = (options: {
|
||||
db: DatabaseClient;
|
||||
turnDaemon: TurnDaemonTransport;
|
||||
profile: GameProfile;
|
||||
auth: GameSessionTokenPayload | null;
|
||||
}): GameApiContext => {
|
||||
return {
|
||||
db: options.db,
|
||||
turnDaemon: options.turnDaemon,
|
||||
profile: options.profile,
|
||||
auth: options.auth,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { RedisClientType } from 'redis';
|
||||
|
||||
import type { TurnDaemonStreamKeys } from './streamKeys.js';
|
||||
import type { TurnDaemonTransport } from './transport.js';
|
||||
import type {
|
||||
@@ -16,6 +14,14 @@ interface RedisTurnDaemonTransportOptions {
|
||||
requestTimeoutMs: number;
|
||||
}
|
||||
|
||||
interface RedisClientLike {
|
||||
xAdd(stream: string, id: string, message: Record<string, string>): Promise<string>;
|
||||
xRead(
|
||||
streams: { key: string; id: string },
|
||||
options?: { BLOCK?: number; COUNT?: number }
|
||||
): Promise<unknown>;
|
||||
}
|
||||
|
||||
type RedisStreamReadResponse = Array<{
|
||||
name: string;
|
||||
messages: Array<{ id: string; message: Record<string, string> }>;
|
||||
@@ -50,11 +56,11 @@ const parseEventEnvelope = (raw: string): TurnDaemonEventEnvelope | null => {
|
||||
|
||||
// 턴 데몬 제어 스트림을 Redis로 구현한 전송기.
|
||||
export class RedisTurnDaemonTransport implements TurnDaemonTransport {
|
||||
private readonly client: RedisClientType;
|
||||
private readonly client: RedisClientLike;
|
||||
private readonly keys: TurnDaemonStreamKeys;
|
||||
private readonly requestTimeoutMs: number;
|
||||
|
||||
constructor(client: RedisClientType, options: RedisTurnDaemonTransportOptions) {
|
||||
constructor(client: RedisClientLike, options: RedisTurnDaemonTransportOptions) {
|
||||
this.client = client;
|
||||
this.keys = options.keys;
|
||||
this.requestTimeoutMs = options.requestTimeoutMs;
|
||||
|
||||
@@ -12,6 +12,8 @@ export * from './daemon/streamKeys.js';
|
||||
export * from './daemon/transport.js';
|
||||
export * from './daemon/inMemoryTransport.js';
|
||||
export * from './daemon/redisTransport.js';
|
||||
export * from './auth/flushStore.js';
|
||||
export * from './auth/tokenVerifier.js';
|
||||
|
||||
const isMain = (): boolean => {
|
||||
if (!process.argv[1]) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import fastify from 'fastify';
|
||||
import fastify, { type FastifyRequest } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import {
|
||||
@@ -12,8 +12,25 @@ import { resolveGameApiConfigFromEnv } from './config.js';
|
||||
import { createGameApiContext } from './context.js';
|
||||
import { buildTurnDaemonStreamKeys } from './daemon/streamKeys.js';
|
||||
import { RedisTurnDaemonTransport } from './daemon/redisTransport.js';
|
||||
import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from './auth/flushStore.js';
|
||||
import { createGameTokenVerifier } from './auth/tokenVerifier.js';
|
||||
import { appRouter } from './router.js';
|
||||
|
||||
const extractBearerToken = (value: string | string[] | undefined): string | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const header = Array.isArray(value) ? value[0] : value;
|
||||
if (!header) {
|
||||
return null;
|
||||
}
|
||||
const prefix = 'Bearer ';
|
||||
if (header.startsWith(prefix)) {
|
||||
return header.slice(prefix.length).trim();
|
||||
}
|
||||
return header.trim();
|
||||
};
|
||||
|
||||
export const createGameApiServer = async () => {
|
||||
const config = resolveGameApiConfigFromEnv();
|
||||
const postgres = createPostgresConnector(resolvePostgresConfigFromEnv());
|
||||
@@ -26,6 +43,20 @@ export const createGameApiServer = async () => {
|
||||
keys: buildTurnDaemonStreamKeys(config.profileName),
|
||||
requestTimeoutMs: config.daemonRequestTimeoutMs,
|
||||
});
|
||||
const flushStore = new InMemoryFlushStore();
|
||||
const flushSubscriberClient = redis.client.duplicate();
|
||||
await flushSubscriberClient.connect();
|
||||
const flushSubscriber = new RedisGatewayFlushSubscriber(
|
||||
flushSubscriberClient,
|
||||
config.flushChannel,
|
||||
flushStore
|
||||
);
|
||||
await flushSubscriber.start();
|
||||
const tokenVerifier = createGameTokenVerifier({
|
||||
secret: config.gameTokenSecret,
|
||||
profileName: config.profileName,
|
||||
flushStore,
|
||||
});
|
||||
|
||||
const app = fastify({
|
||||
logger: true,
|
||||
@@ -40,8 +71,10 @@ export const createGameApiServer = async () => {
|
||||
prefix: config.trpcPath,
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
createContext: () =>
|
||||
createGameApiContext({
|
||||
createContext: ({ req }: { req: FastifyRequest }) => {
|
||||
const token = extractBearerToken(req.headers.authorization);
|
||||
const auth = token ? tokenVerifier.verify(token) : null;
|
||||
return createGameApiContext({
|
||||
db: postgres.prisma,
|
||||
turnDaemon,
|
||||
profile: {
|
||||
@@ -49,7 +82,9 @@ export const createGameApiServer = async () => {
|
||||
scenario: config.scenario,
|
||||
name: config.profileName,
|
||||
},
|
||||
}),
|
||||
auth,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -59,6 +94,8 @@ export const createGameApiServer = async () => {
|
||||
}));
|
||||
|
||||
app.addHook('onClose', async () => {
|
||||
await flushSubscriber.stop();
|
||||
await flushSubscriberClient.quit();
|
||||
await redis.disconnect();
|
||||
await postgres.disconnect();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
|
||||
import type { GameApiContext } from './context.js';
|
||||
|
||||
@@ -6,3 +6,17 @@ const t = initTRPC.context<GameApiContext>().create();
|
||||
|
||||
export const router = t.router;
|
||||
export const procedure = t.procedure;
|
||||
export const authedProcedure = t.procedure.use(({ ctx, next }) => {
|
||||
if (!ctx.auth) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Unauthorized',
|
||||
});
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
auth: ctx.auth,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user