diff --git a/app/gateway-api/src/auth/attemptBudget.ts b/app/gateway-api/src/auth/attemptBudget.ts index 86278f20..c88fe462 100644 --- a/app/gateway-api/src/auth/attemptBudget.ts +++ b/app/gateway-api/src/auth/attemptBudget.ts @@ -111,10 +111,15 @@ const EXPENSIVE_ACTIONS = new Set([ 'account.changePassword', 'account.scheduleDeletion', ]); -const PUBLIC_ACTIONS = new Set(['auth.passwordKey', 'auth.checkRegistrationField', 'auth.kakaoStart']); +const LIGHTWEIGHT_ACTIONS = new Set([ + 'auth.passwordKey', + 'auth.checkRegistrationField', + 'auth.kakaoStart', + 'auth.issueGameSession', +]); export const isBudgetedAuthAction = (action: string): boolean => - EXPENSIVE_ACTIONS.has(action) || PUBLIC_ACTIONS.has(action); + EXPENSIVE_ACTIONS.has(action) || LIGHTWEIGHT_ACTIONS.has(action); export class AuthAttemptBudget { private active = 0; @@ -149,7 +154,11 @@ export class AuthAttemptBudget { { key: this.key('client', client), limit: 600, windowMs: 60_000 }, ]; if (resolvedSubject) - counters.push({ key: this.key('subject', resolvedSubject), limit: 10, windowMs: 60_000 }); + counters.push({ + key: this.key('subject', resolvedSubject), + limit: action === 'auth.issueGameSession' ? 32 : 10, + windowMs: 60_000, + }); return this.store.consume(counters); }; this.pendingChecks += 1; diff --git a/app/gateway-api/src/auth/inMemorySessionService.ts b/app/gateway-api/src/auth/inMemorySessionService.ts index 62669df6..99e5a826 100644 --- a/app/gateway-api/src/auth/inMemorySessionService.ts +++ b/app/gateway-api/src/auth/inMemorySessionService.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { GameSessionLimitError, MAX_GAME_SESSIONS_PER_SESSION } from './sessionService.js'; import type { GameSessionInfo, @@ -60,7 +61,7 @@ export class InMemoryGatewaySessionService implements GatewaySessionService { if (!stored) { return null; } - if (Date.now() > stored.expiresAt) { + if (Date.now() >= stored.expiresAt) { this.sessions.delete(sessionToken); this.sessionGames.delete(sessionToken); return null; @@ -86,9 +87,19 @@ export class InMemoryGatewaySessionService implements GatewaySessionService { async createGameSession(sessionToken: string, profile: string): Promise { const session = await this.getSession(sessionToken); - if (!session) { + const parent = this.sessions.get(sessionToken); + if (!session || !parent || parent.info !== session || parent.expiresAt <= Date.now()) { return null; } + const set = this.sessionGames.get(sessionToken) ?? new Set(); + for (const key of set) { + const stored = this.gameSessions.get(key); + if (!stored || stored.expiresAt <= Date.now()) { + this.gameSessions.delete(key); + set.delete(key); + } + } + if (set.size >= MAX_GAME_SESSIONS_PER_SESSION) throw new GameSessionLimitError(); const gameToken = randomUUID(); const info: GameSessionInfo = { profile, @@ -109,7 +120,6 @@ export class InMemoryGatewaySessionService implements GatewaySessionService { info, expiresAt: Date.now() + this.gameSessionTtlMs, }); - const set = this.sessionGames.get(sessionToken) ?? new Set(); set.add(key); this.sessionGames.set(sessionToken, set); return info; @@ -121,7 +131,7 @@ export class InMemoryGatewaySessionService implements GatewaySessionService { if (!stored) { return null; } - if (Date.now() > stored.expiresAt) { + if (Date.now() >= stored.expiresAt) { this.gameSessions.delete(key); return null; } diff --git a/app/gateway-api/src/auth/redisSessionService.ts b/app/gateway-api/src/auth/redisSessionService.ts index e2fe9ecb..f0e0aa37 100644 --- a/app/gateway-api/src/auth/redisSessionService.ts +++ b/app/gateway-api/src/auth/redisSessionService.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import { parseJson } from '@sammo-ts/common'; import { createGatewayRedisKeyBuilder } from './redisKeys.js'; +import { GameSessionLimitError, MAX_GAME_SESSIONS_PER_SESSION } from './sessionService.js'; import type { GameSessionInfo, GatewaySessionConfig, @@ -15,22 +16,38 @@ interface RedisGatewaySessionOptions extends GatewaySessionConfig { keyPrefix: string; } -interface RedisPipeline { - set(key: string, value: string, options?: { EX?: number }): RedisPipeline; - sAdd(key: string, member: string): RedisPipeline; - expire(key: string, seconds: number): RedisPipeline; - del(key: string): RedisPipeline; - exec(): Promise; -} - interface RedisClientLike { get(key: string): Promise; set(key: string, value: string, options?: { EX?: number }): Promise; - sMembers(key: string): Promise; - multi(): RedisPipeline; + eval(script: string, options: { keys: string[]; arguments: string[] }): Promise; del(key: string): Promise; } +// 부모 확인·만료 항목 정리·상한·새 항목 저장을 한 원자적 경계에서 수행한다. +// 기존 oversized index는 SMEMBERS로 펼치지 않고 발급을 거절한다. +const CREATE_GAME_SESSION_SCRIPT = ` +local parentTtl = redis.call('PTTL', KEYS[1]) +if parentTtl <= 0 or redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end +local limit = tonumber(ARGV[4]) +if redis.call('SCARD', KEYS[2]) > limit then return -1 end +for _, entry in ipairs(redis.call('SMEMBERS', KEYS[2])) do + if redis.call('EXISTS', entry) == 0 then redis.call('SREM', KEYS[2], entry) end +end +if redis.call('SCARD', KEYS[2]) >= limit then return -1 end +redis.call('SET', KEYS[3], ARGV[2], 'EX', ARGV[3]) +redis.call('SADD', KEYS[2], KEYS[3]) +redis.call('PEXPIRE', KEYS[2], math.max(parentTtl, tonumber(ARGV[3]) * 1000)) +return 1 +`; + +// 삭제와 index pop도 한 경계다. 중간 실패에서 추적만 잃는 것을 방지한다. +const REVOKE_GAME_SESSION_BATCH_SCRIPT = ` +redis.call('DEL', KEYS[1]) +local entries = redis.call('SPOP', KEYS[2], 64) +for _, entry in ipairs(entries) do redis.call('DEL', entry) end +return #entries +`; + // Redis 세션 저장소는 게이트웨이와 게임 서버 간 SSO 토큰을 관리한다. export class RedisGatewaySessionService implements GatewaySessionService { private readonly client: RedisClientLike; @@ -75,22 +92,21 @@ export class RedisGatewaySessionService implements GatewaySessionService { options: SessionRevocationOptions = { revokeGames: true } ): Promise { const key = this.keys.sessionKey(sessionToken); - if (options.revokeGames ?? true) { - const gameSetKey = this.keys.sessionGameSetKey(sessionToken); - const games = await this.client.sMembers(gameSetKey); - if (games.length > 0) { - const pipeline = this.client.multi(); - for (const entry of games) { - pipeline.del(entry); - } - pipeline.del(gameSetKey); - pipeline.del(key); - await pipeline.exec(); - return; - } - await this.client.del(gameSetKey); + if (!(options.revokeGames ?? true)) { + await this.client.del(key); + return; + } + // 부모 제거와 각 64개 삭제를 원자적으로 처리한다. 이후 발급 Lua는 실패한다. + // 이전 버전에서 커진 index도 응답/transaction 한 번에 전부 펼치지 않는다. + for (;;) { + const count = await this.client.eval(REVOKE_GAME_SESSION_BATCH_SCRIPT, { + keys: [key, this.keys.sessionGameSetKey(sessionToken)], + arguments: [], + }); + if (typeof count !== 'number' || count < 0 || count > 64) + throw new Error('Unexpected game session revocation result.'); + if (count < 64) break; } - await this.client.del(key); } async createGameSession(sessionToken: string, profile: string): Promise { @@ -115,12 +131,18 @@ export class RedisGatewaySessionService implements GatewaySessionService { }; const gameKey = this.keys.gameSessionKey(profile, gameToken); const gameSetKey = this.keys.sessionGameSetKey(sessionToken); - await this.client - .multi() - .set(gameKey, JSON.stringify(info), { EX: this.gameSessionTtlSeconds }) - .sAdd(gameSetKey, gameKey) - .expire(gameSetKey, this.sessionTtlSeconds) - .exec(); + const result = await this.client.eval(CREATE_GAME_SESSION_SCRIPT, { + keys: [this.keys.sessionKey(sessionToken), gameSetKey, gameKey], + arguments: [ + JSON.stringify(session), + JSON.stringify(info), + String(this.gameSessionTtlSeconds), + String(MAX_GAME_SESSIONS_PER_SESSION), + ], + }); + if (result === 0) return null; + if (result === -1) throw new GameSessionLimitError(); + if (result !== 1) throw new Error('Unexpected game session admission result.'); return info; } diff --git a/app/gateway-api/src/auth/sessionService.ts b/app/gateway-api/src/auth/sessionService.ts index 387514fa..bc36bbe2 100644 --- a/app/gateway-api/src/auth/sessionService.ts +++ b/app/gateway-api/src/auth/sessionService.ts @@ -1,5 +1,13 @@ import type { UserRecord, UserSanctions } from './userRepository.js'; +// 한 Gateway 로그인에서 여러 profile/탭을 사용하되 추적 자료는 유한하게 유지한다. +export const MAX_GAME_SESSIONS_PER_SESSION = 256; +export class GameSessionLimitError extends Error { + constructor() { + super('Game session limit reached.'); + } +} + export interface GatewaySessionInfo { sessionToken: string; userId: string; diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index e082c9e8..7e9122eb 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -20,6 +20,7 @@ import { import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } from './auth/registrationInput.js'; import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js'; import { isGatewaySessionCurrent } from './auth/sessionValidity.js'; +import { GameSessionLimitError } from './auth/sessionService.js'; import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js'; import type { GatewayApiContext } from './context.js'; import { listScenarioPreviews } from './scenario/scenarioCatalog.js'; @@ -1050,7 +1051,7 @@ export const appRouter = router({ issueGameSession: procedure .input( z.object({ - sessionToken: z.string().min(1), + sessionToken: z.string().min(1).max(256), profile: zProfile, }) ) @@ -1091,14 +1092,23 @@ export const appRouter = router({ message: '카카오 인증 유예기간이 만료되었습니다. 인증 후 계속 이용할 수 있습니다.', }); } - const gameSession = await ctx.sessions.createGameSession(input.sessionToken, input.profile); + const gameSession = await ctx.sessions + .createGameSession(input.sessionToken, input.profile) + .catch((error: unknown) => { + if (!(error instanceof GameSessionLimitError)) throw error; + throw new TRPCError({ + code: 'TOO_MANY_REQUESTS', + message: '게임 세션이 많습니다. 기존 세션이 만료된 뒤 다시 시도해 주세요.', + }); + }); if (!gameSession) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Session is not valid.', }); } - const now = new Date(); + // 발급 뒤 logout/flush가 겹쳐도 issuedAt을 나중 시각으로 다시 쓰지 않는다. + const now = new Date(gameSession.issuedAt); const accountIcon = resolveEffectiveAccountIcon(user); const accountIcons = await ctx.users.listIcons(user.id); const payload = { diff --git a/app/gateway-api/src/trpc.ts b/app/gateway-api/src/trpc.ts index 4b551851..6dc912b0 100644 --- a/app/gateway-api/src/trpc.ts +++ b/app/gateway-api/src/trpc.ts @@ -15,9 +15,13 @@ export const procedure = t.procedure.use(async ({ ctx, path, getRawInput, next } if (path === 'auth.login' && typeof input.username === 'string' && input.username.length <= 64) { return `login:${input.username.trim().toLocaleLowerCase('en-US')}`; } - if (path.startsWith('account.') && typeof input.sessionToken === 'string' && input.sessionToken.length <= 256) { + if ( + (path.startsWith('account.') || path === 'auth.issueGameSession') && + typeof input.sessionToken === 'string' && + input.sessionToken.length <= 256 + ) { const session = await ctx.sessions.getSession(input.sessionToken); - if (session) return `account:${session.userId}`; + if (session) return `${path === 'auth.issueGameSession' ? 'game-session' : 'account'}:${session.userId}`; } return undefined; }; diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index 2c18ef32..df97e95a 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -1242,6 +1242,35 @@ describe('gateway auth flow', () => { expect(validated?.user.username).toBe('tester'); }); + 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' }); + const parent = await sessions.createSession(user); + const issuedAt = new Date().toISOString(); + vi.useFakeTimers({ now: new Date(issuedAt) }); + const create = vi.spyOn(sessions, 'createGameSession'); + const original = sessions.createGameSession.bind(sessions); + create.mockImplementationOnce(async (...args) => { + // Snapshot admitted before the subsequent logout/flush instant. + const game = await original(...args); + vi.advanceTimersByTime(5_000); + await sessions.revokeSession(parent.sessionToken); + return game; + }); + try { + const issued = await caller.auth.issueGameSession({ + sessionToken: parent.sessionToken, + profile: 'che:default', + }); + const payload = decryptGameSessionToken(issued.gameToken, 'test-secret'); + expect(payload?.issuedAt).toBe(issuedAt); + expect(payload?.expiresAt).toBe(new Date(Date.parse(issuedAt) + 600_000).toISOString()); + } finally { + create.mockRestore(); + vi.useRealTimers(); + } + }); + it('keeps the migrated member number inside the encrypted game identity', async () => { const { caller, users, sessions } = buildCaller(); const user = await users.createUser({ diff --git a/app/gateway-api/test/gameSessionLifecycle.test.ts b/app/gateway-api/test/gameSessionLifecycle.test.ts new file mode 100644 index 00000000..c02e117c --- /dev/null +++ b/app/gateway-api/test/gameSessionLifecycle.test.ts @@ -0,0 +1,238 @@ +import { randomUUID } from 'node:crypto'; +import fastify, { type FastifyRequest } from 'fastify'; +import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; +import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common'; +import { appRouter } from '../src/router.js'; +import { createGatewayApiContext } from '../src/context.js'; +import { AuthAttemptBudget, RedisAuthCounterStore } from '../src/auth/attemptBudget.js'; +import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js'; +import { createClient } from 'redis'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { RedisGatewaySessionService } from '../src/auth/redisSessionService.js'; +import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js'; +import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js'; + +const redisUrl = process.env.GATEWAY_SESSION_REDIS_TEST_URL; +const limit = 256; +const user = async () => { + const users = createInMemoryUserRepository(); + return users.createUser({ username: `session-${randomUUID()}`, password: 'synthetic-test-password' }); +}; + +describe.skipIf(!redisUrl)('game session lifecycle over real Redis', () => { + const client = createClient({ url: redisUrl }); + const prefix = `game-session-security-test:${randomUUID()}`; + const store = new RedisGatewaySessionService(client, { + keyPrefix: prefix, + sessionTtlSeconds: 600, + gameSessionTtlSeconds: 300, + }); + beforeAll(async () => { + await client.connect(); + }); + afterAll(async () => { + for await (const keys of client.scanIterator({ MATCH: `${prefix}:*`, COUNT: 100 })) { + if (keys.length) await client.del(keys); + } + await client.quit(); + }); + const keys = (token: string) => ({ + parent: `${prefix}:session:${token}`, + games: `${prefix}:session-games:${token}`, + }); + + const httpFixture = async () => { + const users = createInMemoryUserRepository(); + const first = await users.createUser({ username: `first-${randomUUID()}`, password: 'synthetic-password' }); + const second = await users.createUser({ username: `second-${randomUUID()}`, password: 'synthetic-password' }); + const parents = await Promise.all([ + store.createSession(first), + store.createSession(first), + store.createSession(second), + ]); + const profiles = { getProfile: vi.fn(async () => null) }; + const authBudget = new AuthAttemptBudget( + new RedisAuthCounterStore(client), + `${prefix}:${randomUUID()}`, + 'synthetic-rate-key' + ); + const passwordEnvelope = createPasswordEnvelopeService(); + const app = fastify({ trustProxy: false }); + await app.register(fastifyTRPCPlugin, { + prefix: '/trpc', + trpcOptions: { + router: appRouter, + ...trpcJsonBodyHttpServerOptions, + createContext: ({ req }: { req: FastifyRequest }) => + createGatewayApiContext({ + users, + sessions: store, + passwordEnvelope, + authBudget, + requestIp: req.ip, + requestHeaders: req.headers, + flushPublisher: { publishUserFlush: async () => {} }, + gameTokenSecret: 'synthetic-game-key', + gameSessionTtlSeconds: 300, + kakaoClient: {} as never, + oauthSessions: {} as never, + publicBaseUrl: 'http://localhost', + adminLocalAccountEnabled: true, + localRegistrationEnabled: true, + localAccountGraceDays: 7, + profiles: profiles as never, + orchestrator: {} as never, + profileStatus: {} as never, + prisma: {} as never, + }), + }, + }); + const address = await app.listen({ host: '127.0.0.1', port: 0 }); + const post = (operation: string, input: unknown) => + fetch(`${address}/trpc/${operation}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input), + }); + const issue = (index: number) => + post('auth.issueGameSession', { sessionToken: parents[index]!.sessionToken, profile: 'che:default' }); + return { app, post, issue, parents, profiles }; + }; + + it('limits real HTTP issuance by account across login sessions before profile/icon reads', async () => { + const f = await httpFixture(); + try { + for (let i = 0; i < 32; i++) expect((await f.issue(i % 2)).status).toBe(200); + const denied = await f.issue(1); + expect(denied.status).toBe(429); + expect(f.profiles.getProfile).toHaveBeenCalledTimes(32); + expect(JSON.stringify(await denied.json())).not.toContain('stack'); + expect((await f.issue(2)).status).toBe(200); + expect( + (await f.post('auth.issueGameSession', { sessionToken: 'unknown', profile: 'che:default' })).status + ).toBe(401); + } finally { + await f.app.close(); + } + }); + + it('maps the live record cap to HTTP 429 without invalidating existing tokens', async () => { + const f = await httpFixture(); + const parent = f.parents[0]!; + try { + const first = await store.createGameSession(parent.sessionToken, 'che:default'); + for (let i = 1; i < limit; i++) await store.createGameSession(parent.sessionToken, 'che:default'); + expect((await f.issue(0)).status).toBe(429); + expect(await store.getGameSession('che:default', first!.gameToken)).toEqual(first); + expect((await f.post('auth.logout', { sessionToken: parent.sessionToken })).status).toBe(200); + expect((await f.issue(0)).status).toBe(401); + expect(await client.sCard(keys(parent.sessionToken).games)).toBe(0); + } finally { + await f.app.close(); + } + }); + + it('preserves game records when explicitly revoking only the parent', async () => { + const parent = await store.createSession(await user()); + const game = await store.createGameSession(parent.sessionToken, 'che:default'); + await store.revokeSession(parent.sessionToken, { revokeGames: false }); + expect(await store.getSession(parent.sessionToken)).toBeNull(); + expect(await store.getGameSession('che:default', game!.gameToken)).toEqual(game); + }); + + it('atomically admits exactly 256 live records across concurrent profile requests', async () => { + const session = await store.createSession(await user()); + const outcomes = await Promise.allSettled( + Array.from({ length: limit + 32 }, (_, i) => + store.createGameSession(session.sessionToken, i % 2 ? 'che:default' : 'hwe:default') + ) + ); + expect(outcomes.filter((x) => x.status === 'fulfilled' && x.value)).toHaveLength(limit); + expect(outcomes.filter((x) => x.status === 'rejected')).toHaveLength(32); + expect(await client.sCard(keys(session.sessionToken).games)).toBe(limit); + }); + + it('removes expired membership before admitting a new game session', async () => { + const session = await store.createSession(await user()); + const key = keys(session.sessionToken).games; + const expiredKeys = Array.from({ length: limit }, (_, i) => `${prefix}:game-session:che:default:expired-${i}`); + await client.sAdd(key, expiredKeys); + expect(await store.createGameSession(session.sessionToken, 'che:default')).not.toBeNull(); + expect(await client.sCard(key)).toBe(1); + }); + + it('does not revive a parent revoked after its snapshot was read', async () => { + const session = await store.createSession(await user()); + const get = vi.spyOn(store, 'getSession'); + get.mockImplementationOnce(async () => { + await store.revokeSession(session.sessionToken); + return session; + }); + try { + expect(await store.createGameSession(session.sessionToken, 'che:default')).toBeNull(); + expect(await client.exists(keys(session.sessionToken).games)).toBe(0); + } finally { + get.mockRestore(); + } + }); + + it('preserves independent game TTL without resetting the index to a fresh parent TTL', async () => { + const session = await store.createSession(await user()); + const { parent, games } = keys(session.sessionToken); + await client.pExpire(parent, 1000); + const game = await store.createGameSession(session.sessionToken, 'che:default'); + expect(game).not.toBeNull(); + expect(await client.pTTL(games)).toBeGreaterThan(295_000); + expect(await client.pTTL(games)).toBeLessThanOrEqual(300_000); + }); + + it('revokes existing oversized indexes in bounded batches while preserving other parents', async () => { + const session = await store.createSession(await user()); + const other = await store.createSession(await user()); + const gameKeys = Array.from({ length: limit + 9 }, (_, i) => `${prefix}:game-session:che:default:legacy-${i}`); + const transaction = client.multi(); + for (const key of gameKeys) transaction.set(key, '{}', { EX: 300 }); + transaction.sAdd(keys(session.sessionToken).games, gameKeys); + await transaction.exec(); + await expect(store.createGameSession(session.sessionToken, 'che:default')).rejects.toThrow( + 'Game session limit' + ); + await store.revokeSession(session.sessionToken); + expect( + await client.exists([keys(session.sessionToken).parent, keys(session.sessionToken).games, ...gameKeys]) + ).toBe(0); + expect(await store.getSession(other.sessionToken)).not.toBeNull(); + }); +}); + +describe('in-memory game session lifecycle', () => { + it('enforces the same live cap and reclaims expired records', async () => { + const store = new InMemoryGatewaySessionService({ sessionTtlSeconds: 600, gameSessionTtlSeconds: 1 }); + const session = await store.createSession(await user()); + try { + vi.useFakeTimers(); + for (let i = 0; i < limit; i++) + expect(await store.createGameSession(session.sessionToken, 'che:default')).not.toBeNull(); + await expect(store.createGameSession(session.sessionToken, 'hwe:default')).rejects.toThrow( + 'Game session limit' + ); + vi.advanceTimersByTime(1001); + expect(await store.createGameSession(session.sessionToken, 'hwe:default')).not.toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + it('does not issue after a concurrent revoke', async () => { + const store = new InMemoryGatewaySessionService({ sessionTtlSeconds: 600, gameSessionTtlSeconds: 300 }); + const session = await store.createSession(await user()); + const get = vi.spyOn(store, 'getSession').mockImplementationOnce(async () => { + await store.revokeSession(session.sessionToken); + return session; + }); + try { + expect(await store.createGameSession(session.sessionToken, 'che:default')).toBeNull(); + } finally { + get.mockRestore(); + } + }); +}); diff --git a/docs/release-operations.md b/docs/release-operations.md index 8244609d..cbb02f78 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -578,6 +578,7 @@ batch의 항목도 각각 차감한다. 기본 창은 최초 허용 요청부터 | ----------------------------------------------------------------------- | ---------------------- | | 정규화한 로그인 계정명, peer와 무관 | 10회/60초 | | 같은 인증 actor의 비밀번호 변경·탈퇴, session을 바꿔도 공유 | 10회/60초 | +| 같은 계정의 게임 세션 발급, 로그인 session/profile을 바꿔도 공유 | 32회/60초 | | 인증 시작·등록·자격 증명·OTP와 public key/가입 필드 검사, socket peer별 | 600회/60초 | | 위 인증 procedure 전체, 같은 Gateway Redis namespace | 1200회/60초 | | RSA/Argon2/provider 호출이 있는 인증 작업, API 프로세스별 | 동시 4개, 대기 큐 없음 | @@ -614,10 +615,34 @@ Kakao transport는 각 HTTP 요청의 header와 body 전체에 10초 deadline, d 게임 시간·OTP 만료·기존 session lifetime·Argon2 강도와 무관하다. 이 단계는 온라인 자격 증명/가입 비용을 보호하며 established session의 일반 조회, -logout과 game-session 교환에 동일한 인증 시작 budget을 강제로 적용하지 않는다. +logout은 이 budget에서 제외한다. 게임 세션 발급은 별도 계정 counter 32회/60초를 +사용하고 peer/global counter와 확인 대기 상한을 공유하며 RSA/Argon2 동시 자리에는 +들어가지 않는다. 임시 운영 stack과 공개 운영/개발 stack은 Redis와 prefix를 공유하지 않으며 새 코드의 검증 결과를 실제 공개 배포 근거로 사용하지 않는다. +### 게임 세션 발급·추적 수명 (2026-10-09) + +Gateway 로그인 하나가 추적하는 살아 있는 게임 세션은 모든 profile을 합쳐 256개까지 +허용한다. 기존 게임 토큰을 강제로 폐기하지 않으며 초과 발급은 HTTP 429다. +발급 전에 만료된 Redis key를 추적 set에서 제거한다. 로그인별 추적 set이 이전 +버전에서 이미 256개보다 크다면 전부 펼치지 않고 새 발급을 거절하며, 로그아웃 뒤 +다시 로그인하거나 기존 index가 만료된 뒤 복구한다. 정상 다중 탭/여러 profile을 +지원하되 발급 자료의 무제한 누적을 허용하지 않는 Core 보안 정책이다. + +Redis Lua가 부모 session의 존재·snapshot 일치·만료, 정리·상한과 새 저장을 한 번에 +검사한다. 로그아웃은 부모 제거와 최대 64개 게임 key/index 삭제를 원자적으로 +반복하여, 발급 경합과 큰 기존 index의 일괄 응답을 방지한다. 부모만 회수하는 +`revokeGames:false`는 독립 게임 TTL을 유지한다. 게임 token TTL은 여전히 발급 시점부터 +`GAME_SESSION_TTL_SECONDS`이고 부모 로그인 만료가 이미 발급된 게임 token의 만료를 +앞당기지는 않는다. index TTL은 남은 부모 TTL과 게임 TTL 중 큰 값이며 새 부모 TTL로 +매번 초기화하지 않는다. 새로운 env key와 DB migration은 필요 없다. + +암호화 게임 token의 issuedAt/expiresAt은 추적 항목의 최초 발급 시각에서 계산한다. +이후 icon/identity 조회 중 logout/flush가 겹쳐도 issuedAt을 더 늦은 시각으로 +바꿔 이전 flush를 피하지 않는다. 이는 한 발급 flow의 경합 방어이며 pub/sub 누락이나 +API 재시작 후 회수 유지까지 증명하는 정책은 아니다. + ### Builder 요청 인증과 자원 경계 (2026-10-09) `RELEASE_BUILDER_URL`을 설정한 원격 build는 전용 `RELEASE_BUILDER_TOKEN_FILE`이