Skip to content

Instantly share code, notes, and snippets.

@trvswgnr
Created August 8, 2023 04:35
Show Gist options
  • Save trvswgnr/941273f6362fe4584982f64603831bf7 to your computer and use it in GitHub Desktop.
Save trvswgnr/941273f6362fe4584982f64603831bf7 to your computer and use it in GitHub Desktop.
session management with redis store for user auth
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto';
import { createConnection, Socket } from 'net';
import { promisify } from 'util';
type SessionData = {
userId: string;
expiresAt: number;
};
interface IConfigService {
get(key: string): string;
}
interface ILoggerService {
log(message: string): void;
error(message: string): void;
}
interface IRetryService {
execute<T>(fn: () => Promise<T>, retries: number): Promise<T>;
}
interface IKeyManagementService {
getEncryptionKey(): Promise<string>;
}
class RedisStore {
private pool: Socket[] = [];
private readonly ALGORITHM = 'aes-256-gcm';
private readonly IV_LENGTH = 16;
private readonly AUTH_TAG_LENGTH = 16;
constructor(
private configService: IConfigService,
private loggerService: ILoggerService,
private retryService: IRetryService,
private keyManagementService: IKeyManagementService
) {
const poolSize = Number(this.configService.get('POOL_SIZE'));
for (let i = 0; i < poolSize; i++) {
const client = createConnection({
host: this.configService.get('REDIS_HOST'),
port: Number(this.configService.get('REDIS_PORT'))
});
client.on('error', this.handleConnectionError);
this.pool.push(client);
}
}
private handleConnectionError = (error: Error) => {
this.loggerService.error('Redis connection error: ' + error.message);
const client = createConnection({
host: this.configService.get('REDIS_HOST'),
port: Number(this.configService.get('REDIS_PORT'))
});
client.on('error', this.handleConnectionError);
this.pool.push(client);
};
private async getClient(): Promise<Socket> {
while (this.pool.length === 0) {
await promisify(setTimeout)(1000);
}
return this.pool.pop()!;
}
private releaseClient(client: Socket) {
this.pool.push(client);
}
async get(key: string): Promise<SessionData | null> {
const client = await this.getClient();
return this.retryService.execute(() => new Promise((resolve, reject) => {
client.write(`GET ${key}\r\n`);
client.once('data', (data) => {
const result = data.toString();
if (result === '$-1\r\n') {
resolve(null);
} else {
const decrypted = this.decrypt(result.slice(5, -5));
resolve(JSON.parse(decrypted));
}
this.releaseClient(client);
});
}), 3);
}
async set(key: string, value: SessionData, ttl: number): Promise<void> {
const client = await this.getClient();
return this.retryService.execute(() => new Promise((resolve, reject) => {
const encrypted = this.encrypt(JSON.stringify(value));
client.write(`SET ${key} ${encrypted} EX ${ttl}\r\n`);
client.once('data', (data) => {
const result = data.toString();
if (result === '+OK\r\n') {
resolve();
} else {
reject(new Error(`Failed to set key ${key}: ${result}`));
}
this.releaseClient(client);
});
}), 3);
}
async delete(key: string): Promise<void> {
const client = await this.getClient();
return this.retryService.execute(() => new Promise((resolve, reject) => {
client.write(`DEL ${key}\r\n`);
client.once('data', (data) => {
resolve();
this.releaseClient(client);
});
}), 3);
}
private encrypt(text: string) {
const iv = randomBytes(this.IV_LENGTH);
const cipher = createCipheriv(this.ALGORITHM, this.keyManagementService.getEncryptionKey(), iv);
const encrypted = Buffer.concat([cipher.update(text), cipher.final()]);
return iv.toString('hex') + ':' + encrypted.toString('hex') + ':' + cipher.getAuthTag().toString('hex');
}
private decrypt(text: string) {
const textParts = text.split(':');
const iv = Buffer.from(textParts.shift()!, 'hex');
const encryptedText = Buffer.from(textParts.shift()!, 'hex');
const authTag = Buffer.from(textParts.shift()!, 'hex');
const decipher = createDecipheriv(this.ALGORITHM, this.keyManagementService.getEncryptionKey(), iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(encryptedText), decipher.final()]);
return decrypted.toString();
}
}
class SessionManager {
constructor(
private store: RedisStore,
private configService: IConfigService,
private loggerService: ILoggerService
) {}
async createSession(userId: string): Promise<string> {
let sessionId = this.generateSessionId();
while (await this.store.get(sessionId)) {
sessionId = this.generateSessionId();
}
const expiresAt = Date.now() + Number(this.configService.get('SESSION_LIFETIME'));
try {
await this.store.set(sessionId, { userId, expiresAt }, Number(this.configService.get('SESSION_LIFETIME')));
} catch (error) {
this.loggerService.error(`Failed to create session for user ${userId}: ` + error.message);
throw error;
}
return sessionId;
}
async getSession(sessionId: string): Promise<SessionData | null> {
try {
return await this.store.get(sessionId);
} catch (error) {
this.loggerService.error(`Failed to get session ${sessionId}: ` + error.message);
throw error;
}
}
async deleteSession(sessionId: string): Promise<void> {
try {
return await this.store.delete(sessionId);
} catch (error) {
this.loggerService.error(`Failed to delete session ${sessionId}: ` + error.message);
throw error;
}
}
async extendSession(sessionId: string): Promise<void> {
try {
const session = await this.getSession(sessionId);
if (session) {
session.expiresAt = Date.now() + Number(this.configService.get('SESSION_LIFETIME'));
await this.store.set(sessionId, session, Number(this.configService.get('SESSION_LIFETIME')));
}
} catch (error) {
this.loggerService.error(`Failed to extend session ${sessionId}: ` + error.message);
throw error;
}
}
private generateSessionId(): string {
return randomBytes(24).toString('hex');
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment