feat: implement user session management with Redis and add game token verification
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
export interface GatewayUserFlushEvent {
|
||||
userId: string;
|
||||
flushedAt: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface FlushStore {
|
||||
getFlushedAt(userId: string): Date | null;
|
||||
applyFlush(event: GatewayUserFlushEvent): void;
|
||||
}
|
||||
|
||||
export class InMemoryFlushStore implements FlushStore {
|
||||
private readonly flushedAtByUser = new Map<string, Date>();
|
||||
|
||||
getFlushedAt(userId: string): Date | null {
|
||||
return this.flushedAtByUser.get(userId) ?? null;
|
||||
}
|
||||
|
||||
applyFlush(event: GatewayUserFlushEvent): void {
|
||||
const parsed = new Date(event.flushedAt);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return;
|
||||
}
|
||||
const existing = this.flushedAtByUser.get(event.userId);
|
||||
if (!existing || parsed > existing) {
|
||||
this.flushedAtByUser.set(event.userId, parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RedisGatewayFlushSubscriber {
|
||||
private readonly client: {
|
||||
subscribe: (channel: string, listener: (message: string) => void) => Promise<void>;
|
||||
unsubscribe: (channel: string) => Promise<void>;
|
||||
};
|
||||
private readonly channel: string;
|
||||
private readonly store: FlushStore;
|
||||
|
||||
constructor(
|
||||
client: {
|
||||
subscribe: (channel: string, listener: (message: string) => void) => Promise<void>;
|
||||
unsubscribe: (channel: string) => Promise<void>;
|
||||
},
|
||||
channel: string,
|
||||
store: FlushStore
|
||||
) {
|
||||
this.client = client;
|
||||
this.channel = channel;
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.client.subscribe(this.channel, (message) => {
|
||||
try {
|
||||
const payload = JSON.parse(message) as GatewayUserFlushEvent;
|
||||
if (!payload || typeof payload.userId !== 'string') {
|
||||
return;
|
||||
}
|
||||
this.store.applyFlush(payload);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await this.client.unsubscribe(this.channel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken.js';
|
||||
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken.js';
|
||||
|
||||
import type { FlushStore } from './flushStore.js';
|
||||
|
||||
export interface GameTokenVerifier {
|
||||
verify(token: string): GameSessionTokenPayload | null;
|
||||
}
|
||||
|
||||
const parseDate = (value: string): Date | null => {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const createGameTokenVerifier = (options: {
|
||||
secret: string;
|
||||
profileName: string;
|
||||
flushStore: FlushStore;
|
||||
}): GameTokenVerifier => {
|
||||
return {
|
||||
verify: (token: string): GameSessionTokenPayload | null => {
|
||||
const payload = decryptGameSessionToken(token, options.secret);
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
if (payload.profile !== options.profileName) {
|
||||
return null;
|
||||
}
|
||||
const expiresAt = parseDate(payload.expiresAt);
|
||||
const issuedAt = parseDate(payload.issuedAt);
|
||||
if (!expiresAt || !issuedAt) {
|
||||
return null;
|
||||
}
|
||||
if (Date.now() > expiresAt.getTime()) {
|
||||
return null;
|
||||
}
|
||||
const flushedAt = options.flushStore.getFlushedAt(payload.user.id);
|
||||
if (flushedAt && issuedAt <= flushedAt) {
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user