feat: implement Redis session management and user authentication flow

This commit is contained in:
2025-12-30 01:38:09 +00:00
parent 1688ca0d23
commit 29523ef22f
20 changed files with 820 additions and 5 deletions
@@ -0,0 +1,123 @@
import { randomUUID } from 'node:crypto';
import type {
GameSessionInfo,
GatewaySessionConfig,
GatewaySessionInfo,
GatewaySessionService,
SessionRevocationOptions,
} from './sessionService.js';
import type { UserRecord } from './userRepository.js';
interface StoredSession {
info: GatewaySessionInfo;
expiresAt: number;
}
interface StoredGameSession {
info: GameSessionInfo;
expiresAt: number;
}
const buildGameKey = (profile: string, gameToken: string): string => `${profile}:${gameToken}`;
// 세션 TTL 동작을 테스트할 수 있도록 메모리 기반 구현을 제공한다.
export class InMemoryGatewaySessionService implements GatewaySessionService {
private readonly sessions = new Map<string, StoredSession>();
private readonly gameSessions = new Map<string, StoredGameSession>();
private readonly sessionGames = new Map<string, Set<string>>();
private readonly sessionTtlMs: number;
private readonly gameSessionTtlMs: number;
constructor(config: GatewaySessionConfig) {
this.sessionTtlMs = config.sessionTtlSeconds * 1000;
this.gameSessionTtlMs = config.gameSessionTtlSeconds * 1000;
}
async createSession(user: UserRecord): Promise<GatewaySessionInfo> {
const sessionToken = randomUUID();
const info: GatewaySessionInfo = {
sessionToken,
userId: user.id,
username: user.username,
displayName: user.displayName,
issuedAt: new Date().toISOString(),
};
this.sessions.set(sessionToken, {
info,
expiresAt: Date.now() + this.sessionTtlMs,
});
return info;
}
async getSession(sessionToken: string): Promise<GatewaySessionInfo | null> {
const stored = this.sessions.get(sessionToken);
if (!stored) {
return null;
}
if (Date.now() > stored.expiresAt) {
this.sessions.delete(sessionToken);
this.sessionGames.delete(sessionToken);
return null;
}
return stored.info;
}
async revokeSession(
sessionToken: string,
options: SessionRevocationOptions = { revokeGames: true }
): Promise<void> {
if (options.revokeGames ?? true) {
const gameKeys = this.sessionGames.get(sessionToken);
if (gameKeys) {
for (const key of gameKeys) {
this.gameSessions.delete(key);
}
}
this.sessionGames.delete(sessionToken);
}
this.sessions.delete(sessionToken);
}
async createGameSession(
sessionToken: string,
profile: string
): Promise<GameSessionInfo | null> {
const session = await this.getSession(sessionToken);
if (!session) {
return null;
}
const gameToken = randomUUID();
const info: GameSessionInfo = {
profile,
gameToken,
sessionToken,
userId: session.userId,
username: session.username,
displayName: session.displayName,
issuedAt: new Date().toISOString(),
};
const key = buildGameKey(profile, gameToken);
this.gameSessions.set(key, {
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;
}
async getGameSession(profile: string, gameToken: string): Promise<GameSessionInfo | null> {
const key = buildGameKey(profile, gameToken);
const stored = this.gameSessions.get(key);
if (!stored) {
return null;
}
if (Date.now() > stored.expiresAt) {
this.gameSessions.delete(key);
return null;
}
return stored.info;
}
}
@@ -0,0 +1,36 @@
import { randomUUID } from 'node:crypto';
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
import type { CreateUserInput, UserRecord, UserRepository } from './userRepository.js';
// 유저 데이터 저장소를 메모리로 대체한 임시 구현.
export const createInMemoryUserRepository = (
hasher: PasswordHasher = createSimplePasswordHasher()
): UserRepository => {
const usersByName = new Map<string, UserRecord>();
return {
async findByUsername(username: string): Promise<UserRecord | null> {
return usersByName.get(username) ?? null;
},
async createUser(input: CreateUserInput): Promise<UserRecord> {
if (usersByName.has(input.username)) {
throw new Error('User already exists.');
}
const salt = hasher.createSalt();
const user: UserRecord = {
id: randomUUID(),
username: input.username,
displayName: input.displayName ?? input.username,
passwordSalt: salt,
passwordHash: hasher.hash(input.password, salt),
createdAt: new Date().toISOString(),
};
usersByName.set(input.username, user);
return user;
},
async verifyPassword(user: UserRecord, password: string): Promise<boolean> {
return hasher.hash(password, user.passwordSalt) === user.passwordHash;
},
};
};
@@ -0,0 +1,13 @@
import { createHash, randomBytes } from 'node:crypto';
export interface PasswordHasher {
createSalt(): string;
hash(password: string, salt: string): string;
}
// 비밀번호 해싱은 임시 구현이므로 이후 안전한 KDF로 교체한다.
export const createSimplePasswordHasher = (): PasswordHasher => ({
createSalt: () => randomBytes(16).toString('hex'),
hash: (password: string, salt: string) =>
createHash('sha256').update(`${salt}:${password}`).digest('hex'),
});
+12
View File
@@ -0,0 +1,12 @@
export interface GatewayRedisKeyBuilder {
sessionKey(sessionToken: string): string;
sessionGameSetKey(sessionToken: string): string;
gameSessionKey(profile: string, gameToken: string): string;
}
export const createGatewayRedisKeyBuilder = (prefix: string): GatewayRedisKeyBuilder => ({
sessionKey: (sessionToken: string) => `${prefix}:session:${sessionToken}`,
sessionGameSetKey: (sessionToken: string) => `${prefix}:session-games:${sessionToken}`,
gameSessionKey: (profile: string, gameToken: string) =>
`${prefix}:game-session:${profile}:${gameToken}`,
});
@@ -0,0 +1,134 @@
import { randomUUID } from 'node:crypto';
import { createGatewayRedisKeyBuilder } from './redisKeys.js';
import type {
GameSessionInfo,
GatewaySessionConfig,
GatewaySessionInfo,
GatewaySessionService,
SessionRevocationOptions,
} from './sessionService.js';
import type { UserRecord } from './userRepository.js';
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;
del(key: string): Promise<number>;
}
const parseJson = <T>(raw: string | null): T | null => {
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
};
// Redis 세션 저장소는 게이트웨이와 게임 서버 간 SSO 토큰을 관리한다.
export class RedisGatewaySessionService implements GatewaySessionService {
private readonly client: RedisClientLike;
private readonly keys: ReturnType<typeof createGatewayRedisKeyBuilder>;
private readonly sessionTtlSeconds: number;
private readonly gameSessionTtlSeconds: number;
constructor(client: RedisClientLike, options: RedisGatewaySessionOptions) {
this.client = client;
this.keys = createGatewayRedisKeyBuilder(options.keyPrefix);
this.sessionTtlSeconds = options.sessionTtlSeconds;
this.gameSessionTtlSeconds = options.gameSessionTtlSeconds;
}
async createSession(user: UserRecord): Promise<GatewaySessionInfo> {
const sessionToken = randomUUID();
const info: GatewaySessionInfo = {
sessionToken,
userId: user.id,
username: user.username,
displayName: user.displayName,
issuedAt: new Date().toISOString(),
};
await this.client.set(this.keys.sessionKey(sessionToken), JSON.stringify(info), {
EX: this.sessionTtlSeconds,
});
return info;
}
async getSession(sessionToken: string): Promise<GatewaySessionInfo | null> {
const raw = await this.client.get(this.keys.sessionKey(sessionToken));
return parseJson<GatewaySessionInfo>(raw);
}
async revokeSession(
sessionToken: string,
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);
}
await this.client.del(key);
}
async createGameSession(
sessionToken: string,
profile: string
): Promise<GameSessionInfo | null> {
const session = await this.getSession(sessionToken);
if (!session) {
return null;
}
const gameToken = randomUUID();
const info: GameSessionInfo = {
profile,
gameToken,
sessionToken,
userId: session.userId,
username: session.username,
displayName: session.displayName,
issuedAt: new Date().toISOString(),
};
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();
return info;
}
async getGameSession(profile: string, gameToken: string): Promise<GameSessionInfo | null> {
const raw = await this.client.get(this.keys.gameSessionKey(profile, gameToken));
return parseJson<GameSessionInfo>(raw);
}
}
@@ -0,0 +1,36 @@
import type { UserRecord } from './userRepository.js';
export interface GatewaySessionInfo {
sessionToken: string;
userId: string;
username: string;
displayName: string;
issuedAt: string;
}
export interface GameSessionInfo {
profile: string;
gameToken: string;
sessionToken: string;
userId: string;
username: string;
displayName: string;
issuedAt: string;
}
export interface GatewaySessionConfig {
sessionTtlSeconds: number;
gameSessionTtlSeconds: number;
}
export interface SessionRevocationOptions {
revokeGames?: boolean;
}
export interface GatewaySessionService {
createSession(user: UserRecord): Promise<GatewaySessionInfo>;
getSession(sessionToken: string): Promise<GatewaySessionInfo | null>;
revokeSession(sessionToken: string, options?: SessionRevocationOptions): Promise<void>;
createGameSession(sessionToken: string, profile: string): Promise<GameSessionInfo | null>;
getGameSession(profile: string, gameToken: string): Promise<GameSessionInfo | null>;
}
@@ -0,0 +1,34 @@
export interface UserRecord {
id: string;
username: string;
displayName: string;
passwordHash: string;
passwordSalt: string;
createdAt: string;
}
export interface PublicUser {
id: string;
username: string;
displayName: string;
createdAt: string;
}
export const toPublicUser = (user: UserRecord): PublicUser => ({
id: user.id,
username: user.username,
displayName: user.displayName,
createdAt: user.createdAt,
});
export interface CreateUserInput {
username: string;
password: string;
displayName?: string;
}
export interface UserRepository {
findByUsername(username: string): Promise<UserRecord | null>;
createUser(input: CreateUserInput): Promise<UserRecord>;
verifyPassword(user: UserRecord, password: string): Promise<boolean>;
}