게임 세션의 원자적 발급과 추적 상한 및 회수를 보강한다
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();
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user