게임 세션의 원자적 발급과 추적 상한 및 회수를 보강한다

This commit is contained in:
2026-10-09 05:10:05 +09:00
parent 6755f09ee7
commit eeda225429
9 changed files with 399 additions and 44 deletions
+12 -3
View File
@@ -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;
}
+53 -31
View File
@@ -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();
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<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;
+13 -3
View File
@@ -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 = {
+6 -2
View File
@@ -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;
};