diff --git a/app/game-api/package.json b/app/game-api/package.json index 4c8416b..cbfb36a 100644 --- a/app/game-api/package.json +++ b/app/game-api/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@fastify/cors": "^10.0.1", + "@sammo-ts/common": "workspace:*", "@sammo-ts/infra": "workspace:*", "@trpc/server": "^11.4.4", "fastify": "^5.3.3", diff --git a/app/game-api/src/auth/flushStore.ts b/app/game-api/src/auth/flushStore.ts new file mode 100644 index 0000000..d2708b2 --- /dev/null +++ b/app/game-api/src/auth/flushStore.ts @@ -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(); + + 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; + unsubscribe: (channel: string) => Promise; + }; + private readonly channel: string; + private readonly store: FlushStore; + + constructor( + client: { + subscribe: (channel: string, listener: (message: string) => void) => Promise; + unsubscribe: (channel: string) => Promise; + }, + channel: string, + store: FlushStore + ) { + this.client = client; + this.channel = channel; + this.store = store; + } + + async start(): Promise { + 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 { + await this.client.unsubscribe(this.channel); + } +} diff --git a/app/game-api/src/auth/tokenVerifier.ts b/app/game-api/src/auth/tokenVerifier.ts new file mode 100644 index 0000000..f6d223f --- /dev/null +++ b/app/game-api/src/auth/tokenVerifier.ts @@ -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; + }, + }; +}; diff --git a/app/game-api/src/config.ts b/app/game-api/src/config.ts index 81158ca..d9fc006 100644 --- a/app/game-api/src/config.ts +++ b/app/game-api/src/config.ts @@ -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`, }; }; diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index 48af66c..d66133f 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -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, }; }; diff --git a/app/game-api/src/daemon/redisTransport.ts b/app/game-api/src/daemon/redisTransport.ts index 593b960..22f9b18 100644 --- a/app/game-api/src/daemon/redisTransport.ts +++ b/app/game-api/src/daemon/redisTransport.ts @@ -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): Promise; + xRead( + streams: { key: string; id: string }, + options?: { BLOCK?: number; COUNT?: number } + ): Promise; +} + type RedisStreamReadResponse = Array<{ name: string; messages: Array<{ id: string; message: Record }>; @@ -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; diff --git a/app/game-api/src/index.ts b/app/game-api/src/index.ts index 7b671ac..0c39643 100644 --- a/app/game-api/src/index.ts +++ b/app/game-api/src/index.ts @@ -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]) { diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index 58a3c76..b66d996 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -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(); }); diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index 7e91431..8b2221b 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -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().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, + }, + }); +}); diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index 01fa14d..230f29a 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -24,6 +24,7 @@ const buildContext = (options?: { db, turnDaemon: transport, profile, + auth: null, }; }; diff --git a/app/game-api/tsconfig.json b/app/game-api/tsconfig.json index bfd6a06..873b137 100644 --- a/app/game-api/tsconfig.json +++ b/app/game-api/tsconfig.json @@ -7,6 +7,7 @@ }, "include": ["src"], "references": [ + { "path": "../../packages/common" }, { "path": "../../packages/infra" } ] } diff --git a/app/gateway-api/package.json b/app/gateway-api/package.json index 34fc291..f02607b 100644 --- a/app/gateway-api/package.json +++ b/app/gateway-api/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@fastify/cors": "^10.0.1", + "@sammo-ts/common": "workspace:*", "@sammo-ts/infra": "workspace:*", "@trpc/server": "^11.4.4", "fastify": "^5.3.3", diff --git a/app/gateway-api/src/auth/flushPublisher.ts b/app/gateway-api/src/auth/flushPublisher.ts new file mode 100644 index 0000000..95e68fd --- /dev/null +++ b/app/gateway-api/src/auth/flushPublisher.ts @@ -0,0 +1,28 @@ +export interface GatewayUserFlushEvent { + userId: string; + flushedAt: string; + reason?: string; +} + +export interface GatewayFlushPublisher { + publishUserFlush(userId: string, reason?: string): Promise; +} + +export class RedisGatewayFlushPublisher implements GatewayFlushPublisher { + private readonly channel: string; + private readonly client: { publish: (channel: string, message: string) => Promise }; + + constructor(client: { publish: (channel: string, message: string) => Promise }, channel: string) { + this.client = client; + this.channel = channel; + } + + async publishUserFlush(userId: string, reason?: string): Promise { + const payload: GatewayUserFlushEvent = { + userId, + flushedAt: new Date().toISOString(), + reason, + }; + await this.client.publish(this.channel, JSON.stringify(payload)); + } +} diff --git a/app/gateway-api/src/auth/inMemorySessionService.ts b/app/gateway-api/src/auth/inMemorySessionService.ts index 55f3b69..08f1177 100644 --- a/app/gateway-api/src/auth/inMemorySessionService.ts +++ b/app/gateway-api/src/auth/inMemorySessionService.ts @@ -41,6 +41,9 @@ export class InMemoryGatewaySessionService implements GatewaySessionService { userId: user.id, username: user.username, displayName: user.displayName, + roles: user.roles, + sanctions: user.sanctions, + createdAt: user.createdAt, issuedAt: new Date().toISOString(), }; this.sessions.set(sessionToken, { @@ -95,6 +98,9 @@ export class InMemoryGatewaySessionService implements GatewaySessionService { userId: session.userId, username: session.username, displayName: session.displayName, + roles: session.roles, + sanctions: session.sanctions, + createdAt: session.createdAt, issuedAt: new Date().toISOString(), }; const key = buildGameKey(profile, gameToken); diff --git a/app/gateway-api/src/auth/inMemoryUserRepository.ts b/app/gateway-api/src/auth/inMemoryUserRepository.ts index ce9c602..f08e348 100644 --- a/app/gateway-api/src/auth/inMemoryUserRepository.ts +++ b/app/gateway-api/src/auth/inMemoryUserRepository.ts @@ -22,6 +22,8 @@ export const createInMemoryUserRepository = ( id: randomUUID(), username: input.username, displayName: input.displayName ?? input.username, + roles: ['user'], + sanctions: {}, passwordSalt: salt, passwordHash: hasher.hash(input.password, salt), createdAt: new Date().toISOString(), diff --git a/app/gateway-api/src/auth/redisSessionService.ts b/app/gateway-api/src/auth/redisSessionService.ts index b6daa8f..39ab6c3 100644 --- a/app/gateway-api/src/auth/redisSessionService.ts +++ b/app/gateway-api/src/auth/redisSessionService.ts @@ -62,6 +62,9 @@ export class RedisGatewaySessionService implements GatewaySessionService { userId: user.id, username: user.username, displayName: user.displayName, + roles: user.roles, + sanctions: user.sanctions, + createdAt: user.createdAt, issuedAt: new Date().toISOString(), }; await this.client.set(this.keys.sessionKey(sessionToken), JSON.stringify(info), { @@ -114,6 +117,9 @@ export class RedisGatewaySessionService implements GatewaySessionService { userId: session.userId, username: session.username, displayName: session.displayName, + roles: session.roles, + sanctions: session.sanctions, + createdAt: session.createdAt, issuedAt: new Date().toISOString(), }; const gameKey = this.keys.gameSessionKey(profile, gameToken); diff --git a/app/gateway-api/src/auth/sessionService.ts b/app/gateway-api/src/auth/sessionService.ts index e4ecfd5..2f97e27 100644 --- a/app/gateway-api/src/auth/sessionService.ts +++ b/app/gateway-api/src/auth/sessionService.ts @@ -1,10 +1,13 @@ -import type { UserRecord } from './userRepository.js'; +import type { UserRecord, UserSanctions } from './userRepository.js'; export interface GatewaySessionInfo { sessionToken: string; userId: string; username: string; displayName: string; + roles: string[]; + sanctions: UserSanctions; + createdAt: string; issuedAt: string; } @@ -15,6 +18,9 @@ export interface GameSessionInfo { userId: string; username: string; displayName: string; + roles: string[]; + sanctions: UserSanctions; + createdAt: string; issuedAt: string; } diff --git a/app/gateway-api/src/auth/userRepository.ts b/app/gateway-api/src/auth/userRepository.ts index 73ca052..c879e0a 100644 --- a/app/gateway-api/src/auth/userRepository.ts +++ b/app/gateway-api/src/auth/userRepository.ts @@ -2,6 +2,8 @@ export interface UserRecord { id: string; username: string; displayName: string; + roles: string[]; + sanctions: UserSanctions; passwordHash: string; passwordSalt: string; createdAt: string; @@ -11,13 +13,24 @@ export interface PublicUser { id: string; username: string; displayName: string; + roles: string[]; createdAt: string; } +export interface UserSanctions { + bannedUntil?: string; + mutedUntil?: string; + suspendedUntil?: string; + warningCount?: number; + flags?: string[]; + notes?: string; +} + export const toPublicUser = (user: UserRecord): PublicUser => ({ id: user.id, username: user.username, displayName: user.displayName, + roles: user.roles, createdAt: user.createdAt, }); diff --git a/app/gateway-api/src/config.ts b/app/gateway-api/src/config.ts index 863ac30..48038f6 100644 --- a/app/gateway-api/src/config.ts +++ b/app/gateway-api/src/config.ts @@ -3,8 +3,10 @@ export interface GatewayApiConfig { port: number; trpcPath: string; redisKeyPrefix: string; + flushChannel: string; sessionTtlSeconds: number; gameSessionTtlSeconds: number; + gameTokenSecret: string; } const parseNumber = (value: string | undefined, fallback: number, label: string): number => { @@ -21,16 +23,23 @@ const parseNumber = (value: string | undefined, fallback: number, label: string) export const resolveGatewayApiConfigFromEnv = ( env: NodeJS.ProcessEnv = process.env ): GatewayApiConfig => { + const secret = env.GAME_TOKEN_SECRET ?? env.GATEWAY_TOKEN_SECRET ?? ''; + if (!secret) { + throw new Error('GAME_TOKEN_SECRET is required for gateway token encryption.'); + } + const redisKeyPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway'; return { host: env.GATEWAY_API_HOST ?? '0.0.0.0', port: parseNumber(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT'), trpcPath: env.TRPC_PATH ?? '/trpc', - redisKeyPrefix: env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway', + redisKeyPrefix, + flushChannel: `${redisKeyPrefix}:flush`, sessionTtlSeconds: parseNumber(env.SESSION_TTL_SECONDS, 60 * 60 * 24 * 7, 'SESSION_TTL_SECONDS'), gameSessionTtlSeconds: parseNumber( env.GAME_SESSION_TTL_SECONDS, 60 * 60 * 6, 'GAME_SESSION_TTL_SECONDS' ), + gameTokenSecret: secret, }; }; diff --git a/app/gateway-api/src/context.ts b/app/gateway-api/src/context.ts index 88a2d0e..aae4146 100644 --- a/app/gateway-api/src/context.ts +++ b/app/gateway-api/src/context.ts @@ -1,15 +1,25 @@ +import type { GatewayFlushPublisher } from './auth/flushPublisher.js'; import type { GatewaySessionService } from './auth/sessionService.js'; import type { UserRepository } from './auth/userRepository.js'; export interface GatewayApiContext { users: UserRepository; sessions: GatewaySessionService; + flushPublisher: GatewayFlushPublisher; + gameTokenSecret: string; + gameSessionTtlSeconds: number; } export const createGatewayApiContext = (options: { users: UserRepository; sessions: GatewaySessionService; + flushPublisher: GatewayFlushPublisher; + gameTokenSecret: string; + gameSessionTtlSeconds: number; }): GatewayApiContext => ({ users: options.users, sessions: options.sessions, + flushPublisher: options.flushPublisher, + gameTokenSecret: options.gameTokenSecret, + gameSessionTtlSeconds: options.gameSessionTtlSeconds, }); diff --git a/app/gateway-api/src/index.ts b/app/gateway-api/src/index.ts index d0ced8e..5ab21d3 100644 --- a/app/gateway-api/src/index.ts +++ b/app/gateway-api/src/index.ts @@ -14,6 +14,7 @@ export * from './auth/sessionService.js'; export * from './auth/inMemorySessionService.js'; export * from './auth/redisSessionService.js'; export * from './auth/redisKeys.js'; +export * from './auth/flushPublisher.js'; const isMain = (): boolean => { if (!process.argv[1]) { diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index f27016c..53b44e2 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -1,6 +1,11 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; +import { + decryptGameSessionToken, + encryptGameSessionToken, +} from '@sammo-ts/common/auth/gameToken.js'; + import { procedure, router } from './trpc.js'; import { toPublicUser } from './auth/userRepository.js'; @@ -8,6 +13,14 @@ const zUsername = z.string().min(2).max(32); const zPassword = z.string().min(6).max(128); const zProfile = z.string().min(1).max(64); +const parseDate = (value: string): Date | null => { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return null; + } + return parsed; +}; + export const appRouter = router({ health: router({ ping: procedure.query(() => ({ @@ -105,7 +118,11 @@ export const appRouter = router({ }) ) .mutation(async ({ ctx, input }) => { + const session = await ctx.sessions.getSession(input.sessionToken); await ctx.sessions.revokeSession(input.sessionToken, { revokeGames: true }); + if (session) { + await ctx.flushPublisher.publishUserFlush(session.userId, 'logout'); + } return { ok: true }; }), issueGameSession: procedure @@ -126,12 +143,40 @@ export const appRouter = router({ message: 'Session is not valid.', }); } + const now = new Date(); + const payload = { + version: 1, + profile: gameSession.profile, + issuedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + 1000 * ctx.gameSessionTtlSeconds).toISOString(), + sessionId: gameSession.gameToken, + user: { + id: gameSession.userId, + username: gameSession.username, + displayName: gameSession.displayName, + roles: gameSession.roles, + createdAt: gameSession.createdAt, + }, + sanctions: gameSession.sanctions, + } as const; + const gameToken = encryptGameSessionToken(payload, ctx.gameTokenSecret); return { profile: gameSession.profile, - gameToken: gameSession.gameToken, - issuedAt: gameSession.issuedAt, + gameToken, + issuedAt: payload.issuedAt, }; }), + flushUser: procedure + .input( + z.object({ + userId: z.string().min(1), + reason: z.string().min(1).optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + await ctx.flushPublisher.publishUserFlush(input.userId, input.reason); + return { ok: true }; + }), validateGameSession: procedure .input( z.object({ @@ -140,26 +185,26 @@ export const appRouter = router({ }) ) .query(async ({ ctx, input }) => { - const gameSession = await ctx.sessions.getGameSession( - input.profile, - input.gameToken - ); - if (!gameSession) { + const payload = decryptGameSessionToken(input.gameToken, ctx.gameTokenSecret); + if (!payload) { return null; } - const session = await ctx.sessions.getSession(gameSession.sessionToken); - if (!session) { + if (payload.profile !== input.profile) { + return null; + } + const expiresAt = parseDate(payload.expiresAt); + if (!expiresAt || Date.now() > expiresAt.getTime()) { return null; } return { - profile: gameSession.profile, - sessionToken: gameSession.sessionToken, + profile: payload.profile, + sessionToken: payload.sessionId, user: { - id: gameSession.userId, - username: gameSession.username, - displayName: gameSession.displayName, + id: payload.user.id, + username: payload.user.username, + displayName: payload.user.displayName, }, - issuedAt: gameSession.issuedAt, + issuedAt: payload.issuedAt, }; }), }), diff --git a/app/gateway-api/src/server.ts b/app/gateway-api/src/server.ts index 412db1e..6c8b3b3 100644 --- a/app/gateway-api/src/server.ts +++ b/app/gateway-api/src/server.ts @@ -5,6 +5,7 @@ import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra import { resolveGatewayApiConfigFromEnv } from './config.js'; import { createGatewayApiContext } from './context.js'; +import { RedisGatewayFlushPublisher } from './auth/flushPublisher.js'; import { createInMemoryUserRepository } from './auth/inMemoryUserRepository.js'; import { RedisGatewaySessionService } from './auth/redisSessionService.js'; import { appRouter } from './router.js'; @@ -20,6 +21,7 @@ export const createGatewayApiServer = async () => { sessionTtlSeconds: config.sessionTtlSeconds, gameSessionTtlSeconds: config.gameSessionTtlSeconds, }); + const flushPublisher = new RedisGatewayFlushPublisher(redis.client, config.flushChannel); const app = fastify({ logger: true, @@ -38,6 +40,9 @@ export const createGatewayApiServer = async () => { createGatewayApiContext({ users, sessions, + flushPublisher, + gameTokenSecret: config.gameTokenSecret, + gameSessionTtlSeconds: config.gameSessionTtlSeconds, }), }, }); diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index 4e45c1d..94d1376 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -11,7 +11,18 @@ const buildCaller = () => { sessionTtlSeconds: 3600, gameSessionTtlSeconds: 600, }); - return appRouter.createCaller(createGatewayApiContext({ users, sessions })); + const flushPublisher = { + publishUserFlush: async () => {}, + }; + return appRouter.createCaller( + createGatewayApiContext({ + users, + sessions, + flushPublisher, + gameTokenSecret: 'test-secret', + gameSessionTtlSeconds: 600, + }) + ); }; describe('gateway auth flow', () => { diff --git a/app/gateway-api/tsconfig.json b/app/gateway-api/tsconfig.json index bfd6a06..873b137 100644 --- a/app/gateway-api/tsconfig.json +++ b/app/gateway-api/tsconfig.json @@ -7,6 +7,7 @@ }, "include": ["src"], "references": [ + { "path": "../../packages/common" }, { "path": "../../packages/infra" } ] } diff --git a/packages/common/src/auth/gameToken.ts b/packages/common/src/auth/gameToken.ts new file mode 100644 index 0000000..66262e2 --- /dev/null +++ b/packages/common/src/auth/gameToken.ts @@ -0,0 +1,107 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; + +export interface UserSanctions { + bannedUntil?: string; + mutedUntil?: string; + suspendedUntil?: string; + warningCount?: number; + flags?: string[]; + notes?: string; +} + +export interface GatewayUserInfo { + id: string; + username: string; + displayName: string; + roles: string[]; + createdAt?: string; +} + +export interface GameSessionTokenPayload { + version: 1; + profile: string; + issuedAt: string; + expiresAt: string; + sessionId: string; + user: GatewayUserInfo; + sanctions: UserSanctions; +} + +const toBase64Url = (data: Buffer): string => data.toString('base64url'); +const fromBase64Url = (value: string): Buffer => Buffer.from(value, 'base64url'); + +const buildKey = (secret: string): Buffer => + createHash('sha256').update(secret).digest(); + +export const encryptGameSessionToken = ( + payload: GameSessionTokenPayload, + secret: string +): string => { + const iv = randomBytes(12); + const key = buildKey(secret); + const cipher = createCipheriv('aes-256-gcm', key, iv); + const plaintext = Buffer.from(JSON.stringify(payload), 'utf8'); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${toBase64Url(iv)}.${toBase64Url(ciphertext)}.${toBase64Url(tag)}`; +}; + +const parsePayload = (value: unknown): GameSessionTokenPayload | null => { + if (!value || typeof value !== 'object') { + return null; + } + const payload = value as Partial; + if (payload.version !== 1) { + return null; + } + if (typeof payload.profile !== 'string') { + return null; + } + if (typeof payload.issuedAt !== 'string' || typeof payload.expiresAt !== 'string') { + return null; + } + if (typeof payload.sessionId !== 'string') { + return null; + } + if (!payload.user || typeof payload.user !== 'object') { + return null; + } + const user = payload.user as Partial; + if ( + typeof user.id !== 'string' || + typeof user.username !== 'string' || + typeof user.displayName !== 'string' || + !Array.isArray(user.roles) + ) { + return null; + } + if (!payload.sanctions || typeof payload.sanctions !== 'object') { + return null; + } + return payload as GameSessionTokenPayload; +}; + +export const decryptGameSessionToken = ( + token: string, + secret: string +): GameSessionTokenPayload | null => { + const parts = token.split('.'); + if (parts.length !== 3) { + return null; + } + try { + const [ivPart, cipherPart, tagPart] = parts; + const iv = fromBase64Url(ivPart); + const ciphertext = fromBase64Url(cipherPart); + const tag = fromBase64Url(tagPart); + const key = buildKey(secret); + const decipher = createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString( + 'utf8' + ); + return parsePayload(JSON.parse(plaintext)); + } catch { + return null; + } +}; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 355e3de..bb08ff5 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,4 +1,5 @@ export * from './rng.js'; +export * from './auth/gameToken.js'; export * from './time/Clock.js'; export * from './util/BytesLike.js'; export * from './util/convertBytesLikeToArrayBuffer.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3048be..9178d6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@fastify/cors': specifier: ^10.0.1 version: 10.1.0 + '@sammo-ts/common': + specifier: workspace:* + version: link:../../packages/common '@sammo-ts/infra': specifier: workspace:* version: link:../../packages/infra @@ -84,6 +87,9 @@ importers: '@fastify/cors': specifier: ^10.0.1 version: 10.1.0 + '@sammo-ts/common': + specifier: workspace:* + version: link:../../packages/common '@sammo-ts/infra': specifier: workspace:* version: link:../../packages/infra