feat: implement user session management with Redis and add game token verification
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
export interface GatewayUserFlushEvent {
|
||||
userId: string;
|
||||
flushedAt: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface GatewayFlushPublisher {
|
||||
publishUserFlush(userId: string, reason?: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
|
||||
private readonly channel: string;
|
||||
private readonly client: { publish: (channel: string, message: string) => Promise<number> };
|
||||
|
||||
constructor(client: { publish: (channel: string, message: string) => Promise<number> }, channel: string) {
|
||||
this.client = client;
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
async publishUserFlush(userId: string, reason?: string): Promise<void> {
|
||||
const payload: GatewayUserFlushEvent = {
|
||||
userId,
|
||||
flushedAt: new Date().toISOString(),
|
||||
reason,
|
||||
};
|
||||
await this.client.publish(this.channel, JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,9 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
roles: user.roles,
|
||||
sanctions: user.sanctions,
|
||||
createdAt: user.createdAt,
|
||||
issuedAt: new Date().toISOString(),
|
||||
};
|
||||
this.sessions.set(sessionToken, {
|
||||
@@ -95,6 +98,9 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
|
||||
userId: session.userId,
|
||||
username: session.username,
|
||||
displayName: session.displayName,
|
||||
roles: session.roles,
|
||||
sanctions: session.sanctions,
|
||||
createdAt: session.createdAt,
|
||||
issuedAt: new Date().toISOString(),
|
||||
};
|
||||
const key = buildGameKey(profile, gameToken);
|
||||
|
||||
@@ -22,6 +22,8 @@ export const createInMemoryUserRepository = (
|
||||
id: randomUUID(),
|
||||
username: input.username,
|
||||
displayName: input.displayName ?? input.username,
|
||||
roles: ['user'],
|
||||
sanctions: {},
|
||||
passwordSalt: salt,
|
||||
passwordHash: hasher.hash(input.password, salt),
|
||||
createdAt: new Date().toISOString(),
|
||||
|
||||
@@ -62,6 +62,9 @@ export class RedisGatewaySessionService implements GatewaySessionService {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
roles: user.roles,
|
||||
sanctions: user.sanctions,
|
||||
createdAt: user.createdAt,
|
||||
issuedAt: new Date().toISOString(),
|
||||
};
|
||||
await this.client.set(this.keys.sessionKey(sessionToken), JSON.stringify(info), {
|
||||
@@ -114,6 +117,9 @@ export class RedisGatewaySessionService implements GatewaySessionService {
|
||||
userId: session.userId,
|
||||
username: session.username,
|
||||
displayName: session.displayName,
|
||||
roles: session.roles,
|
||||
sanctions: session.sanctions,
|
||||
createdAt: session.createdAt,
|
||||
issuedAt: new Date().toISOString(),
|
||||
};
|
||||
const gameKey = this.keys.gameSessionKey(profile, gameToken);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { UserRecord } from './userRepository.js';
|
||||
import type { UserRecord, UserSanctions } from './userRepository.js';
|
||||
|
||||
export interface GatewaySessionInfo {
|
||||
sessionToken: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
sanctions: UserSanctions;
|
||||
createdAt: string;
|
||||
issuedAt: string;
|
||||
}
|
||||
|
||||
@@ -15,6 +18,9 @@ export interface GameSessionInfo {
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
sanctions: UserSanctions;
|
||||
createdAt: string;
|
||||
issuedAt: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ export interface UserRecord {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
sanctions: UserSanctions;
|
||||
passwordHash: string;
|
||||
passwordSalt: string;
|
||||
createdAt: string;
|
||||
@@ -11,13 +13,24 @@ export interface PublicUser {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface UserSanctions {
|
||||
bannedUntil?: string;
|
||||
mutedUntil?: string;
|
||||
suspendedUntil?: string;
|
||||
warningCount?: number;
|
||||
flags?: string[];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export const toPublicUser = (user: UserRecord): PublicUser => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
roles: user.roles,
|
||||
createdAt: user.createdAt,
|
||||
});
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ export interface GatewayApiConfig {
|
||||
port: number;
|
||||
trpcPath: string;
|
||||
redisKeyPrefix: string;
|
||||
flushChannel: string;
|
||||
sessionTtlSeconds: number;
|
||||
gameSessionTtlSeconds: number;
|
||||
gameTokenSecret: string;
|
||||
}
|
||||
|
||||
const parseNumber = (value: string | undefined, fallback: number, label: string): number => {
|
||||
@@ -21,16 +23,23 @@ const parseNumber = (value: string | undefined, fallback: number, label: string)
|
||||
export const resolveGatewayApiConfigFromEnv = (
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): GatewayApiConfig => {
|
||||
const secret = env.GAME_TOKEN_SECRET ?? env.GATEWAY_TOKEN_SECRET ?? '';
|
||||
if (!secret) {
|
||||
throw new Error('GAME_TOKEN_SECRET is required for gateway token encryption.');
|
||||
}
|
||||
const redisKeyPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway';
|
||||
return {
|
||||
host: env.GATEWAY_API_HOST ?? '0.0.0.0',
|
||||
port: parseNumber(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT'),
|
||||
trpcPath: env.TRPC_PATH ?? '/trpc',
|
||||
redisKeyPrefix: env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway',
|
||||
redisKeyPrefix,
|
||||
flushChannel: `${redisKeyPrefix}:flush`,
|
||||
sessionTtlSeconds: parseNumber(env.SESSION_TTL_SECONDS, 60 * 60 * 24 * 7, 'SESSION_TTL_SECONDS'),
|
||||
gameSessionTtlSeconds: parseNumber(
|
||||
env.GAME_SESSION_TTL_SECONDS,
|
||||
60 * 60 * 6,
|
||||
'GAME_SESSION_TTL_SECONDS'
|
||||
),
|
||||
gameTokenSecret: secret,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import type { GatewayFlushPublisher } from './auth/flushPublisher.js';
|
||||
import type { GatewaySessionService } from './auth/sessionService.js';
|
||||
import type { UserRepository } from './auth/userRepository.js';
|
||||
|
||||
export interface GatewayApiContext {
|
||||
users: UserRepository;
|
||||
sessions: GatewaySessionService;
|
||||
flushPublisher: GatewayFlushPublisher;
|
||||
gameTokenSecret: string;
|
||||
gameSessionTtlSeconds: number;
|
||||
}
|
||||
|
||||
export const createGatewayApiContext = (options: {
|
||||
users: UserRepository;
|
||||
sessions: GatewaySessionService;
|
||||
flushPublisher: GatewayFlushPublisher;
|
||||
gameTokenSecret: string;
|
||||
gameSessionTtlSeconds: number;
|
||||
}): GatewayApiContext => ({
|
||||
users: options.users,
|
||||
sessions: options.sessions,
|
||||
flushPublisher: options.flushPublisher,
|
||||
gameTokenSecret: options.gameTokenSecret,
|
||||
gameSessionTtlSeconds: options.gameSessionTtlSeconds,
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ export * from './auth/sessionService.js';
|
||||
export * from './auth/inMemorySessionService.js';
|
||||
export * from './auth/redisSessionService.js';
|
||||
export * from './auth/redisKeys.js';
|
||||
export * from './auth/flushPublisher.js';
|
||||
|
||||
const isMain = (): boolean => {
|
||||
if (!process.argv[1]) {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
decryptGameSessionToken,
|
||||
encryptGameSessionToken,
|
||||
} from '@sammo-ts/common/auth/gameToken.js';
|
||||
|
||||
import { procedure, router } from './trpc.js';
|
||||
import { toPublicUser } from './auth/userRepository.js';
|
||||
|
||||
@@ -8,6 +13,14 @@ const zUsername = z.string().min(2).max(32);
|
||||
const zPassword = z.string().min(6).max(128);
|
||||
const zProfile = z.string().min(1).max(64);
|
||||
|
||||
const parseDate = (value: string): Date | null => {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const appRouter = router({
|
||||
health: router({
|
||||
ping: procedure.query(() => ({
|
||||
@@ -105,7 +118,11 @@ export const appRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await ctx.sessions.getSession(input.sessionToken);
|
||||
await ctx.sessions.revokeSession(input.sessionToken, { revokeGames: true });
|
||||
if (session) {
|
||||
await ctx.flushPublisher.publishUserFlush(session.userId, 'logout');
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
issueGameSession: procedure
|
||||
@@ -126,12 +143,40 @@ export const appRouter = router({
|
||||
message: 'Session is not valid.',
|
||||
});
|
||||
}
|
||||
const now = new Date();
|
||||
const payload = {
|
||||
version: 1,
|
||||
profile: gameSession.profile,
|
||||
issuedAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + 1000 * ctx.gameSessionTtlSeconds).toISOString(),
|
||||
sessionId: gameSession.gameToken,
|
||||
user: {
|
||||
id: gameSession.userId,
|
||||
username: gameSession.username,
|
||||
displayName: gameSession.displayName,
|
||||
roles: gameSession.roles,
|
||||
createdAt: gameSession.createdAt,
|
||||
},
|
||||
sanctions: gameSession.sanctions,
|
||||
} as const;
|
||||
const gameToken = encryptGameSessionToken(payload, ctx.gameTokenSecret);
|
||||
return {
|
||||
profile: gameSession.profile,
|
||||
gameToken: gameSession.gameToken,
|
||||
issuedAt: gameSession.issuedAt,
|
||||
gameToken,
|
||||
issuedAt: payload.issuedAt,
|
||||
};
|
||||
}),
|
||||
flushUser: procedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string().min(1),
|
||||
reason: z.string().min(1).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.flushPublisher.publishUserFlush(input.userId, input.reason);
|
||||
return { ok: true };
|
||||
}),
|
||||
validateGameSession: procedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -140,26 +185,26 @@ export const appRouter = router({
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const gameSession = await ctx.sessions.getGameSession(
|
||||
input.profile,
|
||||
input.gameToken
|
||||
);
|
||||
if (!gameSession) {
|
||||
const payload = decryptGameSessionToken(input.gameToken, ctx.gameTokenSecret);
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
const session = await ctx.sessions.getSession(gameSession.sessionToken);
|
||||
if (!session) {
|
||||
if (payload.profile !== input.profile) {
|
||||
return null;
|
||||
}
|
||||
const expiresAt = parseDate(payload.expiresAt);
|
||||
if (!expiresAt || Date.now() > expiresAt.getTime()) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
profile: gameSession.profile,
|
||||
sessionToken: gameSession.sessionToken,
|
||||
profile: payload.profile,
|
||||
sessionToken: payload.sessionId,
|
||||
user: {
|
||||
id: gameSession.userId,
|
||||
username: gameSession.username,
|
||||
displayName: gameSession.displayName,
|
||||
id: payload.user.id,
|
||||
username: payload.user.username,
|
||||
displayName: payload.user.displayName,
|
||||
},
|
||||
issuedAt: gameSession.issuedAt,
|
||||
issuedAt: payload.issuedAt,
|
||||
};
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra
|
||||
|
||||
import { resolveGatewayApiConfigFromEnv } from './config.js';
|
||||
import { createGatewayApiContext } from './context.js';
|
||||
import { RedisGatewayFlushPublisher } from './auth/flushPublisher.js';
|
||||
import { createInMemoryUserRepository } from './auth/inMemoryUserRepository.js';
|
||||
import { RedisGatewaySessionService } from './auth/redisSessionService.js';
|
||||
import { appRouter } from './router.js';
|
||||
@@ -20,6 +21,7 @@ export const createGatewayApiServer = async () => {
|
||||
sessionTtlSeconds: config.sessionTtlSeconds,
|
||||
gameSessionTtlSeconds: config.gameSessionTtlSeconds,
|
||||
});
|
||||
const flushPublisher = new RedisGatewayFlushPublisher(redis.client, config.flushChannel);
|
||||
|
||||
const app = fastify({
|
||||
logger: true,
|
||||
@@ -38,6 +40,9 @@ export const createGatewayApiServer = async () => {
|
||||
createGatewayApiContext({
|
||||
users,
|
||||
sessions,
|
||||
flushPublisher,
|
||||
gameTokenSecret: config.gameTokenSecret,
|
||||
gameSessionTtlSeconds: config.gameSessionTtlSeconds,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user