게임 세션의 원자적 발급과 추적 상한 및 회수를 보강한다
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<GameSessionInfo | null> {
|
||||
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<string>();
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<unknown>;
|
||||
}
|
||||
|
||||
interface RedisClientLike {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string, options?: { EX?: number }): Promise<unknown>;
|
||||
sMembers(key: string): Promise<string[]>;
|
||||
multi(): RedisPipeline;
|
||||
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
||||
del(key: string): Promise<number>;
|
||||
}
|
||||
|
||||
// 부모 확인·만료 항목 정리·상한·새 항목 저장을 한 원자적 경계에서 수행한다.
|
||||
// 기존 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<void> {
|
||||
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();
|
||||
if (!(options.revokeGames ?? true)) {
|
||||
await this.client.del(key);
|
||||
return;
|
||||
}
|
||||
await this.client.del(gameSetKey);
|
||||
// 부모 제거와 각 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<GameSessionInfo | null> {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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`이
|
||||
|
||||
Reference in New Issue
Block a user