게임 토큰 회수를 Redis에 유지하고 재시작과 알림 누락을 방어한다
This commit is contained in:
@@ -1,3 +1,28 @@
|
||||
import {
|
||||
buildSessionRevocationBaselineKey,
|
||||
buildUserSessionRevocationKey,
|
||||
SessionRevocationCommandBudget,
|
||||
} from '@sammo-ts/common/auth/sessionRevocation';
|
||||
|
||||
interface RedisClientLike {
|
||||
readonly isReady?: boolean;
|
||||
set(key: string, value: string, options: { NX: true }): Promise<string | null>;
|
||||
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
||||
withAbortSignal?(signal: AbortSignal): RedisClientLike;
|
||||
}
|
||||
|
||||
// 지속 watermark를 먼저 저장한 뒤 알림을 보낸다. 구독자가 0이어도 회수는 유효하다.
|
||||
const PUBLISH_FLUSH_SCRIPT = `
|
||||
redis.call('SET', KEYS[1], ARGV[1], 'NX')
|
||||
local raw = redis.call('GET', KEYS[2])
|
||||
local prior = tonumber(raw)
|
||||
if raw and not prior then return redis.error_reply('Invalid session revocation state') end
|
||||
local cutoff = math.max(prior or 0, tonumber(ARGV[1]))
|
||||
redis.call('SET', KEYS[2], string.format('%.0f', cutoff))
|
||||
redis.call('PUBLISH', ARGV[2], ARGV[3])
|
||||
return 1
|
||||
`;
|
||||
|
||||
export interface GatewayUserFlushEvent {
|
||||
userId: string;
|
||||
flushedAt: string;
|
||||
@@ -17,13 +42,28 @@ export interface GatewayFlushPublisher {
|
||||
|
||||
export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
|
||||
private readonly channel: string;
|
||||
private readonly client: { publish: (channel: string, message: string) => Promise<number> };
|
||||
private readonly client: RedisClientLike;
|
||||
private readonly commands = new SessionRevocationCommandBudget(32);
|
||||
|
||||
constructor(client: { publish: (channel: string, message: string) => Promise<number> }, channel: string) {
|
||||
constructor(client: RedisClientLike, channel: string) {
|
||||
this.client = client;
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
private commandClient(): RedisClientLike {
|
||||
if (this.client.isReady === false) throw new Error('Session revocation storage is unavailable.');
|
||||
return this.client.withAbortSignal?.(AbortSignal.timeout(2000)) ?? this.client;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// 새 계약 최초 활성화에서만 기존 게임 credential을 회수한다. 재시작은 cutoff를 보존한다.
|
||||
await this.commands.run(() =>
|
||||
this.commandClient().set(buildSessionRevocationBaselineKey(this.channel), String(Date.now()), {
|
||||
NX: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async publishUserFlush(
|
||||
userId: string,
|
||||
reason?: string,
|
||||
@@ -37,6 +77,15 @@ export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
|
||||
...(metadata?.displayName ? { displayName: metadata.displayName } : {}),
|
||||
...(metadata?.identityRevision ? { identityRevision: metadata.identityRevision } : {}),
|
||||
};
|
||||
await this.client.publish(this.channel, JSON.stringify(payload));
|
||||
const result = await this.commands.run(() =>
|
||||
this.commandClient().eval(PUBLISH_FLUSH_SCRIPT, {
|
||||
keys: [
|
||||
buildSessionRevocationBaselineKey(this.channel),
|
||||
buildUserSessionRevocationKey(this.channel, userId),
|
||||
],
|
||||
arguments: [String(Date.parse(payload.flushedAt)), this.channel, JSON.stringify(payload)],
|
||||
})
|
||||
);
|
||||
if (result !== 1) throw new Error('Session revocation persistence failed.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1056,6 +1056,8 @@ export const appRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// 권한 snapshot보다 이후에 발생한 flush도 이 발급을 회수해야 한다.
|
||||
const authorizationStartedAt = Date.now();
|
||||
const gatewaySession = await ctx.sessions.getSession(input.sessionToken);
|
||||
if (!gatewaySession) {
|
||||
throw new TRPCError({
|
||||
@@ -1108,7 +1110,7 @@ export const appRouter = router({
|
||||
});
|
||||
}
|
||||
// 발급 뒤 logout/flush가 겹쳐도 issuedAt을 나중 시각으로 다시 쓰지 않는다.
|
||||
const now = new Date(gameSession.issuedAt);
|
||||
const now = new Date(Math.min(authorizationStartedAt, Date.parse(gameSession.issuedAt)));
|
||||
const accountIcon = resolveEffectiveAccountIcon(user);
|
||||
const accountIcons = await ctx.users.listIcons(user.id);
|
||||
const payload = {
|
||||
|
||||
@@ -73,6 +73,17 @@ export const createGatewayApiServer = async () => {
|
||||
config.gameTokenSecret
|
||||
);
|
||||
const flushPublisher = new RedisGatewayFlushPublisher(redis.client, config.flushChannel);
|
||||
try {
|
||||
await flushPublisher.initialize();
|
||||
} catch {
|
||||
await Promise.allSettled([
|
||||
Promise.resolve().then(() => {
|
||||
if (redis.client.isOpen) redis.client.destroy();
|
||||
}),
|
||||
postgres.disconnect(),
|
||||
]);
|
||||
throw new Error('Gateway session revocation initialization failed.');
|
||||
}
|
||||
const kakaoClient = new KakaoOAuthClient({
|
||||
restKey: config.kakaoRestKey,
|
||||
adminKey: config.kakaoAdminKey,
|
||||
|
||||
Reference in New Issue
Block a user